diff --git a/src/export/json.test.ts b/src/export/json.test.ts index 7f7c861..ce87074 100644 --- a/src/export/json.test.ts +++ b/src/export/json.test.ts @@ -62,6 +62,14 @@ describe("validation", () => { expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/purchases/); }); + test("rejects a non-array recurring field instead of silently importing it as empty (M7)", () => { + // The other four sibling collections already go through requireArray; + // recurring used to fall back to [] instead of being rejected here. + const data = populated() as any; + data.recurring = "not an array"; + expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/recurring/); + }); + test("rejects a purchase whose splits reference an unknown post", () => { const data = populated(); data.purchases[0]!.splits[0]!.postId = "ghost"; @@ -306,6 +314,40 @@ describe("recurring cost validation", () => { .toThrow(/whole number/); }); + test("everyNMonths accepts either startDate granularity (C1)", () => { + expect(parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-01" }])).recurring.length).toBe(1); + expect(parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-01-15" }])).recurring.length).toBe(1); + }); + + test("everyNDays with a month-only startDate is refused (C1)", () => { + // This is the exact reachable bug: occurrencesOf hands a month-only + // startDate straight to addDays for a day-granular kind and throws deep + // inside the fold. Reject it here instead, at the import boundary. + expect(() => + parseDatasetJson( + withRecurring([{ ...valid, startDate: "2026-01", recurrence: { kind: "everyNDays", n: 30 } }]), + ), + ).toThrow(/month-only start date/); + }); + + test("everyNWeeks with a month-only startDate is refused (C1)", () => { + expect(() => + parseDatasetJson( + withRecurring([ + { ...valid, startDate: "2026-01", recurrence: { kind: "everyNWeeks", n: 1, weekday: 2 } }, + ]), + ), + ).toThrow(/month-only start date/); + }); + + test("everyNDays with a day-granular startDate is accepted (C1)", () => { + expect( + parseDatasetJson( + withRecurring([{ ...valid, startDate: "2026-01-05", recurrence: { kind: "everyNDays", n: 30 } }]), + ).recurring.length, + ).toBe(1); + }); + test("an unknown recurrence kind is refused", () => { expect(() => parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyFullMoon", n: 1 } }]))) .toThrow(/unknown kind/); diff --git a/src/export/json.ts b/src/export/json.ts index b9d0c05..babaf50 100644 --- a/src/export/json.ts +++ b/src/export/json.ts @@ -35,6 +35,14 @@ const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/; * throwing out of `monthOf()` deep inside the balance fold. */ const PURCHASE_DATE = /^\d{4}-(0[1-9]|1[0-2])(-(0[1-9]|[12]\d|3[01]))?$/; +/** + * The day-granular subset of `PURCHASE_DATE`: "YYYY-MM-DD" only, no month-only + * form. `everyNDays` and `everyNWeeks` hand `startDate` straight to `addDays` + * (`src/domain/occurrences.ts`), which throws on a month-only date — so those + * kinds need this narrower shape, checked here rather than three months later + * inside a fold. + */ +const DAY_GRANULAR_DATE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/; const ANCHORINGS = new Set(["calendar", "lastCharge"]); const RECURRENCE_KINDS = new Set(["everyNMonths", "everyNDays", "everyNWeeks"]); @@ -217,8 +225,11 @@ function requireSplits( /** * Validates far enough that the fold cannot throw on the imported data: every * split points at a real post, every purchase has exactly one remainder - * absorber, every MonthId parses, and every rule version resolves - * unambiguously to a number rather than to NaN or a missing rate. + * absorber, every MonthId parses, every rule version resolves unambiguously + * to a number rather than to NaN or a missing rate, and every recurring + * cost's startDate is granular enough for its recurrence kind (day-granular + * for everyNDays/everyNWeeks, since occurrencesOf hands it straight to + * addDays). */ export function parseDatasetJson(text: string): Dataset { let raw: unknown; @@ -306,7 +317,7 @@ export function parseDatasetJson(text: string): Dataset { } } - const recurring = Array.isArray(data.recurring) ? (data.recurring as any[]) : []; + const recurring = requireArray(data, "recurring") as any[]; dataset.recurring = recurring; // Optional chaining, not `.id` directly: a null/non-object entry must fall // through to the loop below and fail there with an ImportValidationError, @@ -329,6 +340,15 @@ export function parseDatasetJson(text: string): Dataset { throw new ImportValidationError(`${label} has an invalid ended-from date "${cost.endedFrom}"`); } requireRecurrence(cost.recurrence, label); + // `everyNMonths` walks through `monthOf`, which accepts either + // granularity; the other two kinds reach `addDays` and need a + // day-granular startDate specifically. `endedFrom` needs no such check — + // it is only ever compared lexicographically, never fed to `addDays`. + if (cost.recurrence.kind !== "everyNMonths" && !DAY_GRANULAR_DATE.test(cost.startDate)) { + throw new ImportValidationError( + `${label} has a "${cost.recurrence.kind}" recurrence but a month-only start date "${cost.startDate}"; it needs "YYYY-MM-DD"`, + ); + } if (!ANCHORINGS.has(cost.anchoring)) { throw new ImportValidationError(