// importFormat — the format codec (#324). // // The chantier's root bug was a format field that never reached the database // and a restore that invented a value for it. These tests hold the two halves // of the repair: the codec carries EVERY field in both directions, and the // wizard restores what was saved instead of re-deriving it. import { describe, it, expect } from "vitest"; import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import type { ImportFormat, ImportFormatRow, ImportFormatRowInput, ParsedRow, } from "../shared/types"; import { AMOUNT_MODES, FORMAT_FIELD_PAIRS, ImportFormatError, ROW_ERROR_KEYS, SIGN_CONVENTIONS, clearMappingForMode, detectAmountSeparators, flipSignFormat, formatFromRow, formatToRow, isRowErrorKey, mapRow, pickFormatRow, summarizeParsedRows, } from "./importFormat"; import fr from "../i18n/locales/fr.json"; import en from "../i18n/locales/en.json"; // --------------------------------------------------------------------------- // Samples. Every field deliberately differs from the wizard's default config, // so a dropped field surfaces as a mismatch rather than as an accidental pass. // --------------------------------------------------------------------------- const SAMPLE_FORMAT: ImportFormat = { delimiter: ",", encoding: "windows-1252", dateFormat: "YYYY-MM-DD", skipLines: 3, hasHeader: false, columnMapping: { date: 2, description: 5, amount: 7 }, amountMode: "single", signConvention: "positive_expense", }; const SAMPLE_ROW: ImportFormatRow = { delimiter: ",", encoding: "windows-1252", date_format: "YYYY-MM-DD", skip_lines: 3, has_header: 0, column_mapping: '{"date":2,"description":5,"amount":7}', amount_mode: "single", sign_convention: "positive_expense", }; // --------------------------------------------------------------------------- // Completeness — the property the codec exists to hold // --------------------------------------------------------------------------- describe("codec completeness (#324)", () => { // `FORMAT_FIELD_PAIRS` is typed `Record`, so a field added to `ImportFormat` fails to BUILD until // it is listed. These two assertions close the other half: a field listed but // not wired through a codec body fails the TEST. it("formatToRow emits exactly the persisted fields of the table", () => { expect(Object.keys(formatToRow(SAMPLE_FORMAT)).sort()).toEqual( Object.values(FORMAT_FIELD_PAIRS).sort() ); }); it("formatFromRow emits exactly the domain fields of the table", () => { expect(Object.keys(formatFromRow(SAMPLE_ROW)).sort()).toEqual( Object.keys(FORMAT_FIELD_PAIRS).sort() ); }); it("covers the eight format fields, no more and no fewer", () => { expect(Object.keys(FORMAT_FIELD_PAIRS)).toHaveLength(8); }); it("maps every domain field to a distinct persisted field", () => { const persisted = Object.values(FORMAT_FIELD_PAIRS); expect(new Set(persisted).size).toBe(persisted.length); }); it("pickFormatRow emits exactly the persisted fields of the table", () => { expect(Object.keys(pickFormatRow(SAMPLE_ROW)).sort()).toEqual( Object.values(FORMAT_FIELD_PAIRS).sort() ); }); }); // --------------------------------------------------------------------------- // Projection — the direction the SREF export needs (#331) // --------------------------------------------------------------------------- describe("pickFormatRow (#331)", () => { it("carries the eight columns of a row verbatim", () => { expect(pickFormatRow({ ...SAMPLE_ROW, id: 7, name: "Visa" })).toEqual( SAMPLE_ROW ); }); it("normalizes has_header to the 0/1 the column stores", () => { // `ImportSource` declares it a boolean; SQLite has no such type. expect(pickFormatRow({ ...SAMPLE_ROW, has_header: true }).has_header).toBe(1); expect(pickFormatRow({ ...SAMPLE_ROW, has_header: false }).has_header).toBe(0); }); it("does NOT validate — an unreadable mapping still travels", () => { // `formatFromRow` refuses this row; a backup must still be able to carry a // source an older build left in that state. const row = { ...SAMPLE_ROW, column_mapping: "{}" }; expect(() => formatFromRow(row)).toThrow(ImportFormatError); expect(pickFormatRow(row).column_mapping).toBe("{}"); }); it("never names the drift metadata — it is not a format field", () => { const picked = pickFormatRow({ ...SAMPLE_ROW, header_signature: "date|desc", }); expect("header_signature" in picked).toBe(false); }); }); // --------------------------------------------------------------------------- // Round trip — configure -> save -> reload // --------------------------------------------------------------------------- describe("round trip (#324)", () => { it("survives domain -> row -> domain field by field", () => { const restored = formatFromRow(formatToRow(SAMPLE_FORMAT)); for (const field of Object.keys(FORMAT_FIELD_PAIRS) as Array< keyof ImportFormat >) { expect(restored[field]).toEqual(SAMPLE_FORMAT[field]); } expect(restored).toEqual(SAMPLE_FORMAT); }); it("survives row -> domain -> row field by field", () => { expect(formatToRow(formatFromRow(SAMPLE_ROW))).toEqual(SAMPLE_ROW); }); // THE regression: the credit-card case. Before v17 the convention was not a // column, so restoring one wrote "negative_expense" and every expense of the // next import landed as income. it("keeps positive_expense across a save and a reload", () => { const positive: ImportFormat = { ...SAMPLE_FORMAT, signConvention: "positive_expense", }; expect(formatFromRow(formatToRow(positive)).signConvention).toBe( "positive_expense" ); }); it("keeps negative_expense across a save and a reload", () => { const negative: ImportFormat = { ...SAMPLE_FORMAT, signConvention: "negative_expense", }; expect(formatFromRow(formatToRow(negative)).signConvention).toBe( "negative_expense" ); }); // The mode used to be re-derived from `mapping.debitAmount !== undefined`. A // debit/credit source whose debit column is not mapped yet came back as // "single" — this is that shape. it("keeps debit_credit even when the mapping carries no debit column", () => { const partial: ImportFormat = { ...SAMPLE_FORMAT, amountMode: "debit_credit", columnMapping: { date: 0, description: 1, creditAmount: 3 }, }; const restored = formatFromRow(formatToRow(partial)); expect(restored.amountMode).toBe("debit_credit"); expect(restored.columnMapping).toEqual({ date: 0, description: 1, creditAmount: 3, }); }); it("keeps single even when the mapping still carries debit/credit keys", () => { const stale: ImportFormat = { ...SAMPLE_FORMAT, amountMode: "single", columnMapping: { date: 0, description: 1, amount: 2, debitAmount: 3 }, }; expect(formatFromRow(formatToRow(stale)).amountMode).toBe("single"); }); }); // --------------------------------------------------------------------------- // has_header normalization // --------------------------------------------------------------------------- describe("has_header normalization", () => { it("writes 0/1, never a boolean — SQLite has no boolean type", () => { expect(formatToRow({ ...SAMPLE_FORMAT, hasHeader: true }).has_header).toBe(1); expect(formatToRow({ ...SAMPLE_FORMAT, hasHeader: false }).has_header).toBe(0); }); it("reads the integer an import_config_templates row carries", () => { expect(formatFromRow({ ...SAMPLE_ROW, has_header: 1 }).hasHeader).toBe(true); expect(formatFromRow({ ...SAMPLE_ROW, has_header: 0 }).hasHeader).toBe(false); }); it("reads the boolean an import_sources row is declared with", () => { const asBoolean: ImportFormatRowInput = { ...SAMPLE_ROW, has_header: true }; expect(formatFromRow(asBoolean).hasHeader).toBe(true); expect(formatFromRow({ ...asBoolean, has_header: false }).hasHeader).toBe(false); }); }); // --------------------------------------------------------------------------- // Validation — a value the app cannot map must raise, never fall back // --------------------------------------------------------------------------- describe("formatFromRow validation", () => { it("accepts every mode and convention the app implements", () => { for (const amount_mode of AMOUNT_MODES) { expect(formatFromRow({ ...SAMPLE_ROW, amount_mode }).amountMode).toBe( amount_mode ); } for (const sign_convention of SIGN_CONVENTIONS) { expect( formatFromRow({ ...SAMPLE_ROW, sign_convention }).signConvention ).toBe(sign_convention); } }); // The v17 CHECK admits 'absolute_indicator' so the third mode ships without a // migration. Until it is implemented, reading one must raise — a fall-back to // the `single` branch would read the wrong column for every row. it("rejects an amount mode the app cannot map", () => { const row = { ...SAMPLE_ROW, amount_mode: "absolute_indicator", } as unknown as ImportFormatRowInput; expect(() => formatFromRow(row)).toThrow(ImportFormatError); try { formatFromRow(row); } catch (e) { expect((e as ImportFormatError).i18nKey).toBe( "import.errors.unsupportedAmountMode" ); } }); it("rejects an unknown sign convention rather than defaulting", () => { const row = { ...SAMPLE_ROW, sign_convention: "whatever", } as unknown as ImportFormatRowInput; expect(() => formatFromRow(row)).toThrow(ImportFormatError); try { formatFromRow(row); } catch (e) { expect((e as ImportFormatError).i18nKey).toBe( "import.errors.unsupportedSignConvention" ); } }); it("rejects a column mapping that is not valid JSON", () => { expect(() => formatFromRow({ ...SAMPLE_ROW, column_mapping: "{not json" }) ).toThrow(ImportFormatError); }); it("rejects a column mapping without usable date/description columns", () => { for (const column_mapping of ["null", "[]", '{"date":"2"}', "{}"]) { expect(() => formatFromRow({ ...SAMPLE_ROW, column_mapping })).toThrow( ImportFormatError ); } }); }); // --------------------------------------------------------------------------- // clearMappingForMode — the mode owns the mapping // --------------------------------------------------------------------------- describe("clearMappingForMode", () => { const full = { date: 0, description: 1, amount: 2, debitAmount: 3, creditAmount: 4, }; it("drops the debit/credit columns when switching to single", () => { expect(clearMappingForMode(full, "single")).toEqual({ date: 0, description: 1, amount: 2, }); }); it("drops the amount column when switching to debit/credit", () => { expect(clearMappingForMode(full, "debit_credit")).toEqual({ date: 0, description: 1, debitAmount: 3, creditAmount: 4, }); }); it("keeps date and description untouched in both directions", () => { expect(clearMappingForMode({ date: 4, description: 6 }, "single")).toEqual({ date: 4, description: 6, }); expect( clearMappingForMode({ date: 4, description: 6 }, "debit_credit") ).toEqual({ date: 4, description: 6 }); }); // #325 replaces the `?? 0` fallbacks with an explicit "amount column not // mapped" row error. Materializing the column the