All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m48s
The root bug of this chantier. `import_sources` carried no `amount_mode` and no `sign_convention` until v17, so restoring a configured source re-inferred the mode from `mapping.debitAmount !== undefined` and wrote `signConvention: "negative_expense"` outright (useImportWizard.ts:321-323). A credit-card statement configured for positive expenses came back on the default convention at its second import and `parseFilesInternal` negated every amount: expenses landed as income, with no error shown anywhere. The format is a read value now, not a guessed one. Two types and a codec, not one composed type. The four carriers are structurally incompatible -- `ImportSource.has_header` is declared boolean, `ImportConfigTemplate.has_header` is a number, `SourceConfig` is camelCase on a parsed mapping -- so the guarantee cannot come from a shared shape. It comes from `src/utils/importFormat.ts` being the single conversion point between `ImportFormatRow` (persisted: snake_case, mapping as JSON, `has_header` normalized to 0/1) and `ImportFormat` (domain), and from its completeness test. That test is enforced on two levels, and both were mutation-checked: `FORMAT_FIELD_PAIRS` is typed `Record<keyof ImportFormat, keyof ImportFormatRow>`, so a field added to the format fails to BUILD until it is listed; the test then compares each codec's real output keys against that table, so a field listed but not wired fails the TEST. Dropping `sign_convention` from `formatToRow` -- the shape of the original bug -- fails 14 tests. `formatFromRow` validates rather than falls back. The v17 CHECK admits `absolute_indicator` so the third amount mode ships without another migration, but the app cannot map one: falling through to the `single` branch would read the wrong column for every row, and anything other than `positive_expense` would silently mean `negative_expense`. It raises an `ImportFormatError` carrying an i18n key, and the wizard opens on a fresh configuration so "reconfigure this source" stays an action the user can actually take. Also here: - The config write moves from `checkDuplicatesInternal` to `executeImport`, so an import abandoned at the duplicate step leaves no configuration behind. It is the only write point in the hook and a guard test holds that. - Switching amount mode prunes the abandoned mode's columns, so the mode owns the mapping rather than the reverse. The column the `<select>` merely displays is deliberately not materialized -- #325 turns an unmapped amount column into an explicit row error, and writing a 0 here would make it unreachable. The mode and the pruned mapping land in ONE state update: the panel's handlers each spread the same `config` prop, so two calls would see the same stale value. - `template_id` is recorded and restored as provenance only, never re-read as format. `selectedTemplateId` is no longer blanked on every source selection. An acceptance test rewrites a template end to end and asserts the linked source reads identically, plus a non-vacuity check that the template really changed. - Both template writers go through the codec too, so a new format field cannot reach one table and miss the other. `parseFilesInternal` is untouched: link 1's static guard on its five pinned expressions still passes. 39 new tests (963 vitest total, was 924), build clean, `cargo check` clean, no migration. Resolves #324
262 lines
9.5 KiB
TypeScript
262 lines
9.5 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
|
|
function makeFakeDb() {
|
|
const tables: Record<string, Row[]> = {
|
|
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<typeof makeFakeDb>;
|
|
|
|
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<SourceConfig> {
|
|
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<keyof ImportFormat>) {
|
|
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);
|
|
});
|
|
});
|