Files
budget2.0/src/store/snapshot.test.ts
T
Lucas Winther c250bbe476
Continuous Integration / lint-and-test (push) Successful in 29s
Continuous Integration / docker-build-and-push (push) Successful in 40s
feat: refine month totals and recurring purchase entry
2026-10-03 16:40:00 +02:00

404 lines
16 KiB
TypeScript

import { test, expect, describe } from "bun:test";
import { createSnapshotStore, type Persistence } from "./snapshot.ts";
import { createSeedDataset } from "../domain/seed.ts";
import type { Dataset, FxRate } from "../domain/types.ts";
function fakePersistence(initial: Dataset | null = null) {
const writes: Dataset[] = [];
let stored = initial;
const persistence: Persistence = {
async read() {
return stored;
},
async write(dataset) {
stored = dataset;
writes.push(dataset);
},
};
return { persistence, writes, get stored() { return stored; } };
}
describe("load", () => {
test("seeds and persists on first run", async () => {
const fake = fakePersistence(null);
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
expect(store.get().posts).toHaveLength(3);
expect(fake.writes).toHaveLength(1);
expect(fake.stored!.settings.foldStartMonth).toBe("2026-09");
});
test("uses stored data when it exists and does not rewrite it", async () => {
const existing = createSeedDataset("2025-01");
const fake = fakePersistence(existing);
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
expect(store.get().settings.foldStartMonth).toBe("2025-01");
expect(fake.writes).toHaveLength(0);
});
});
describe("mutate", () => {
test("applies the change, writes through, and notifies subscribers", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
let notifications = 0;
store.subscribe(() => notifications++);
await store.mutate((draft) => {
draft.months[0]!.income = { amount: 20000, currency: "DKK" };
});
expect(store.get().months[0]!.income.amount).toBe(20000);
expect(fake.stored!.months[0]!.income.amount).toBe(20000);
expect(notifications).toBe(1);
});
test("does not mutate the previous snapshot object", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
const before = store.get();
await store.mutate((draft) => {
draft.posts[0]!.name = "Renamed";
});
expect(before.posts[0]!.name).toBe("Video Games");
expect(store.get().posts[0]!.name).toBe("Renamed");
});
test("leaves the snapshot untouched when the write fails", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(
{ read: fake.persistence.read, write: async () => { throw new Error("disk full"); } },
"2026-09",
);
await store.load();
await expect(
store.mutate((draft) => { draft.posts[0]!.name = "Renamed"; }),
).rejects.toThrow("disk full");
expect(store.get().posts[0]!.name).toBe("Video Games");
});
test("two concurrent mutate() calls both survive (no lost update)", async () => {
// Simulate real persistence latency: each write takes ~20ms, so if the
// two mutations clone concurrently instead of queueing, the second
// commit silently overwrites the first's change.
let stored = createSeedDataset("2026-09");
const persistence: Persistence = {
async read() {
return stored;
},
async write(dataset) {
await new Promise((resolve) => setTimeout(resolve, 20));
stored = dataset;
},
};
const store = createSnapshotStore(persistence, "2026-09");
await store.load();
const first = store.mutate((draft) => {
draft.months[0]!.income = { amount: 20000, currency: "DKK" };
});
const second = store.mutate((draft) => {
draft.posts[0]!.name = "Renamed";
});
await Promise.all([first, second]);
expect(store.get().months[0]!.income.amount).toBe(20000);
expect(store.get().posts[0]!.name).toBe("Renamed");
expect(stored.months[0]!.income.amount).toBe(20000);
expect(stored.posts[0]!.name).toBe("Renamed");
});
test("a failing write mid-queue rejects only its own caller and does not wedge later mutations", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
// The queue processes mutations strictly in order, so failing the
// SECOND write call (regardless of when each mutate() is invoked)
// reliably targets the middle mutation below.
let writeCount = 0;
const persistence: Persistence = {
read: fake.persistence.read,
async write(dataset) {
writeCount++;
if (writeCount === 2) throw new Error("disk full");
await fake.persistence.write(dataset);
},
};
const store = createSnapshotStore(persistence, "2026-09");
await store.load();
const okBefore = store.mutate((draft) => {
draft.months[0]!.income = { amount: 111, currency: "DKK" };
});
const failing = store.mutate((draft) => {
draft.posts[0]!.name = "Should not stick";
});
const okAfter = store.mutate((draft) => {
draft.posts[1]!.name = "Should stick";
});
await okBefore;
await expect(failing).rejects.toThrow("disk full");
await okAfter;
expect(store.get().months[0]!.income.amount).toBe(111);
expect(store.get().posts[0]!.name).not.toBe("Should not stick");
expect(store.get().posts[1]!.name).toBe("Should stick");
});
});
test("replace swaps the whole dataset, as JSON import needs", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
const incoming = createSeedDataset("2020-01");
await store.replace(incoming);
expect(store.get().settings.foldStartMonth).toBe("2020-01");
expect(fake.stored!.settings.foldStartMonth).toBe("2020-01");
});
describe("reset", () => {
test("puts back exactly what a first run would have produced", async () => {
const fake = fakePersistence(null);
const rates: FxRate[] = [
{ currency: "USD", baseUnitsPerOne: 6.449532, updatedAt: "2026-09-01", source: "manual" },
];
const store = createSnapshotStore(fake.persistence, "2026-09", rates);
await store.load();
// Diverge from the seed in every direction a reset has to undo: an added
// currency, a renamed post, recorded income and a purchase.
await store.mutate((draft) => {
draft.currencies.push({ code: "CHF", name: "Swiss franc" });
draft.posts[0]!.name = "Renamed";
draft.posts.push({
id: "extra",
name: "Extra",
order: 9,
folderId: null,
archived: false,
currency: "DKK",
rules: [],
});
draft.months[0]!.income = { amount: 28000, currency: "DKK" };
draft.purchases.push({
id: "p1",
date: "2026-09-04",
description: "Groceries",
total: { amount: 100, currency: "DKK" },
splitMode: "percent",
splits: [{ postId: draft.posts[0]!.id, value: 100, absorbsRemainder: true }],
schedule: null,
});
});
let notifications = 0;
store.subscribe(() => notifications++);
await store.reset();
const after = store.get();
// Post IDs are freshly generated, so compare on everything else.
expect(after.posts.map((p) => p.name)).toEqual([
"Video Games",
"Food",
"Events and Social",
]);
expect(after.currencies.map((c) => c.code)).toEqual(["DKK", "USD", "EUR", "GBP", "JPY", "AUD", "CAD", "MXN"]);
expect(after.fxRates).toEqual(rates);
expect(after.purchases).toEqual([]);
expect(after.months).toHaveLength(1);
expect(after.months[0]!.income.amount).toBe(0);
expect(notifications).toBe(1);
});
test("persists, so the reset survives a reload", async () => {
const fake = fakePersistence(null);
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
await store.mutate((draft) => {
draft.posts.length = 0;
});
await store.reset();
expect(fake.stored!.posts).toHaveLength(3);
});
test("leaves the snapshot untouched when the write fails", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
await store.mutate((draft) => {
draft.posts[0]!.name = "Renamed";
});
fake.persistence.write = async () => {
throw new Error("quota exceeded");
};
await expect(store.reset()).rejects.toThrow("quota exceeded");
expect(store.get().posts[0]!.name).toBe("Renamed");
});
});
test("unsubscribe stops notifications", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
let notifications = 0;
const unsubscribe = store.subscribe(() => notifications++);
unsubscribe();
await store.mutate((draft) => { draft.posts[0]!.name = "X"; });
expect(notifications).toBe(0);
});
test("get before load throws rather than returning empty data", () => {
const fake = fakePersistence(null);
const store = createSnapshotStore(fake.persistence, "2026-09");
expect(() => store.get()).toThrow(/not loaded/i);
});
describe("seed rates", () => {
test("a first run seeds the rates it was given", async () => {
const fake = fakePersistence(null);
const rates = [
{ currency: "EUR" as const, baseUnitsPerOne: 7.474959, updatedAt: "2026-09-01", source: "manual" as const },
];
const store = createSnapshotStore(fake.persistence, "2026-09", rates);
await store.load();
expect(store.get().fxRates).toEqual(rates);
});
test("an existing dataset is never given seed rates", async () => {
// Loading stored data must not touch fxRates — the user may have
// deliberately cleared one, and re-adding it would convert money at a
// number they never chose.
const stored = createSeedDataset("2026-01");
const fake = fakePersistence(stored);
const store = createSnapshotStore(fake.persistence, "2026-09", [
{ currency: "EUR", baseUnitsPerOne: 7.474959, updatedAt: "2026-09-01", source: "manual" },
]);
await store.load();
expect(store.get().fxRates).toEqual([]);
});
});
describe("session history", () => {
test("undo and redo persist complete prior snapshots in order", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
const originalId = store.get().posts[0]!.id;
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
await store.mutate(draft => { draft.posts[0]!.name = "Second"; });
expect(store.getHistory()).toEqual({ canUndo: true, canRedo: false });
expect(await store.undo()).toBe(true);
expect(store.get().posts[0]).toMatchObject({ id: originalId, name: "First" });
expect(fake.stored!.posts[0]!.name).toBe("First");
expect(await store.undo()).toBe(true);
expect(store.get().posts[0]!.name).toBe("Video Games");
expect(await store.undo()).toBe(false);
expect(store.getHistory()).toEqual({ canUndo: false, canRedo: true });
expect(await store.redo()).toBe(true);
expect(await store.redo()).toBe(true);
expect(await store.redo()).toBe(false);
expect(store.get().posts[0]!.name).toBe("Second");
expect(fake.stored!.posts[0]!.name).toBe("Second");
});
test("an unchanged edit creates no step and does not discard redo", async () => {
const store = createSnapshotStore(fakePersistence(createSeedDataset("2026-09")).persistence, "2026-09");
await store.load();
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
await store.undo();
await store.mutate(draft => { draft.posts[0]!.name = "Video Games"; });
expect(store.getHistory()).toEqual({ canUndo: false, canRedo: true });
expect(await store.redo()).toBe(true);
expect(store.get().posts[0]!.name).toBe("First");
});
test("a new saved edit after undo discards redo", async () => {
const store = createSnapshotStore(fakePersistence(createSeedDataset("2026-09")).persistence, "2026-09");
await store.load();
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
await store.undo();
await store.mutate(draft => { draft.posts[1]!.name = "Changed"; });
expect(store.getHistory()).toEqual({ canUndo: true, canRedo: false });
expect(await store.redo()).toBe(false);
});
test("a rejected undo leaves data and history available for retry", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
fake.persistence.write = async () => { throw new Error("disk full"); };
await expect(store.undo()).rejects.toThrow("disk full");
expect(store.get().posts[0]!.name).toBe("First");
expect(store.getHistory()).toEqual({ canUndo: true, canRedo: false });
fake.persistence.write = async () => {};
expect(await store.undo()).toBe(true);
});
test("a rejected new edit preserves redo", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
await store.undo();
fake.persistence.write = async () => { throw new Error("disk full"); };
await expect(store.mutate(draft => { draft.posts[1]!.name = "Fail"; })).rejects.toThrow("disk full");
expect(store.getHistory()).toEqual({ canUndo: false, canRedo: true });
});
test("undo runs after already queued edits", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
const first = store.mutate(draft => { draft.posts[0]!.name = "First"; });
const second = store.mutate(draft => { draft.posts[0]!.name = "Second"; });
const undo = store.undo();
await Promise.all([first, second, undo]);
expect(store.get().posts[0]!.name).toBe("First");
expect(fake.stored!.posts[0]!.name).toBe("First");
});
test("import and reset clear history only after their writes succeed", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
await store.mutate(draft => { draft.posts[0]!.name = "First"; });
const incoming = createSeedDataset("2020-01");
fake.persistence.write = async () => { throw new Error("disk full"); };
await expect(store.replace(incoming)).rejects.toThrow("disk full");
expect(store.getHistory().canUndo).toBe(true);
fake.persistence.write = async () => {};
await store.replace(incoming);
expect(store.getHistory()).toEqual({ canUndo: false, canRedo: false });
await store.mutate(draft => { draft.posts[0]!.name = "New"; });
await store.reset();
expect(store.getHistory()).toEqual({ canUndo: false, canRedo: false });
});
test("history is limited to fifty edits and disappears on reload", async () => {
const fake = fakePersistence(createSeedDataset("2026-09"));
const store = createSnapshotStore(fake.persistence, "2026-09");
await store.load();
for (let n = 1; n <= 51; n++) {
await store.mutate(draft => { draft.posts[0]!.name = `Edit ${n}`; });
}
for (let n = 0; n < 50; n++) expect(await store.undo()).toBe(true);
expect(store.get().posts[0]!.name).toBe("Edit 1");
expect(await store.undo()).toBe(false);
const reopened = createSnapshotStore(fake.persistence, "2026-09");
await reopened.load();
expect(reopened.get().posts[0]!.name).toBe("Edit 1");
expect(reopened.getHistory()).toEqual({ canUndo: false, canRedo: false });
});
});