import { IDBFactory } from "fake-indexeddb"; import type { SaveDocument } from "@deadhead/schema"; import { describe, expect, it } from "vitest"; import { fullSave } from "../../schema/test/save-fixture"; import { FilesystemBackend, IndexedDbBackend, MemoryBackend, SaveStorage, type FilesystemPlugin, type SaveBackend, } from "../src/index"; /** A Capacitor Filesystem stand-in: files in a map, with failures on demand. */ class FakeFilesystem implements FilesystemPlugin { files = new Map(); /** Fail the next writeFile after writing this many characters of it. */ failWriteAfter: number | null = null; failRename = false; async readFile({ path }: { path: string }) { const data = this.files.get(path); if (data === undefined) throw new Error("File does not exist"); return { data }; } async writeFile({ path, data }: { path: string; data: string }) { if (this.failWriteAfter !== null) { this.files.set(path, data.slice(0, this.failWriteAfter)); this.failWriteAfter = null; throw new Error("Disk full"); } this.files.set(path, data); } async rename({ from, to }: { from: string; to: string }) { if (this.failRename) throw new Error("Interrupted"); const data = this.files.get(from); if (data === undefined) throw new Error("File does not exist"); this.files.set(to, data); this.files.delete(from); } async deleteFile({ path }: { path: string }) { if (!this.files.delete(path)) throw new Error("File does not exist"); } } const version = (n: number): SaveDocument => ({ ...fullSave(), lastTimeSeen: 1_000 + n }); const backends: [string, () => SaveBackend][] = [ ["memory", () => new MemoryBackend()], ["IndexedDB", () => new IndexedDbBackend(new IDBFactory())], ["filesystem", () => new FilesystemBackend(new FakeFilesystem())], ]; describe.each(backends)("save storage on %s (Step 14.3)", (_name, makeBackend) => { it("is empty before the first save", async () => { expect(await new SaveStorage(makeBackend()).load()).toEqual({ status: "empty" }); }); it("loads what it saved", async () => { const storage = new SaveStorage(makeBackend()); await storage.save(version(1)); expect(await storage.load()).toEqual({ status: "loaded", save: version(1) }); }); it("keeps exactly the last three saves as backups, newest first", async () => { const storage = new SaveStorage(makeBackend()); for (let n = 1; n <= 5; n++) await storage.save(version(n)); expect(await storage.listBackups()).toEqual([ { index: 1, valid: true, lastTimeSeen: 1_004 }, { index: 2, valid: true, lastTimeSeen: 1_003 }, { index: 3, valid: true, lastTimeSeen: 1_002 }, ]); const second = await storage.loadBackup(2); expect(second.ok && second.save.lastTimeSeen).toBe(1_003); }); it("falls back to the newest valid backup when the main save is corrupted", async () => { const backend = makeBackend(); const storage = new SaveStorage(backend); await storage.save(version(1)); await storage.save(version(2)); await storage.save(version(3)); await backend.write("main", '{"saveVersion": 1, "company": '); await backend.write("backup-1", JSON.stringify({ ...version(2), company: null })); const result = await storage.load(); expect(result).toMatchObject({ status: "restored-from-backup", backup: 2, save: version(1) }); }); it("reports an unreadable save when nothing validates", async () => { const backend = makeBackend(); await backend.write("main", "not a save"); expect(await new SaveStorage(backend).load()).toEqual({ status: "unreadable", problem: "The save isn't valid JSON.", }); }); it("clears the save and its backups", async () => { const storage = new SaveStorage(makeBackend()); await storage.save(version(1)); await storage.save(version(2)); await storage.clear(); expect(await storage.load()).toEqual({ status: "empty" }); expect(await storage.listBackups()).toEqual([]); }); }); describe("native writes are atomic (Step 14.3)", () => { it("never leaves a half-written main file when a write is interrupted", async () => { const fs = new FakeFilesystem(); const storage = new SaveStorage(new FilesystemBackend(fs)); await storage.save(version(1)); fs.failWriteAfter = 20; await expect(storage.save(version(2))).rejects.toThrow("Disk full"); expect(await storage.load()).toEqual({ status: "loaded", save: version(1) }); fs.failRename = true; await expect(storage.save(version(3))).rejects.toThrow("Interrupted"); expect(await storage.load()).toEqual({ status: "loaded", save: version(1) }); fs.failRename = false; await storage.save(version(4)); expect(await storage.load()).toEqual({ status: "loaded", save: version(4) }); }); });