Allow month-only purchases to start finance plans. Keep unbudgeted posts distinct from explicit zero rules in overspend reporting.
660 lines
25 KiB
TypeScript
660 lines
25 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 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("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("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("no splits at all is refused", () => {
|
|
expect(() => parseDatasetJson(withRecurring([{ ...valid, splits: [] }])))
|
|
.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);
|
|
});
|
|
});
|