From 932b88b189776113781af6139ecda3f9f4778d63 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Tue, 1 Sep 2026 20:02:02 +0200 Subject: [PATCH] feat(export): add JSON export and validated import 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) --- src/export/json.test.ts | 118 ++++++++++++++++++++++++++++++++++++++ src/export/json.ts | 124 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 src/export/json.test.ts create mode 100644 src/export/json.ts diff --git a/src/export/json.test.ts b/src/export/json.test.ts new file mode 100644 index 0000000..f493b9c --- /dev/null +++ b/src/export/json.test.ts @@ -0,0 +1,118 @@ +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"); +}); diff --git a/src/export/json.ts b/src/export/json.ts new file mode 100644 index 0000000..6ebc8de --- /dev/null +++ b/src/export/json.ts @@ -0,0 +1,124 @@ +import { CURRENCIES, type Currency, type Dataset, type MonthId } from "../domain/types.ts"; +import { migrate } from "../store/migrations.ts"; + +export class ImportValidationError extends Error { + constructor(message: string) { + super(message); + this.name = "ImportValidationError"; + } +} + +export function exportDatasetJson(dataset: Dataset): string { + return JSON.stringify(dataset, null, 2); +} + +export function describeDataset(dataset: Dataset) { + return { + posts: dataset.posts.length, + months: dataset.months.length, + purchases: dataset.purchases.length, + }; +} + +export function exportFilename(monthId: MonthId): string { + return `budget-${monthId}.json`; +} + +// Shape AND range: months 01-12 only. A shape-only regex (\d{2}) lets +// "2026-13" through import and it then silently normalizes to a different +// month inside the balance fold (the same defect fixed earlier in +// src/domain/months.ts). This is the import path that made it reachable. +const MONTH_ID = /^\d{4}-(0[1-9]|1[0-2])$/; + +function requireCurrency(value: unknown, where: string): Currency { + if (!CURRENCIES.includes(value as Currency)) { + throw new ImportValidationError(`Unsupported currency ${String(value)} in ${where}`); + } + return value as Currency; +} + +function requireArray(data: Record, key: string): unknown[] { + const value = data[key]; + if (!Array.isArray(value)) { + throw new ImportValidationError(`Dataset field "${key}" is missing or not an array`); + } + return value; +} + +/** + * 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. + */ +export function parseDatasetJson(text: string): Dataset { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch (error) { + throw new ImportValidationError( + `File is not valid JSON: ${(error as Error).message}`, + ); + } + + let dataset: Dataset; + try { + dataset = migrate(raw); + } catch (error) { + throw new ImportValidationError((error as Error).message); + } + + const data = dataset as unknown as Record; + requireArray(data, "posts"); + requireArray(data, "months"); + requireArray(data, "purchases"); + requireArray(data, "fxRates"); + + requireCurrency(dataset.settings.baseCurrency, "settings.baseCurrency"); + if (!MONTH_ID.test(dataset.settings.foldStartMonth)) { + throw new ImportValidationError( + `Invalid foldStartMonth "${dataset.settings.foldStartMonth}"`, + ); + } + + const postIds = new Set(dataset.posts.map((p) => p.id)); + for (const post of dataset.posts) { + requireCurrency(post.currency, `post "${post.name}"`); + } + + for (const month of dataset.months) { + if (!MONTH_ID.test(month.id)) { + throw new ImportValidationError(`Invalid month id "${month.id}"`); + } + requireCurrency(month.income.currency, `month ${month.id} income`); + } + + for (const purchase of dataset.purchases) { + const label = `purchase "${purchase.description}"`; + requireCurrency(purchase.total.currency, label); + + if (purchase.splits.length === 0) { + throw new ImportValidationError(`${label} has no splits; at least one split is required`); + } + const absorbers = purchase.splits.filter((s) => s.absorbsRemainder).length; + if (absorbers !== 1) { + throw new ImportValidationError( + `${label} has ${absorbers} splits flagged absorbsRemainder; exactly one is required`, + ); + } + for (const split of purchase.splits) { + if (!postIds.has(split.postId)) { + throw new ImportValidationError( + `${label} references unknown post "${split.postId}"`, + ); + } + } + for (const slice of purchase.schedule?.slices ?? []) { + if (!MONTH_ID.test(slice.month)) { + throw new ImportValidationError(`${label} has invalid slice month "${slice.month}"`); + } + requireCurrency(slice.amount.currency, `${label} slice ${slice.month}`); + } + } + + return dataset; +}