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) <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5 committed 2026-09-01 20:02:02 +02:00
1 parent 0625a5ffa5
commit 932b88b189
2 files changed
+242

No files matched your search

+118
View File
@@ -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");
});
+124
View File
@@ -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<string, unknown>, 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<string, unknown>;
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;
}