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/);
+23 -3
View File
@@ -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(