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:
1 parent
0625a5ffa5
commit
932b88b189
2 files changed
+242
No files matched your search
@@ -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");
|
||||
});
|
||||
Reference in new issue
Block a user