380 lines
10 KiB
TypeScript
380 lines
10 KiB
TypeScript
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);
|
|
});
|
|
});
|