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");
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user