880 lines
35 KiB
TypeScript
880 lines
35 KiB
TypeScript
import { test, expect, describe } from "bun:test";
|
|
import {
|
|
exportDatasetJson,
|
|
parseDatasetJson,
|
|
describeDataset,
|
|
ImportValidationError,
|
|
exportFilename,
|
|
} from "./json.ts";
|
|
import { addRecurringCost, updatePost, updateRecurringCost } from "../store/actions.ts";
|
|
import { createSeedDataset } from "../domain/seed.ts";
|
|
import { monthView, yearView } from "../domain/views.ts";
|
|
import { occurrencesByMonth } from "../domain/occurrences.ts";
|
|
import type { Dataset } from "../domain/types.ts";
|
|
|
|
function populated(): Dataset {
|
|
const data = createSeedDataset("2026-01");
|
|
data.months[0]!.income = { amount: 20000, currency: "DKK" };
|
|
data.purchases.push({
|
|
id: "p1",
|
|
date: "2026-01-15",
|
|
description: "Console",
|
|
total: { amount: 3000, currency: "DKK" },
|
|
splitMode: "percent",
|
|
splits: [
|
|
{ postId: data.posts[0]!.id, value: 70, absorbsRemainder: true },
|
|
{ postId: data.posts[2]!.id, value: 30, absorbsRemainder: false },
|
|
],
|
|
schedule: {
|
|
slices: [
|
|
{ month: "2026-01", amount: { amount: 1000, currency: "DKK" } },
|
|
{ month: "2026-02", amount: { amount: 2000, currency: "DKK" } },
|
|
],
|
|
},
|
|
});
|
|
return data;
|
|
}
|
|
|
|
describe("round trip", () => {
|
|
test("export then import yields an equal dataset", () => {
|
|
const data = populated();
|
|
expect(parseDatasetJson(exportDatasetJson(data))).toEqual(data);
|
|
});
|
|
|
|
test("derived output is identical after a round trip", () => {
|
|
const data = populated();
|
|
const restored = parseDatasetJson(exportDatasetJson(data));
|
|
expect(monthView(restored, "2026-01")).toEqual(monthView(data, "2026-01"));
|
|
expect(yearView(restored, 2026)).toEqual(yearView(data, 2026));
|
|
});
|
|
|
|
test("export is human-readable, indented JSON", () => {
|
|
expect(exportDatasetJson(createSeedDataset("2026-01"))).toContain("\n ");
|
|
});
|
|
});
|
|
|
|
describe("validation", () => {
|
|
test.each(["true", 1, null])("rejects a non-boolean existing-bill entry preference: %s", value => {
|
|
const data = populated();
|
|
(data.settings as any).allowExistingRecurringCost = value;
|
|
expect(() => parseDatasetJson(exportDatasetJson(data))).toThrow(ImportValidationError);
|
|
});
|
|
|
|
test.each([true, false, undefined])("round trips the existing-bill entry preference: %s", value => {
|
|
const data = populated();
|
|
data.settings.allowExistingRecurringCost = value;
|
|
expect(parseDatasetJson(exportDatasetJson(data)).settings.allowExistingRecurringCost).toBe(value);
|
|
});
|
|
|
|
test("accepts an unassigned recurring cost and its unassigned charge", () => {
|
|
const data = populated();
|
|
const cost = addRecurringCost(data, {
|
|
name: "Phone", folderId: null, archived: false,
|
|
amount: { amount: 30, currency: "DKK" }, startDate: "2026-01",
|
|
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
|
|
splitMode: "percent", splits: [],
|
|
});
|
|
data.purchases.push({
|
|
id: "phone-charge", date: "2026-01", description: "Phone",
|
|
total: { amount: 30, currency: "DKK" }, splitMode: "percent", splits: [],
|
|
schedule: null, source: { recurringId: cost.id, occurrenceDate: "2026-01" },
|
|
});
|
|
const restored = parseDatasetJson(exportDatasetJson(data));
|
|
expect(restored.recurring[0]!.splits).toEqual([]);
|
|
expect(restored.purchases.at(-1)!.splits).toEqual([]);
|
|
expect(() => monthView(restored, "2026-01")).not.toThrow();
|
|
});
|
|
test("accepts a purchase with no post and preserves it through export", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.splits = [];
|
|
expect(parseDatasetJson(exportDatasetJson(data)).purchases[0]!.splits).toEqual([]);
|
|
});
|
|
test("rejects malformed JSON", () => {
|
|
expect(() => parseDatasetJson("{not json")).toThrow(ImportValidationError);
|
|
});
|
|
|
|
test("rejects a dataset missing a required collection", () => {
|
|
const data = populated() as any;
|
|
delete data.purchases;
|
|
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";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/ghost/);
|
|
});
|
|
|
|
test("rejects a purchase with no remainder-absorbing split", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.splits[0]!.absorbsRemainder = false;
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/absorbsRemainder/);
|
|
});
|
|
|
|
test("rejects a purchase with two remainder-absorbing splits", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.splits[1]!.absorbsRemainder = true;
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/exactly one/i);
|
|
});
|
|
|
|
test("rejects an unsupported currency", () => {
|
|
const data = populated() as any;
|
|
// CHF, not GBP: GBP is one of the currencies the app ships with now.
|
|
data.settings.baseCurrency = "CHF";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/CHF/);
|
|
});
|
|
|
|
test("rejects a malformed MonthId", () => {
|
|
const data = populated();
|
|
data.months[0]!.id = "2026-1";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-1/);
|
|
});
|
|
|
|
test("rejects a MonthId with an out-of-range month", () => {
|
|
const data = populated();
|
|
data.months[0]!.id = "2026-13";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-13/);
|
|
});
|
|
});
|
|
|
|
describe("import rejects invalid folder graph", () => {
|
|
const folder = (id: string, parentId: string | null, order: number) => ({ id, name: id, parentId, order });
|
|
const parse = (data: Dataset) => parseDatasetJson(JSON.stringify(data));
|
|
|
|
test("round-trips a standalone purchase and a bill nested beneath a post", () => {
|
|
const data = populated();
|
|
data.folders = [folder("home", null, 3)];
|
|
data.purchases[0]!.placement = { folderId: "home", order: 0 };
|
|
data.recurring.push({ id: "rent", name: "Rent", order: 0, folderId: null, parentPostId: data.posts[0]!.id, archived: false, amount: { amount: 100, currency: "DKK" }, startDate: "2026-01", recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar", splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }] });
|
|
const restored = parse(data);
|
|
expect(restored.purchases[0]!.placement?.folderId).toBe("home");
|
|
expect(restored.recurring[0]!.parentPostId).toBe(data.posts[0]!.id);
|
|
restored.recurring[0]!.parentPostId = "missing";
|
|
expect(() => parse(restored)).toThrow(/parent post/);
|
|
restored.recurring[0]!.parentPostId = data.posts[0]!.id;
|
|
restored.purchases[0]!.placement!.folderId = "missing";
|
|
expect(() => parse(restored)).toThrow(/unknown folder/);
|
|
});
|
|
|
|
test("valid nested tree parses", () => {
|
|
const data = populated();
|
|
data.folders = [folder("home", null, 0), folder("bills", "home", 0)];
|
|
data.posts[0]!.folderId = "bills";
|
|
expect(parse(data).posts[0]!.folderId).toBe("bills");
|
|
});
|
|
|
|
test("rejects duplicate folder IDs", () => {
|
|
const data = populated();
|
|
data.folders = [folder("same", null, 0), folder("same", null, 1)];
|
|
expect(() => parse(data)).toThrow(ImportValidationError);
|
|
});
|
|
|
|
test("rejects missing parent", () => {
|
|
const data = populated();
|
|
data.folders = [folder("child", "missing", 0)];
|
|
expect(() => parse(data)).toThrow(ImportValidationError);
|
|
});
|
|
|
|
test("rejects missing item folders", () => {
|
|
const data = populated();
|
|
data.posts[0]!.folderId = "missing";
|
|
expect(() => parse(data)).toThrow(ImportValidationError);
|
|
data.posts[0]!.folderId = null;
|
|
data.recurring.push({
|
|
id: "rent", name: "Rent", order: 3, folderId: "missing", archived: false,
|
|
amount: { amount: 100, currency: "DKK" }, startDate: "2026-01",
|
|
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
|
|
splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
|
|
});
|
|
expect(() => parse(data)).toThrow(ImportValidationError);
|
|
});
|
|
|
|
test("rejects cycles", () => {
|
|
const data = populated();
|
|
data.folders = [folder("a", "b", 0), folder("b", "a", 0)];
|
|
expect(() => parse(data)).toThrow(ImportValidationError);
|
|
});
|
|
|
|
test("rejects duplicate sibling order across item kinds", () => {
|
|
const data = populated();
|
|
data.folders = [folder("bills", null, 0)];
|
|
data.posts[0]!.order = 0;
|
|
expect(() => parse(data)).toThrow(ImportValidationError);
|
|
});
|
|
|
|
test.each([
|
|
["", "Rent"], [" ", "Rent"], ["Games", ""], ["Games", " "],
|
|
])("blank item names (%j, %j) survive saved dataset round trips and v7 import", (postName, billName) => {
|
|
const data = populated();
|
|
const bill = addRecurringCost(data, {
|
|
name: "Rent", folderId: null, archived: false,
|
|
amount: { amount: 100, currency: "DKK" }, startDate: "2026-01",
|
|
recurrence: { kind: "everyNMonths", n: 1 }, anchoring: "calendar",
|
|
splitMode: "percent", splits: [{ postId: data.posts[0]!.id, value: 100, absorbsRemainder: true }],
|
|
});
|
|
updatePost(data, data.posts[0]!.id, { name: postName });
|
|
updateRecurringCost(data, bill.id, { name: billName });
|
|
expect(parseDatasetJson(exportDatasetJson(data))).toEqual(data);
|
|
const legacy = JSON.parse(exportDatasetJson(data));
|
|
legacy.settings.schemaVersion = 7;
|
|
delete legacy.folders;
|
|
for (const item of [...legacy.posts, ...legacy.recurring]) delete item.folderId;
|
|
const imported = parseDatasetJson(JSON.stringify(legacy));
|
|
expect(imported.posts[0]!.name).toBe(postName);
|
|
expect(imported.recurring[0]!.name).toBe(billName);
|
|
expect(imported.folders).toEqual([]);
|
|
});
|
|
|
|
test.each(["", " "])("still rejects blank folder names %j", name => {
|
|
const data = populated();
|
|
data.folders = [{ ...folder("home", null, data.posts.length), name }];
|
|
expect(() => parse(data)).toThrow(/empty name/);
|
|
});
|
|
});
|
|
|
|
test("describeDataset counts what an import would replace", () => {
|
|
expect(describeDataset(populated())).toEqual({
|
|
posts: 3,
|
|
months: 1,
|
|
purchases: 1,
|
|
});
|
|
});
|
|
|
|
test("exportFilename is stable and sortable", () => {
|
|
expect(exportFilename("2026-09")).toBe("budget-2026-09.json");
|
|
});
|
|
|
|
describe("purchase dates", () => {
|
|
test("accepts a full date and a month-only date", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.date = "2026-01-15";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
|
|
data.purchases[0]!.date = "2026-01";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
|
|
});
|
|
|
|
test("rejects a malformed date instead of letting the fold throw later", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.date = "not-a-date";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/not-a-date/);
|
|
});
|
|
|
|
test("rejects an out-of-range month in a purchase date", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.date = "2026-13-01";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/2026-13-01/);
|
|
});
|
|
|
|
test("rejects an out-of-range day", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.date = "2026-01-32";
|
|
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/);
|
|
});
|
|
|
|
test.each([
|
|
["missing percent", { kind: "percentOfIncome" }],
|
|
["string percent", { kind: "percentOfIncome", percent: "10" }],
|
|
["null percent", { kind: "percentOfIncome", percent: null }],
|
|
["missing fixed amount", { kind: "fixed", amount: { currency: "DKK" } }],
|
|
["string fixed amount", { kind: "fixed", amount: { amount: "500", currency: "DKK" } }],
|
|
["null fixed amount", { kind: "fixed", amount: { amount: null, currency: "DKK" } }],
|
|
])("rejects a %s before it can corrupt the fold", (_label, rule) => {
|
|
const data = populated() as any;
|
|
data.posts[0].rules = [{ from: "2026-01", rule }];
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(ImportValidationError);
|
|
});
|
|
});
|
|
|
|
describe("the decimal-places setting on import", () => {
|
|
test("a valid setting round-trips", () => {
|
|
const data = populated();
|
|
data.settings.digits = 0;
|
|
expect(parseDatasetJson(exportDatasetJson(data)).settings.digits).toBe(0);
|
|
});
|
|
|
|
test.each([
|
|
["missing", undefined],
|
|
["fractional", 1.5],
|
|
["negative", -1],
|
|
["beyond four places", 5],
|
|
["a string", "2"],
|
|
])("a %s setting is refused", (_label, value) => {
|
|
const data = populated() as any;
|
|
if (value === undefined) delete data.settings.digits;
|
|
else data.settings.digits = value;
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(ImportValidationError);
|
|
});
|
|
|
|
test("a per-currency digits left over in a hand-edited file is ignored, not refused", () => {
|
|
// An unknown field is not a reason to refuse the owner's only backup.
|
|
const data = populated() as any;
|
|
data.currencies[0].digits = 3;
|
|
const out = parseDatasetJson(JSON.stringify(data));
|
|
expect("digits" in out.currencies[0]!).toBe(false);
|
|
expect(out.settings.digits).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("recurring cost validation", () => {
|
|
function withRecurring(recurring: unknown[]): string {
|
|
const data: any = createSeedDataset("2026-01");
|
|
data.posts[0].id = "housing";
|
|
data.recurring = recurring;
|
|
return JSON.stringify(data);
|
|
}
|
|
|
|
const valid = {
|
|
id: "r1",
|
|
name: "Rent",
|
|
order: 3,
|
|
folderId: null,
|
|
archived: false,
|
|
amount: { amount: 8000, currency: "DKK" },
|
|
startDate: "2026-01",
|
|
recurrence: { kind: "everyNMonths", n: 1 },
|
|
anchoring: "calendar",
|
|
splitMode: "percent",
|
|
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
|
};
|
|
|
|
test("a well-formed cost is accepted", () => {
|
|
expect(parseDatasetJson(withRecurring([valid])).recurring.length).toBe(1);
|
|
});
|
|
|
|
test("an undefined currency is refused", () => {
|
|
expect(() => parseDatasetJson(withRecurring([{ ...valid, amount: { amount: 1, currency: "XYZ" } }])))
|
|
.toThrow(/Unsupported currency/);
|
|
});
|
|
|
|
test("a malformed start date is refused", () => {
|
|
expect(() => parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-13" }])))
|
|
.toThrow(/start date/);
|
|
});
|
|
|
|
test("a malformed endedFrom is refused", () => {
|
|
expect(() => parseDatasetJson(withRecurring([{ ...valid, endedFrom: "nope" }])))
|
|
.toThrow(/ended-from date/);
|
|
});
|
|
|
|
// Same residual-C1 gap as startDate: PURCHASE_DATE checks shape and digit
|
|
// range, not calendar validity, so "2026-02-30" passes it. §10: endedFrom
|
|
// carries no granularity rule, but it must still be a real date.
|
|
test("a calendar-impossible endedFrom is refused (day- or month-shaped)", () => {
|
|
expect(() => parseDatasetJson(withRecurring([{ ...valid, endedFrom: "2026-02-30" }])))
|
|
.toThrow(/calendar-impossible ended-from/);
|
|
expect(() => parseDatasetJson(withRecurring([{ ...valid, endedFrom: "2026-02-29" }]))) // not a leap year
|
|
.toThrow(/calendar-impossible ended-from/);
|
|
});
|
|
|
|
test("a valid endedFrom of either granularity is accepted, regardless of recurrence kind", () => {
|
|
expect(
|
|
parseDatasetJson(withRecurring([{ ...valid, endedFrom: "2026-06" }])).recurring.length,
|
|
).toBe(1);
|
|
expect(
|
|
parseDatasetJson(
|
|
withRecurring([{ ...valid, endedFrom: "2026-06-17" }]), // month-only recurrence, day-granular end
|
|
).recurring.length,
|
|
).toBe(1);
|
|
});
|
|
|
|
test("an interval below 1 is refused, not clamped", () => {
|
|
// Clamping would silently change the bill's schedule. The walk in
|
|
// occurrences.ts cannot terminate without this.
|
|
expect(() => parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyNDays", n: 0 } }])))
|
|
.toThrow(/at least 1/);
|
|
});
|
|
|
|
test("a fractional interval is refused", () => {
|
|
expect(() => parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyNDays", n: 1.5 } }])))
|
|
.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);
|
|
});
|
|
|
|
// Residual C1: PURCHASE_DATE/DAY_GRANULAR_DATE check shape and range
|
|
// ("31" is a valid day-of-month digit pair), not calendar validity.
|
|
// September has 30 days, so "2026-09-31" passed both regexes and the
|
|
// fold later threw "Invalid day in IsoDate" out of addDays/toDayOrdinal.
|
|
test("an impossible day on a day-granular kind is refused, not just shape-checked (residual C1)", () => {
|
|
expect(() =>
|
|
parseDatasetJson(
|
|
withRecurring([
|
|
{ ...valid, startDate: "2026-02-30", recurrence: { kind: "everyNDays", n: 1 } },
|
|
]),
|
|
),
|
|
).toThrow(/calendar-impossible/);
|
|
});
|
|
|
|
test("an impossible day is refused even under everyNMonths (residual C1)", () => {
|
|
expect(() =>
|
|
parseDatasetJson(withRecurring([{ ...valid, startDate: "2026-09-31" }])),
|
|
).toThrow(/calendar-impossible/);
|
|
});
|
|
|
|
test("leap-year and non-leap-year Februaries are accepted (residual C1)", () => {
|
|
expect(
|
|
parseDatasetJson(
|
|
withRecurring([
|
|
{ ...valid, startDate: "2024-02-29", recurrence: { kind: "everyNDays", n: 1 } }, // 2024 is a leap year
|
|
]),
|
|
).recurring.length,
|
|
).toBe(1);
|
|
expect(
|
|
parseDatasetJson(
|
|
withRecurring([
|
|
{ ...valid, startDate: "2026-02-28", recurrence: { kind: "everyNDays", n: 1 } }, // 2026 is not
|
|
]),
|
|
).recurring.length,
|
|
).toBe(1);
|
|
});
|
|
|
|
test("an unknown recurrence kind is refused", () => {
|
|
expect(() => parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyFullMoon", n: 1 } }])))
|
|
.toThrow(/unknown kind/);
|
|
});
|
|
|
|
test("a weekday outside 0-6 is refused", () => {
|
|
expect(() =>
|
|
parseDatasetJson(withRecurring([{ ...valid, recurrence: { kind: "everyNWeeks", n: 1, weekday: 7 } }])),
|
|
).toThrow(/weekday/);
|
|
});
|
|
|
|
test("an unknown anchoring is refused", () => {
|
|
expect(() => parseDatasetJson(withRecurring([{ ...valid, anchoring: "whenever" }])))
|
|
.toThrow(/anchoring/);
|
|
});
|
|
|
|
test("splits must have exactly one remainder absorber", () => {
|
|
expect(() =>
|
|
parseDatasetJson(withRecurring([{
|
|
...valid,
|
|
splits: [
|
|
{ postId: "housing", value: 50, absorbsRemainder: true },
|
|
{ postId: "housing", value: 50, absorbsRemainder: true },
|
|
],
|
|
}])),
|
|
).toThrow(/absorbsRemainder/);
|
|
});
|
|
|
|
test("a split naming an unknown post is refused", () => {
|
|
expect(() =>
|
|
parseDatasetJson(withRecurring([{
|
|
...valid,
|
|
splits: [{ postId: "ghost", value: 100, absorbsRemainder: true }],
|
|
}])),
|
|
).toThrow(/unknown post/);
|
|
});
|
|
|
|
test("a missing splits array is refused", () => {
|
|
expect(() => parseDatasetJson(withRecurring([{ ...valid, splits: null }])))
|
|
.toThrow(/no splits/);
|
|
});
|
|
|
|
test("a null recurring cost entry is refused, not a raw TypeError", () => {
|
|
// `recurring: [null]` used to reach `cost.id`/`cost.name` unguarded and
|
|
// throw TypeError instead of the file's own ImportValidationError.
|
|
expect(() => parseDatasetJson(withRecurring([null]))).toThrow(ImportValidationError);
|
|
});
|
|
|
|
test("an unknown splitMode is refused", () => {
|
|
// charges.ts picks distributeByWeight/distributeByAmount on
|
|
// `splitMode === "percent"`; any other string silently takes the "fixed"
|
|
// branch and treats a percentage as an absolute amount.
|
|
expect(() => parseDatasetJson(withRecurring([{ ...valid, splitMode: "ratio" }])))
|
|
.toThrow(/splitMode/);
|
|
});
|
|
|
|
test("a non-numeric split value is refused", () => {
|
|
// A non-finite value would otherwise propagate as NaN through the fold,
|
|
// and roundMoney(NaN, digits) returns NaN rather than throwing.
|
|
expect(() =>
|
|
parseDatasetJson(withRecurring([{
|
|
...valid,
|
|
splits: [{ postId: "housing", value: "oops", absorbsRemainder: true }],
|
|
}])),
|
|
).toThrow(/finite number/);
|
|
});
|
|
|
|
test("a negative split value is still accepted", () => {
|
|
// Deliberately unchecked per AGENTS.md §3: a refund is a normal line.
|
|
// Only non-numbers are rejected, never a negative or over-100 value.
|
|
expect(() =>
|
|
parseDatasetJson(withRecurring([{
|
|
...valid,
|
|
splits: [{ postId: "housing", value: -50, absorbsRemainder: true }],
|
|
}])),
|
|
).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("Purchase.source validation", () => {
|
|
function withSource(source: unknown): string {
|
|
const data: any = createSeedDataset("2026-01");
|
|
data.posts[0].id = "housing";
|
|
data.recurring = [];
|
|
data.purchases = [{
|
|
id: "p1",
|
|
date: "2026-01",
|
|
description: "Rent",
|
|
total: { amount: 8000, currency: "DKK" },
|
|
splitMode: "percent",
|
|
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
|
schedule: null,
|
|
source,
|
|
}];
|
|
return JSON.stringify(data);
|
|
}
|
|
|
|
test("a purchase with no source is fine", () => {
|
|
const data: any = createSeedDataset("2026-01");
|
|
data.recurring = [];
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
|
|
});
|
|
|
|
test("a source naming an unknown cost is refused", () => {
|
|
expect(() => parseDatasetJson(withSource({ recurringId: "ghost", occurrenceDate: "2026-01" })))
|
|
.toThrow(/unknown recurring cost/);
|
|
});
|
|
|
|
test("a source with a malformed occurrence date is refused", () => {
|
|
const data: any = JSON.parse(withSource({ recurringId: "r1", occurrenceDate: "nope" }));
|
|
data.recurring = [{
|
|
id: "r1", name: "Rent", order: 3, folderId: null, archived: false,
|
|
amount: { amount: 8000, currency: "DKK" },
|
|
startDate: "2026-01",
|
|
recurrence: { kind: "everyNMonths", n: 1 },
|
|
anchoring: "calendar",
|
|
splitMode: "percent",
|
|
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
|
}];
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/occurrence date/);
|
|
});
|
|
|
|
test("a source of null is refused, not a raw TypeError", () => {
|
|
// `source: null` used to reach `null.recurringId` unguarded — a plausible
|
|
// hand-edit, since the sibling field on the same object literal IS
|
|
// `schedule: null`. `source` is typed absent-or-object, never null, so
|
|
// this must fail as ImportValidationError, not TypeError.
|
|
expect(() => parseDatasetJson(withSource(null))).toThrow(ImportValidationError);
|
|
});
|
|
});
|
|
|
|
describe("purchase splits validation", () => {
|
|
// I1/I2 apply identically to purchases: the shared requireSplits() helper
|
|
// must not check purchases to a lower standard than recurring costs.
|
|
test("an unknown splitMode on a purchase is refused", () => {
|
|
const data: any = populated();
|
|
data.purchases[0].splitMode = "ratio";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/splitMode/);
|
|
});
|
|
|
|
test("a non-numeric split value on a purchase is refused", () => {
|
|
const data: any = populated();
|
|
data.purchases[0].splits[0].value = "oops";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/finite number/);
|
|
});
|
|
|
|
test("a negative split value on a purchase is still accepted", () => {
|
|
const data: any = populated();
|
|
data.purchases[0].splits[0].value = -70;
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).not.toThrow();
|
|
});
|
|
|
|
test("a well-formed purchase still round-trips through the shared helper", () => {
|
|
const data = populated();
|
|
expect(parseDatasetJson(exportDatasetJson(data)).purchases.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("the occurrenceDate / anchoring pairing", () => {
|
|
function withCostAndCharge(anchoring: string, source: any): string {
|
|
const data: any = createSeedDataset("2026-09");
|
|
data.posts[0].id = "housing";
|
|
data.recurring = [{
|
|
id: "r1", name: "Bill", order: 99, folderId: null, archived: false,
|
|
amount: { amount: 300, currency: "DKK" },
|
|
startDate: anchoring === "lastCharge" ? "2026-09-01" : "2026-09",
|
|
recurrence: { kind: "everyNMonths", n: 1 },
|
|
anchoring, splitMode: "percent",
|
|
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
|
}];
|
|
data.purchases = [{
|
|
id: "p1", date: "2026-09-20", description: "Bill",
|
|
total: { amount: 300, currency: "DKK" }, splitMode: "percent",
|
|
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
|
schedule: null, source,
|
|
}];
|
|
return JSON.stringify(data);
|
|
}
|
|
|
|
test("a calendar charge REQUIRES occurrenceDate", () => {
|
|
expect(() => parseDatasetJson(withCostAndCharge("calendar", { recurringId: "r1" })))
|
|
.toThrow(/occurrence date/i);
|
|
});
|
|
|
|
test("a calendar charge with occurrenceDate is accepted", () => {
|
|
expect(() =>
|
|
parseDatasetJson(withCostAndCharge("calendar", { recurringId: "r1", occurrenceDate: "2026-09" })),
|
|
).not.toThrow();
|
|
});
|
|
|
|
test("an anchored charge FORBIDS occurrenceDate", () => {
|
|
// Its presence describes a schedule the app will not compute.
|
|
expect(() =>
|
|
parseDatasetJson(withCostAndCharge("lastCharge", { recurringId: "r1", occurrenceDate: "2026-09" })),
|
|
).toThrow(/does not settle a slot|no slots|anchored/i);
|
|
});
|
|
|
|
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", () => {
|
|
function withCost(anchoring: string, kind: string, startDate: string): string {
|
|
const data: any = createSeedDataset("2026-09");
|
|
data.posts[0].id = "housing";
|
|
data.recurring = [{
|
|
id: "r1", name: "Bill", order: 99, folderId: null, archived: false,
|
|
amount: { amount: 300, currency: "DKK" },
|
|
startDate, recurrence: kind === "everyNMonths" ? { kind, n: 1 } : { kind, n: 30 },
|
|
anchoring, splitMode: "percent",
|
|
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
|
}];
|
|
data.purchases = [];
|
|
return JSON.stringify(data);
|
|
}
|
|
|
|
test("anchored + everyNMonths now needs a DAY", () => {
|
|
expect(() => parseDatasetJson(withCost("lastCharge", "everyNMonths", "2026-09"))).toThrow(/day-granular/);
|
|
});
|
|
|
|
test("calendar + everyNMonths still accepts a month", () => {
|
|
expect(() => parseDatasetJson(withCost("calendar", "everyNMonths", "2026-09"))).not.toThrow();
|
|
});
|
|
|
|
test("calendar + everyNMonths still accepts a day", () => {
|
|
expect(() => parseDatasetJson(withCost("calendar", "everyNMonths", "2026-09-20"))).not.toThrow();
|
|
});
|
|
|
|
test("a calendar-impossible date is still refused", () => {
|
|
expect(() => parseDatasetJson(withCost("lastCharge", "everyNMonths", "2026-09-31"))).toThrow(/impossible/i);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* The other half of the same rule, and the half nobody checked: an anchored
|
|
* cost has no slots, so `anchoredOccurrences` steps forward from its latest
|
|
* CHARGE's own `Purchase.date`. `PURCHASE_DATE` allows "YYYY-MM" — right for
|
|
* an ordinary purchase, wrong for this one — so a month-only anchored charge
|
|
* imported clean and threw out of the very first fold, from a frame naming no
|
|
* cost. That falsified this file's own claim to validate far enough that the
|
|
* fold cannot throw, and made restoring a backup reproduce the crash.
|
|
*/
|
|
describe("an anchored cost's CHARGE dates need a day at the import boundary", () => {
|
|
function withCharge(anchoring: string, kind: string, purchaseDate: string): string {
|
|
const data: any = createSeedDataset("2026-09");
|
|
data.posts[0].id = "housing";
|
|
data.recurring = [{
|
|
id: "r1", name: "Bill", order: 99, folderId: null, archived: false,
|
|
amount: { amount: 300, currency: "DKK" },
|
|
// Before the charge, so the charge's own date is the anchor the walk
|
|
// uses rather than being masked by `max(latest, startDate)`.
|
|
startDate: anchoring === "lastCharge" ? "2026-08-04" : "2026-08",
|
|
recurrence: kind === "everyNMonths" ? { kind, n: 1 } : { kind, n: 30 },
|
|
anchoring, splitMode: "percent",
|
|
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
|
}];
|
|
data.purchases = [{
|
|
id: "p1", date: purchaseDate, description: "Bill",
|
|
total: { amount: 300, currency: "DKK" }, splitMode: "percent",
|
|
splits: [{ postId: "housing", value: 100, absorbsRemainder: true }],
|
|
schedule: null,
|
|
source: anchoring === "lastCharge"
|
|
? { recurringId: "r1" }
|
|
: { recurringId: "r1", occurrenceDate: "2026-09" },
|
|
}];
|
|
return JSON.stringify(data);
|
|
}
|
|
|
|
test("a month-only date on an anchored monthly charge is refused, naming the cost", () => {
|
|
expect(() => parseDatasetJson(withCharge("lastCharge", "everyNMonths", "2026-09"))).toThrow(
|
|
/Bill.*day-granular/,
|
|
);
|
|
});
|
|
|
|
test("a month-only date on an anchored DAY-granular charge is refused too", () => {
|
|
expect(() => parseDatasetJson(withCharge("lastCharge", "everyNDays", "2026-09"))).toThrow(
|
|
/Bill.*day-granular/,
|
|
);
|
|
});
|
|
|
|
test("a day-granular date on an anchored charge is accepted, and folds", () => {
|
|
const dataset = parseDatasetJson(withCharge("lastCharge", "everyNMonths", "2026-09-20"));
|
|
expect(() => occurrencesByMonth(dataset, "2026-12")).not.toThrow();
|
|
});
|
|
|
|
test("a day-SHAPED but calendar-impossible date on an anchored charge is refused", () => {
|
|
expect(() => parseDatasetJson(withCharge("lastCharge", "everyNMonths", "2026-02-30"))).toThrow(
|
|
/impossible/i,
|
|
);
|
|
});
|
|
|
|
test("a month-only date on a CALENDAR cost's charge is still accepted", () => {
|
|
// It settles the slot in `occurrenceDate`; its own date is never stepped
|
|
// from, so there is nothing here to require a day.
|
|
const dataset = parseDatasetJson(withCharge("calendar", "everyNMonths", "2026-09"));
|
|
expect(() => occurrencesByMonth(dataset, "2026-12")).not.toThrow();
|
|
});
|
|
|
|
test("every dataset this boundary accepts can be folded", () => {
|
|
// The claim in `parseDatasetJson`'s own doc, asserted rather than stated.
|
|
for (const date of ["2026-09-01", "2026-09-20", "2026-09-30"]) {
|
|
const dataset = parseDatasetJson(withCharge("lastCharge", "everyNMonths", date));
|
|
expect(() => occurrencesByMonth(dataset, "2027-06")).not.toThrow();
|
|
}
|
|
});
|
|
});
|