import type { ExportBundle } from "@initiative/domain"; import { describe, expect, it } from "vitest"; import { validateImportBundle } from "../persistence/export-import.js"; function validBundle(): Record { return { version: 1, exportedAt: "2026-03-27T12:00:00.000Z", encounter: { combatants: [{ id: "c-1", name: "Goblin", initiative: 15 }], activeIndex: 0, roundNumber: 1, }, undoStack: [], redoStack: [], playerCharacters: [], }; } describe("validateImportBundle", () => { it("accepts a valid bundle", () => { const result = validateImportBundle(validBundle()); expect(typeof result).toBe("object"); const bundle = result as ExportBundle; expect(bundle.version).toBe(1); expect(bundle.encounter.combatants).toHaveLength(1); expect(bundle.encounter.combatants[0].name).toBe("Goblin"); }); it("accepts a valid bundle with empty encounter", () => { const input = { ...validBundle(), encounter: { combatants: [], activeIndex: 0, roundNumber: 1 }, }; const result = validateImportBundle(input); expect(typeof result).toBe("object"); const bundle = result as ExportBundle; expect(bundle.encounter.combatants).toHaveLength(0); }); it("accepts a bundle with undo/redo stacks", () => { const enc = { combatants: [{ id: "c-1", name: "Orc" }], activeIndex: 0, roundNumber: 1, }; const input = { ...validBundle(), undoStack: [enc], redoStack: [enc], }; const result = validateImportBundle(input); expect(typeof result).toBe("object"); const bundle = result as ExportBundle; expect(bundle.undoStack).toHaveLength(1); expect(bundle.redoStack).toHaveLength(1); }); it("accepts a bundle with player characters", () => { const input = { ...validBundle(), playerCharacters: [ { id: "pc-1", name: "Aria", ac: 16, maxHp: 45, color: "blue", icon: "sword", }, ], }; const result = validateImportBundle(input); expect(typeof result).toBe("object"); const bundle = result as ExportBundle; expect(bundle.playerCharacters).toHaveLength(1); expect(bundle.playerCharacters[0].name).toBe("Aria"); }); it("rejects non-object input", () => { expect(validateImportBundle(null)).toBe("Invalid file format"); expect(validateImportBundle(42)).toBe("Invalid file format"); expect(validateImportBundle("string")).toBe("Invalid file format"); expect(validateImportBundle([])).toBe("Invalid file format"); expect(validateImportBundle(undefined)).toBe("Invalid file format"); }); it("rejects missing version field", () => { const input = validBundle(); delete input.version; expect(validateImportBundle(input)).toBe("Invalid file format"); }); it("rejects version 0 or negative", () => { expect(validateImportBundle({ ...validBundle(), version: 0 })).toBe( "Invalid file format", ); expect(validateImportBundle({ ...validBundle(), version: -1 })).toBe( "Invalid file format", ); }); it("rejects unknown version", () => { expect(validateImportBundle({ ...validBundle(), version: 99 })).toBe( "Invalid file format", ); }); it("rejects missing encounter field", () => { const input = validBundle(); delete input.encounter; expect(validateImportBundle(input)).toBe("Invalid encounter data"); }); it("rejects invalid encounter data", () => { expect( validateImportBundle({ ...validBundle(), encounter: "not an object" }), ).toBe("Invalid encounter data"); }); it("rejects missing undoStack", () => { const input = validBundle(); delete input.undoStack; expect(validateImportBundle(input)).toBe("Invalid file format"); }); it("rejects missing redoStack", () => { const input = validBundle(); delete input.redoStack; expect(validateImportBundle(input)).toBe("Invalid file format"); }); it("rejects missing playerCharacters", () => { const input = validBundle(); delete input.playerCharacters; expect(validateImportBundle(input)).toBe("Invalid file format"); }); it("rejects non-string exportedAt", () => { expect(validateImportBundle({ ...validBundle(), exportedAt: 12345 })).toBe( "Invalid file format", ); }); it("drops invalid entries from undo stack", () => { const valid = { combatants: [{ id: "c-1", name: "Orc" }], activeIndex: 0, roundNumber: 1, }; const input = { ...validBundle(), undoStack: [valid, "invalid", { bad: true }, valid], }; const result = validateImportBundle(input); expect(typeof result).toBe("object"); const bundle = result as ExportBundle; expect(bundle.undoStack).toHaveLength(2); }); it("drops invalid player characters", () => { const input = { ...validBundle(), playerCharacters: [ { id: "pc-1", name: "Valid", ac: 10, maxHp: 20 }, { id: "", name: "Bad ID" }, "not an object", { id: "pc-3", name: "Also Valid", ac: 15, maxHp: 30 }, ], }; const result = validateImportBundle(input); expect(typeof result).toBe("object"); const bundle = result as ExportBundle; expect(bundle.playerCharacters).toHaveLength(2); }); it("rejects JSON array instead of object", () => { expect(validateImportBundle([1, 2, 3])).toBe("Invalid file format"); }); it("rejects encounter that fails rehydration (missing combatant fields)", () => { const input = { ...validBundle(), encounter: { combatants: [{ noId: true }], activeIndex: 0, roundNumber: 1, }, }; expect(validateImportBundle(input)).toBe("Invalid encounter data"); }); it("strips invalid color/icon from player characters but keeps the character", () => { const input = { ...validBundle(), playerCharacters: [ { id: "pc-1", name: "Test", ac: 10, maxHp: 20, color: "neon-pink", icon: "bazooka", }, ], }; const result = validateImportBundle(input); // rehydrateCharacter rejects characters with invalid color/icon members // that are not in the valid sets, so this character is dropped expect(typeof result).toBe("object"); const bundle = result as ExportBundle; expect(bundle.playerCharacters).toHaveLength(0); }); it("keeps player characters with valid optional color and icon", () => { const input = { ...validBundle(), playerCharacters: [ { id: "pc-1", name: "Aria", ac: 16, maxHp: 45, color: "blue", icon: "sword", }, ], }; const result = validateImportBundle(input); expect(typeof result).toBe("object"); const bundle = result as ExportBundle; expect(bundle.playerCharacters).toHaveLength(1); expect(bundle.playerCharacters[0].color).toBe("blue"); expect(bundle.playerCharacters[0].icon).toBe("sword"); }); it("ignores unknown extra fields on the bundle", () => { const input = { ...validBundle(), unknownField: "should be ignored", anotherExtra: 42, }; const result = validateImportBundle(input); expect(typeof result).toBe("object"); const bundle = result as ExportBundle; expect(bundle.version).toBe(1); expect("unknownField" in bundle).toBe(false); }); });