feat(export): validate rule versions on import

Rejects a missing rules array, a malformed or out-of-range start month, and two
versions sharing a month — which would make the effective rule ambiguous — at
the import boundary rather than letting the fold resolve something arbitrary.

Also rejects a rule of an unknown kind and a fixed rule in an unsupported
currency. The first falls through resolveRule's percentage branch and turns
every figure after it into NaN; the second throws MissingRateError deep inside
the balance fold. Both are the failure class this validation exists to stop.
The rule's VALUE stays unvalidated: over-budget is always allowed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
Lucas Winther
2026-09-02 14:09:28 +02:00
co-authored by Claude Opus 5
parent de128e3c87
commit 1cb91d37ea
2 changed files with 117 additions and 1 deletions
+78
View File
@@ -144,3 +144,81 @@ describe("purchase dates", () => {
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-01-32/);
});
});
describe("rule versions on import", () => {
const pct = (percent: number) => ({ kind: "percentOfIncome" as const, percent });
test("accepts a well-formed series", () => {
const data = populated();
data.posts[0]!.rules = [
{ from: "2026-01", rule: pct(10) },
{ from: "2026-07", rule: pct(15) },
];
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
});
test("accepts an empty series — an unbudgeted post is legal", () => {
const data = populated();
data.posts[0]!.rules = [];
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
});
test("rejects a post whose rules is missing or not an array", () => {
const data = populated() as any;
delete data.posts[0].rules;
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/rules/);
});
test("rejects a malformed from month", () => {
const data = populated();
data.posts[0]!.rules = [{ from: "2026-1", rule: pct(10) }];
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-1/);
});
test("rejects an out-of-range from month", () => {
const data = populated();
data.posts[0]!.rules = [{ from: "2026-13", rule: pct(10) }];
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-13/);
});
test("rejects two versions sharing a month, which would be ambiguous", () => {
const data = populated();
data.posts[0]!.rules = [
{ from: "2026-04", rule: pct(10) },
{ from: "2026-04", rule: pct(15) },
];
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-04/);
});
test("does NOT reject a percentage above 100", () => {
const data = populated();
data.posts[0]!.rules = [{ from: "2026-01", rule: pct(150) }];
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
});
test("does NOT reject a negative fixed amount", () => {
const data = populated();
data.posts[0]!.rules = [
{ from: "2026-01", rule: { kind: "fixed", amount: { amount: -500, currency: "DKK" } } },
];
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
});
// Without this, toBase() throws MissingRateError deep inside the balance
// fold instead of at the import boundary.
test("rejects a fixed rule in an unsupported currency", () => {
const data = populated() as any;
data.posts[0].rules = [
{ from: "2026-01", rule: { kind: "fixed", amount: { amount: 500, currency: "XYZ" } } },
];
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/XYZ/);
});
// Without this, resolveRule() falls through to the percentage branch and
// every figure downstream of it becomes NaN, silently.
test("rejects a rule of an unrecognized kind", () => {
const data = populated() as any;
data.posts[0].rules = [{ from: "2026-01", rule: { kind: "everySecondTuesday" } }];
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/everySecondTuesday/);
});
});
+39 -1
View File
@@ -54,7 +54,8 @@ function requireArray(data: Record<string, unknown>, key: string): unknown[] {
/**
* 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, and every MonthId parses.
* absorber, every MonthId parses, and every rule version resolves
* unambiguously to a number rather than to NaN or a missing rate.
*/
export function parseDatasetJson(text: string): Dataset {
let raw: unknown;
@@ -89,6 +90,43 @@ export function parseDatasetJson(text: string): Dataset {
const postIds = new Set(dataset.posts.map((p) => p.id));
for (const post of dataset.posts) {
requireCurrency(post.currency, `post "${post.name}"`);
const rules = (post as unknown as Record<string, unknown>).rules;
if (!Array.isArray(rules)) {
throw new ImportValidationError(`Post "${post.name}" is missing its rules array`);
}
const seenFrom = new Set<string>();
for (const version of rules as Array<Record<string, unknown>>) {
const from = String(version.from);
if (!MONTH_ID.test(from)) {
throw new ImportValidationError(
`Post "${post.name}" has a rule with an invalid start month "${from}"`,
);
}
if (seenFrom.has(from)) {
throw new ImportValidationError(
`Post "${post.name}" has two rules starting in "${from}"; the effective rule would be ambiguous`,
);
}
seenFrom.add(from);
// The rule's VALUE is deliberately unvalidated — a percentage above 100
// and a negative amount are both legal. Its SHAPE is not: an unknown
// kind falls through resolveRule's percentage branch and turns every
// figure after it into NaN, and an unsupported currency throws
// MissingRateError deep inside the fold rather than here.
const rule = (version.rule ?? {}) as {
kind?: unknown;
amount?: { currency?: unknown };
};
if (rule.kind === "fixed") {
requireCurrency(rule.amount?.currency, `post "${post.name}" rule from ${from}`);
} else if (rule.kind !== "percentOfIncome") {
throw new ImportValidationError(
`Post "${post.name}" has a rule from "${from}" of unknown kind "${String(rule.kind)}"`,
);
}
}
}
for (const month of dataset.months) {