fix(validation): review round 1 — close the store's source hole, fix ordering and messages

I1: a calendar charge's MISSING occurrenceDate no longer reports the literal
word "undefined" (String(undefined) printed it, and that text appears
nowhere in the file someone is searching). Missing and malformed are split
into two messages; the missing one names the cost, matching the anchored
side's message.

I2: addPurchase/updatePurchase accepted an arbitrary `source` unchecked —
only confirmOccurrence built a coherent one. A later task hand-builds a
source and passes it straight to addPurchase, so this closes it at the store
layer now rather than leaving that task to carry the validation. Both
enforce the same pairing the importer does: a calendar cost's source
requires occurrenceDate, an anchored cost's source forbids it, an unknown
cost throws. updatePurchase checks the resolved pair (incoming source if
given, else the stored one), the same style already used for anchoring.

M1: the "no recurring costs" fold test added last round was a duplicate of
an existing one and couldn't fail independently. Replaced with a test that
actually discriminates which charge an anchored cost's walk steps from —
a 10-day cycle matching the gap between three charges, where anchoring off
the earliest instead of the latest changes whether anything is still
expected in the same month. Verified by making that swap in
occurrences.ts, watching it fail, and reverting.

M2: updateRecurringCost now refuses a charged cost's anchoring change
before checking startDate granularity, so a doomed request fails on the
real reason first rather than making the owner fix an unrelated date
problem and resubmit into the same wall.

Also closed two pre-existing asymmetries with json.ts: requireRecurrence
now rejects an unknown recurrence kind, and both addRecurringCost and
updateRecurringCost reject an unknown anchoring — both previously relied
on TypeScript's unions alone, which a hand-edited object or an `as` cast
does not go through.
This commit is contained in:
Lucas Winther committed 2026-09-05 22:58:20 +02:00
1 parent 77bd049fb9
commit 7a755f6406
5 files changed
+322 -26

No files matched your search

+24
View File
@@ -606,6 +606,30 @@ describe("the occurrenceDate / anchoring pairing", () => {
test("an anchored charge without occurrenceDate is accepted", () => {
expect(() => parseDatasetJson(withCostAndCharge("lastCharge", { recurringId: "r1" }))).not.toThrow();
});
// REVIEW ROUND 1 / I1: a calendar charge with a genuinely MISSING
// occurrenceDate used to be reported with `String(undefined)`, which prints
// the literal word "undefined" — so someone restoring a backup would
// search their file for that text and find nothing, since the file has no
// such string anywhere. Missing and malformed are different problems, and
// the missing case now names the cost instead.
test("a calendar charge missing occurrenceDate names the cost, not the word 'undefined'", () => {
let message = "";
try {
parseDatasetJson(withCostAndCharge("calendar", { recurringId: "r1" }));
} catch (error) {
message = (error as Error).message;
}
expect(message).toMatch(/Bill/); // the cost's name, from withCostAndCharge's fixture
expect(message).not.toMatch(/undefined/);
});
// The malformed case (present but not a real date) keeps its old shape.
test("a calendar charge with a malformed occurrenceDate still names the bad value", () => {
expect(() =>
parseDatasetJson(withCostAndCharge("calendar", { recurringId: "r1", occurrenceDate: "nope" })),
).toThrow(/invalid occurrence date "nope"/);
});
});
describe("startDate granularity under the anchored model", () => {