feat(content): add revision and publication lifecycle
This commit is contained in:
@@ -0,0 +1,379 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user