/** * Import format — save/reload integration (#324). * * The unit tests in `src/utils/importFormat.test.ts` prove the codec carries * every field. This file proves the SQL on either side of it does too: a format * written by `importSourceService` and read back is the same format, field by * field. That is the test that would have caught the root bug — before v17 the * INSERT simply had no `amount_mode` / `sign_convention` column to write to, so * a positive-expense source silently came back inverted on its second import. * * Like `balance-flow.test.ts`, real `tauri-plugin-sql` cannot be started outside * the Tauri WebView, so the services run against an in-memory FakeDb that * interprets the handful of statements they actually issue. It stores what it * is given, so `has_header` lands as the 0/1 integer SQLite stores and comes * back as one — the exact shape the codec has to absorb. */ import { describe, it, expect, beforeEach, vi } from "vitest"; vi.mock("../services/db", () => { const getDb = vi.fn(); return { getDb, withTransaction: vi.fn(async (fn: (db: unknown) => unknown) => fn(await getDb())), }; }); import { getDb } from "../services/db"; import { createSource, getSourceByName, updateSource, } from "../services/importSourceService"; import { createTemplate, getAllTemplates, updateTemplate, } from "../services/importConfigTemplateService"; import { formatFromRow, formatToRow, FORMAT_FIELD_PAIRS } from "../utils/importFormat"; import type { ImportFormat, SourceConfig } from "../shared/types"; // --------------------------------------------------------------------------- // FakeDb — a tiny interpreter for the statements these two services issue. // --------------------------------------------------------------------------- type Row = Record; function makeFakeDb() { const tables: Record = { import_sources: [], import_config_templates: [], }; let nextId = 1; const insert = (sql: string, params: unknown[]) => { const [, table, cols] = /INSERT INTO (\w+) \(([^)]+)\)/.exec(sql) ?? []; const columns = cols.split(",").map((c) => c.trim()); const row: Row = { id: nextId++ }; columns.forEach((c, i) => (row[c] = params[i])); if (/ON CONFLICT\(name\) DO UPDATE/.test(sql)) { const clash = tables[table].find((r) => r.name === row.name); if (clash) { // Mimic `excluded.*`: overwrite every listed column, keep the id. columns.forEach((c, i) => (clash[c] = params[i])); nextId--; return { lastInsertId: 0, rowsAffected: 1 }; } } tables[table].push(row); return { lastInsertId: row.id as number, rowsAffected: 1 }; }; const update = (sql: string, params: unknown[]) => { const [, table, assignments] = /UPDATE (\w+)\s+SET ([\s\S]+?)\s+WHERE id\s*=\s*\$(\d+)/.exec(sql) ?? []; const id = params[params.length - 1]; const row = tables[table].find((r) => r.id === id); if (!row) return { rowsAffected: 0 }; for (const [, column, index] of assignments.matchAll( /(\w+)\s*=\s*\$(\d+)/g )) { row[column] = params[Number(index) - 1]; } return { rowsAffected: 1 }; }; return { tables, execute: vi.fn(async (sql: string, params: unknown[] = []) => { if (sql.trimStart().startsWith("INSERT")) return insert(sql, params); if (sql.trimStart().startsWith("UPDATE")) return update(sql, params); throw new Error(`FakeDb: unsupported statement ${sql.slice(0, 40)}`); }), select: vi.fn(async (sql: string, params: unknown[] = []) => { const [, table] = /FROM (\w+)/.exec(sql) ?? []; const rows = tables[table]; if (/WHERE name = \$1/.test(sql)) return rows.filter((r) => r.name === params[0]); if (/WHERE id = \$1/.test(sql)) return rows.filter((r) => r.id === params[0]); return [...rows].sort((a, b) => String(a.name).localeCompare(String(b.name))); }), }; } let db: ReturnType; beforeEach(() => { db = makeFakeDb(); vi.mocked(getDb).mockResolvedValue(db as never); }); // --------------------------------------------------------------------------- // Fixtures — a credit-card statement: expenses are POSITIVE. This is the // configuration the old code could not keep. // --------------------------------------------------------------------------- const CREDIT_CARD: SourceConfig = { name: "Visa Desjardins", delimiter: ",", encoding: "windows-1252", dateFormat: "YYYY-MM-DD", skipLines: 2, hasHeader: false, columnMapping: { date: 1, description: 3, amount: 4 }, amountMode: "single", signConvention: "positive_expense", }; /** What `useImportWizard.selectSource` does on a stored source. */ async function reload(name: string): Promise { const stored = await getSourceByName(name); if (!stored) throw new Error(`source ${name} not found`); return { name: stored.name, ...formatFromRow(stored) }; } function expectSameFormat(actual: ImportFormat, expected: ImportFormat) { for (const field of Object.keys(FORMAT_FIELD_PAIRS) as Array) { expect(actual[field], `field ${field}`).toEqual(expected[field]); } } // --------------------------------------------------------------------------- describe("configure -> save -> reload (#324)", () => { it("returns the saved format field by field", async () => { await createSource({ name: CREDIT_CARD.name, ...formatToRow(CREDIT_CARD) }); expectSameFormat(await reload(CREDIT_CARD.name), CREDIT_CARD); }); it("keeps positive_expense on the second import and every one after", async () => { await createSource({ name: CREDIT_CARD.name, ...formatToRow(CREDIT_CARD) }); // Three consecutive imports re-save what was reloaded, as the wizard does. let current = await reload(CREDIT_CARD.name); for (let i = 0; i < 3; i++) { const id = (await getSourceByName(CREDIT_CARD.name))!.id; await updateSource(id, { name: current.name, ...formatToRow(current) }); current = await reload(CREDIT_CARD.name); } expect(current.signConvention).toBe("positive_expense"); expectSameFormat(current, CREDIT_CARD); }); it("stores has_header as the integer SQLite holds, not a JS boolean", async () => { await createSource({ name: CREDIT_CARD.name, ...formatToRow(CREDIT_CARD) }); expect(db.tables.import_sources[0].has_header).toBe(0); expect((await reload(CREDIT_CARD.name)).hasHeader).toBe(false); }); it("keeps the chosen mode when the mapping does not betray it", async () => { const debitCredit: SourceConfig = { ...CREDIT_CARD, amountMode: "debit_credit", // No debitAmount: the old restore re-inferred "single" from this shape. columnMapping: { date: 1, description: 3, creditAmount: 5 }, }; await createSource({ name: debitCredit.name, ...formatToRow(debitCredit) }); expect((await reload(debitCredit.name)).amountMode).toBe("debit_credit"); }); it("updates the format in place when the source is re-configured", async () => { const id = await createSource({ name: CREDIT_CARD.name, ...formatToRow(CREDIT_CARD), }); const flipped: SourceConfig = { ...CREDIT_CARD, signConvention: "negative_expense", amountMode: "debit_credit", columnMapping: { date: 1, description: 3, debitAmount: 4, creditAmount: 5 }, }; await updateSource(id, { name: flipped.name, ...formatToRow(flipped) }); expectSameFormat(await reload(CREDIT_CARD.name), flipped); expect(db.tables.import_sources).toHaveLength(1); }); }); describe("template_id is provenance, never format (#324)", () => { async function seedLinkedPair() { const templateId = await createTemplate({ name: "Desjardins carte", ...formatToRow(CREDIT_CARD), }); await createSource({ name: CREDIT_CARD.name, ...formatToRow(CREDIT_CARD), template_id: templateId, }); return templateId; } it("records the template the source was configured from", async () => { const templateId = await seedLinkedPair(); expect((await getSourceByName(CREDIT_CARD.name))!.template_id).toBe(templateId); }); // The acceptance criterion: the eight source columns are authoritative, so a // template edit cannot reach through the link and change how a source reads. it("editing the template alters no linked source", async () => { const templateId = await seedLinkedPair(); await updateTemplate(templateId, { name: "Desjardins carte", ...formatToRow({ ...CREDIT_CARD, delimiter: "\t", encoding: "utf-8", dateFormat: "DD/MM/YYYY", skipLines: 0, hasHeader: true, columnMapping: { date: 0, description: 1, debitAmount: 2, creditAmount: 3 }, amountMode: "debit_credit", signConvention: "negative_expense", }), }); expectSameFormat(await reload(CREDIT_CARD.name), CREDIT_CARD); // …and the template really did change, so the test is not vacuous. const [stored] = await getAllTemplates(); expect(formatFromRow(stored).signConvention).toBe("negative_expense"); expect(formatFromRow(stored).amountMode).toBe("debit_credit"); }); it("survives a re-import that re-saves the format", async () => { const templateId = await seedLinkedPair(); const id = (await getSourceByName(CREDIT_CARD.name))!.id; const current = await reload(CREDIT_CARD.name); await updateSource(id, { name: current.name, ...formatToRow(current), template_id: templateId, }); expect((await getSourceByName(CREDIT_CARD.name))!.template_id).toBe(templateId); }); });