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) <noreply@anthropic.com>
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/);
});
});