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:
co-authored by
Claude Opus 5
parent
de128e3c87
commit
1cb91d37ea
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user