import { describe, expect, it } from "vitest"; import { ContentLifecycle } from "./lifecycle"; const characterId = "17d23f26-df04-47e5-b8da-907d62b15260"; const pageId = "432cf444-dd8a-4ee5-8b21-6dc6d5d325e7"; const secondPageId = "9e52ac3a-b3fe-431b-adb6-d9cae2798705"; const sourceId = "33a89cab-d063-4551-9b13-5b3eb6ab6e61"; const mediaId = "cb661013-c489-4da0-8b1f-f7afcb327dd6"; const authorId = "admin"; function setup(now = new Date("2026-08-28T10:15:00.000Z")) { let sequence = 0; const store = new ContentLifecycle({ now: () => new Date(now), idFactory: () => `00000000-0000-4000-8000-${String(++sequence).padStart(12, "0")}`, }); store.addCharacter({ id: characterId, slug: "mizuki", nameTh: "มิซึกิ", nameEn: "Mizuki", description: null, portraitMediaId: null, element: "ลม", role: "สนับสนุน", rarity: 5, sortOrder: 0, }); store.addDataSource({ id: sourceId, name: "อาวุธ", version: 1, fields: [ { key: "name", label: "ชื่อ", type: "text" }, { key: "base", label: "พื้นฐาน", type: "number" }, { key: "bonus", label: "โบนัส", type: "number" }, { key: "score", label: "คะแนน", type: "number", formula: "base + bonus", }, ], rows: [ { id: "06200523-1a7b-478c-8587-c54483458dac", values: { name: "ตัวเลือก A", base: "10", bonus: "2", score: null }, }, ], }); store.addPage({ id: pageId, characterId, slug: "info", title: "ข้อมูล", navOrder: 1, publicNote: null, }); store.addPage({ id: secondPageId, characterId, slug: "combo", title: "คอมโบ", navOrder: 0, publicNote: null, }); return store; } function rankingBlock() { return { id: "1e0b3229-549c-4897-a765-50c56d26b414", type: "ranking", schemaVersion: 1, sortOrder: 0, config: { title: "อันดับอาวุธ", dataSourceId: sourceId, labelField: "name", valueField: "score", higherIsBetter: true, }, }; } describe("content lifecycle", () => { it("starts new content hidden and requires both visibility flags", () => { const store = setup(); expect(store.getDirectory()).toEqual([]); store.setCharacterVisibility(characterId, true); expect(store.getDirectory()).toEqual([]); const saved = store.savePage({ pageId, expectedVersion: 1, authorId, title: "ข้อมูล", slug: "info", visible: true, publicNote: "อัปเดตล่าสุดสำหรับเวอร์ชัน 1.0", blocks: [rankingBlock()], }); expect(saved.status).toBe("accepted"); expect(store.getDirectory()).toMatchObject([ { slug: "mizuki", firstPageSlug: "info" }, ]); expect(store.resolvePublicRoute("mizuki", "info")).toMatchObject({ type: "page", snapshot: { page: { publicNote: "อัปเดตล่าสุดสำหรับเวอร์ชัน 1.0" } }, }); }); it("orders pages and redirects a character route to the first visible page", () => { const store = setup(); store.setCharacterVisibility(characterId, true); store.savePage({ pageId, expectedVersion: 1, authorId, title: "ข้อมูล", slug: "info", visible: true, blocks: [], }); store.savePage({ pageId: secondPageId, expectedVersion: 1, authorId, title: "คอมโบ", slug: "combo", visible: true, blocks: [], }); expect(store.resolvePublicRoute("mizuki")).toEqual({ type: "redirect", location: "/mizuki/combo", status: 308, }); }); it("creates permanent aliases after custom character and page slug changes", () => { const store = setup(); store.setCharacterVisibility(characterId, true); store.savePage({ pageId, expectedVersion: 1, authorId, title: "ข้อมูล", slug: "build-info", visible: true, blocks: [], }); expect(store.resolvePublicRoute("mizuki", "info")).toEqual({ type: "redirect", location: "/mizuki/build-info", status: 308, }); store.renameCharacter(characterId, "mizuki-guide"); expect(store.resolvePublicRoute("mizuki", "build-info")).toEqual({ type: "redirect", location: "/mizuki-guide/build-info", status: 308, }); }); it("rejects stale concurrent changes and invalid blocks", () => { const store = setup(); const accepted = store.savePage({ pageId, expectedVersion: 1, authorId, title: "ข้อมูลใหม่", slug: "info", visible: false, blocks: [], }); expect(accepted.status).toBe("accepted"); expect( store.savePage({ pageId, expectedVersion: 1, authorId, title: "เขียนทับ", slug: "info", visible: false, blocks: [], }), ).toEqual({ status: "conflict", currentVersion: 2 }); expect( store.savePage({ pageId, expectedVersion: 2, authorId, title: "ข้อมูลใหม่", slug: "info", visible: false, blocks: [ { id: "c9c1fc08-8180-439b-8962-26fe1c8977b1", type: "callout", schemaVersion: 1, sortOrder: 0, config: { body: "", tone: "unknown" }, }, ], }).status, ).toBe("validation-error"); }); it("versions global sources independently and refreshes only latest consumers", () => { const store = setup(); const saved = store.savePage({ pageId, expectedVersion: 1, authorId, title: "ข้อมูล", slug: "info", visible: false, blocks: [rankingBlock()], }); expect(saved.status).toBe("accepted"); const beforeEvents = store.getOutbox().length; const source = store.saveDataSource({ id: sourceId, expectedVersion: 1, name: "อาวุธ", fields: [ { key: "value", label: "ค่า", type: "number" }, { key: "broken", label: "สูตรที่ผิด", type: "number", formula: "value / 0", }, ], rows: [ { id: "560bb5b4-f4c7-46ba-82d5-e3dca8217b0a", values: { value: "4", broken: null }, }, ], }); expect(source.status).toBe("accepted"); expect(store.getOutbox().slice(beforeEvents)).toMatchObject([ { eventType: "data-source.updated", payload: { version: 2 } }, { eventType: "page.updated", aggregateId: pageId }, ]); }); it("coalesces hourly checkpoints, keeps named revisions, and expires only checkpoints", () => { const store = setup(); store.savePage({ pageId, expectedVersion: 1, authorId, title: "ครั้งที่ 1", slug: "info", visible: false, blocks: [], now: new Date("2026-08-28T10:05:00.000Z"), }); store.createNamedRevision( pageId, "เวอร์ชันเปิดตัว", authorId, new Date("2026-08-28T10:10:00.000Z"), ); store.savePage({ pageId, expectedVersion: 2, authorId, title: "ครั้งที่ 2", slug: "info", visible: false, blocks: [], now: new Date("2026-08-28T10:55:00.000Z"), }); expect(store.listRevisions(pageId)).toHaveLength(2); expect( store.listRevisions(pageId).filter((revision) => revision.kind === "checkpoint"), ).toHaveLength(1); const expired = store.expireCheckpoints( new Date("2026-09-28T11:00:00.000Z"), ); expect(expired).toHaveLength(1); expect(store.listRevisions(pageId)).toMatchObject([ { kind: "named", label: "เวอร์ชันเปิดตัว", expiresAt: null }, ]); }); it("restores a new page version pinned to historical data and can use latest again", () => { const store = setup(); store.savePage({ pageId, expectedVersion: 1, authorId, title: "เวอร์ชันเดิม", slug: "info", visible: false, blocks: [rankingBlock()], }); const revision = store.createNamedRevision(pageId, "ก่อนปรับสูตร", authorId)!; store.saveDataSource({ id: sourceId, expectedVersion: 1, name: "อาวุธรุ่นใหม่", fields: [{ key: "value", label: "ค่า", type: "number" }], rows: [], }); store.savePage({ pageId, expectedVersion: 2, authorId, title: "เวอร์ชันใหม่", slug: "info", visible: false, blocks: [rankingBlock()], }); const restored = store.restoreRevision(revision.id, 3, authorId); expect(restored.status).toBe("accepted"); if (restored.status === "accepted") { expect(restored.value.page.version).toBe(4); expect(restored.value.page.title).toBe("เวอร์ชันเดิม"); expect(restored.value.versionVector[sourceId]).toBe(1); } const latest = store.useLatestDataSource(pageId, sourceId, 4, authorId); expect(latest.status).toBe("accepted"); if (latest.status === "accepted") { expect(latest.value.page.version).toBe(5); expect(latest.value.versionVector[sourceId]).toBe(2); } }); it("retains media for current content or revisions and exposes it only from visible snapshots", () => { const store = setup(); store.setCharacterVisibility(characterId, true); const imageBlock = { id: "949499c7-3162-4ff5-87f0-f90711a97e6f", type: "image", schemaVersion: 1, sortOrder: 0, config: { mediaId, alt: "ภาพตัวอย่าง", fit: "cover" }, }; store.savePage({ pageId, expectedVersion: 1, authorId, title: "ข้อมูล", slug: "info", visible: true, blocks: [imageBlock], }); store.createNamedRevision(pageId, "มีภาพ", authorId); expect(store.isMediaPublic(mediaId)).toBe(true); expect(store.isMediaRetained(mediaId)).toBe(true); store.savePage({ pageId, expectedVersion: 2, authorId, title: "ข้อมูล", slug: "info", visible: true, blocks: [], }); expect(store.isMediaPublic(mediaId)).toBe(false); expect(store.isMediaRetained(mediaId)).toBe(true); }); });