Files
initiative/apps/web/src/__tests__/validate-import-bundle.test.ts
Lukas fba83bebd6 Add JSON import/export for full encounter state
Export and import encounter, undo/redo history, and player characters
as a downloadable .json file. Export/import actions are in the action
bar overflow menu. Import validates using existing rehydration functions
and shows a confirmation dialog when replacing a non-empty encounter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:28:39 +01:00

250 lines
6.9 KiB
TypeScript

import type { ExportBundle } from "@initiative/domain";
import { describe, expect, it } from "vitest";
import { validateImportBundle } from "../persistence/export-import.js";
function validBundle(): Record<string, unknown> {
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);
});
});