Import validates far enough that the fold cannot throw on imported data:
exactly one remainder-absorbing split per purchase, at least one split,
every split.postId resolving to a real post, every currency supported,
and every MonthId well-formed and in range (01-12). Over-budget and
percentages not summing to 100 remain permitted.
Tightens MONTH_ID to /^\d{4}-(0[1-9]|1[0-2])$/ (shape-only regex would
let "2026-13" through import and silently misfile inside the balance
fold, mirroring the defect already fixed in src/domain/months.ts).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
119 lines
3.9 KiB
TypeScript
119 lines
3.9 KiB
TypeScript
import { test, expect, describe } from "bun:test";
|
|
import {
|
|
exportDatasetJson,
|
|
parseDatasetJson,
|
|
describeDataset,
|
|
ImportValidationError,
|
|
exportFilename,
|
|
} from "./json.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("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 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 a purchase with no splits", () => {
|
|
const data = populated();
|
|
data.purchases[0]!.splits = [];
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/at least one split/i);
|
|
});
|
|
|
|
test("rejects an unsupported currency", () => {
|
|
const data = populated() as any;
|
|
data.settings.baseCurrency = "GBP";
|
|
expect(() => parseDatasetJson(JSON.stringify(data))).toThrow(/GBP/);
|
|
});
|
|
|
|
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/);
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|