fix(import): reject a startDate/kind granularity mismatch, and a

non-array recurring field (C1, M7)

parseDatasetJson now enforces the same rule as the store: everyNDays and
everyNWeeks require a day-granular startDate, since occurrencesOf hands it
straight to addDays. Without this, a hand-edited or exported-then-mutated
v7 file could import a cost that bricks every route touching the fold.
Updates the module docstring's claim that it "validates far enough that
the fold cannot throw" to actually cover this case.

Also switches the recurring field to requireArray, matching its four
sibling collections (posts/months/purchases/fxRates) — a non-array
`recurring` used to silently import as empty instead of being rejected.
This commit is contained in:
Lucas Winther
2026-09-04 07:57:30 +02:00
parent d1ccc65d72
commit 397bb245d8
2 changed files with 65 additions and 3 deletions
+42
View File
@@ -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/);