import { getPayload, Payload } from "payload"; import config from "@/payload.config"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { StoryBeat, StoryBeatState, StoryVoiceover, User } from "@/payload-types"; import { markBeatRead, setBeatReaction } from "@/lib/story"; import { sortBeats, chronicleDisplayOrder } from "@/lib/story/sortBeats"; import { bookColumnOffset, bookPageCount, bookSwipeDirection } from "@/lib/story/bookLayout"; let payload: Payload; const RUN = `story-${Date.now().toString(36)}`; // Minimal MPEG-1 Layer 3 frame-sync header: what `file-type` sniffs as // audio/mpeg. The bytes must be wrapped in a realm-local Uint8Array, because // vitest's jsdom environment swaps the global Uint8Array and Node Buffers then // fail file-type's instanceof check (Payload would report the file unreadable). function mp3FileData(): Buffer { const buffer = Buffer.alloc(67); buffer[0] = 0xff; buffer[1] = 0xfb; return new Uint8Array(buffer) as unknown as Buffer; } function textFileData(text: string): Buffer { return new Uint8Array(Buffer.from(text)) as unknown as Buffer; } function lexicalBody(text: string): StoryBeat["body"] { return { root: { type: "root", format: "", indent: 0, version: 1, direction: null, children: [ { type: "paragraph", format: "", indent: 0, version: 1, direction: null, textFormat: 0, textStyle: "", children: [ { type: "text", format: 0, version: 1, mode: "normal", style: "", text, detail: 0 }, ], }, ], }, } as StoryBeat["body"]; } describe("Story beats", () => { let admin: User; let adminId: number; let user: User; let userId: number; let otherUser: User; let otherUserId: number; const beatIds: number[] = []; const stateIds: number[] = []; const voiceoverIds: number[] = []; let draftBeatId: number; let publishedBeatId: number; beforeAll(async () => { const payloadConfig = await config; payload = await getPayload({ config: payloadConfig }); const createTestUser = async (name: string, roles: User["roles"]) => { return (await payload.create({ collection: "users", data: { username: `${RUN}-${name}`, discordUsername: `${RUN}-${name}`, displayName: name.toUpperCase(), steamId: `7656119${Math.floor(Math.random() * 1e9)}`, password: "Test123", roles, }, overrideAccess: true, depth: 0, })) as unknown as User; }; admin = await createTestUser("admin", ["admin"]); adminId = admin.id; user = await createTestUser("user", ["user"]); userId = user.id; otherUser = await createTestUser("other", ["user"]); otherUserId = otherUser.id; const draftBeat = (await payload.create({ collection: "story-beats", data: { title: `${RUN} Draft beat`, status: "draft", body: lexicalBody("A draft beat that should never be public."), }, overrideAccess: true, depth: 0, })) as unknown as StoryBeat; draftBeatId = draftBeat.id; beatIds.push(draftBeatId); const publishedBeat = (await payload.create({ collection: "story-beats", data: { title: `${RUN} Published beat`, status: "published", body: lexicalBody("A published beat body."), }, overrideAccess: true, depth: 0, })) as unknown as StoryBeat; publishedBeatId = publishedBeat.id; beatIds.push(publishedBeatId); }); afterAll(async () => { for (const id of voiceoverIds) { await payload .delete({ collection: "story-voiceovers", id, overrideAccess: true }) .catch(() => {}); } for (const id of stateIds) { await payload .delete({ collection: "story-beat-states", id, overrideAccess: true }) .catch(() => {}); } for (const id of beatIds) { await payload.delete({ collection: "story-beats", id, overrideAccess: true }).catch(() => {}); } for (const id of [adminId, userId, otherUserId]) { const accounts = await payload .find({ collection: "bank-accounts", where: { ownerUser: { equals: id } }, limit: 5, depth: 0, overrideAccess: true, }) .catch(() => null); for (const account of accounts?.docs ?? []) { await payload .delete({ collection: "bank-accounts", id: account.id, overrideAccess: true }) .catch(() => {}); } const profiles = await payload .find({ collection: "profiles", where: { user: { equals: id } }, limit: 5, depth: 0, overrideAccess: true, }) .catch(() => null); for (const profile of profiles?.docs ?? []) { await payload .delete({ collection: "profiles", id: profile.id, overrideAccess: true }) .catch(() => {}); } await payload.delete({ collection: "users", id, overrideAccess: true }).catch(() => {}); } }); describe("visibility gate", () => { it("hides all beats from guests", async () => { await expect( payload.find({ collection: "story-beats", overrideAccess: false }), ).rejects.toThrow(); await expect( payload.findByID({ collection: "story-beats", id: publishedBeatId, overrideAccess: false, }), ).rejects.toThrow(); }); it("shows only published beats to normal users", async () => { const result = (await payload.find({ collection: "story-beats", where: { title: { like: RUN } }, overrideAccess: false, user, depth: 0, })) as unknown as { docs: StoryBeat[] }; expect(result.docs).toHaveLength(1); expect(result.docs[0]?.id).toBe(publishedBeatId); expect(result.docs[0]?.status).toBe("published"); await expect( payload.findByID({ collection: "story-beats", id: draftBeatId, overrideAccess: false, user, depth: 0, }), ).rejects.toThrow(); }); it("shows all beats to admin/developer authors", async () => { const result = (await payload.find({ collection: "story-beats", where: { title: { like: RUN } }, overrideAccess: false, user: admin, depth: 0, })) as unknown as { docs: StoryBeat[] }; const ids = result.docs.map((beat) => beat.id).sort(); expect(ids).toEqual([draftBeatId, publishedBeatId].sort()); }); it("blocks normal users from creating beats", async () => { await expect( payload.create({ collection: "story-beats", data: { title: "nope", status: "draft", body: lexicalBody("nope") }, overrideAccess: false, user, depth: 0, }), ).rejects.toThrow(); }); it("allows admin authors to create and publish beats", async () => { const created = (await payload.create({ collection: "story-beats", data: { title: `${RUN} Authoring check`, status: "draft", body: lexicalBody("wip") }, overrideAccess: false, user: admin, depth: 0, })) as unknown as StoryBeat; beatIds.push(created.id); const published = (await payload.update({ collection: "story-beats", id: created.id, data: { status: "published" }, overrideAccess: false, user: admin, depth: 0, })) as unknown as StoryBeat; expect(published.status).toBe("published"); }); }); describe("read state", () => { it("marks a published beat read for a normal user", async () => { const state = await markBeatRead(payload, { user, beatId: publishedBeatId }); stateIds.push(state.id); expect(state.readAt).toBeTruthy(); }); it("is idempotent: a second read does not change readAt", async () => { const first = await markBeatRead(payload, { user, beatId: publishedBeatId }); const second = await markBeatRead(payload, { user, beatId: publishedBeatId }); expect(second.id).toBe(first.id); expect(second.readAt).toBe(first.readAt); }); it("refuses to mark a draft beat read for a normal user", async () => { await expect(markBeatRead(payload, { user, beatId: draftBeatId })).rejects.toThrow(); }); it("scopes state docs to their owner", async () => { const result = (await payload.find({ collection: "story-beat-states", where: { id: { in: stateIds } }, overrideAccess: false, user: otherUser, depth: 0, })) as unknown as { docs: StoryBeatState[] }; expect(result.docs).toHaveLength(0); }); }); describe("reactions", () => { it("sets and replaces reactions", async () => { const heart = await setBeatReaction(payload, { user, beatId: publishedBeatId, reaction: "heart", }); expect(heart.reaction).toBe("heart"); const salute = await setBeatReaction(payload, { user, beatId: publishedBeatId, reaction: "salute", }); expect(salute.reaction).toBe("salute"); }); it("toggles the same reaction off", async () => { const set = await setBeatReaction(payload, { user, beatId: publishedBeatId, reaction: "like", }); expect(set.reaction).toBe("like"); const cleared = await setBeatReaction(payload, { user, beatId: publishedBeatId, reaction: "like", }); expect(cleared.reaction).toBeNull(); }); it("rejects invalid reaction values", async () => { await expect( setBeatReaction(payload, { user, beatId: publishedBeatId, reaction: "fire" as never, }), ).rejects.toThrow("Invalid reaction."); }); it("refuses reactions on draft beats for normal users", async () => { await expect( setBeatReaction(payload, { user, beatId: draftBeatId, reaction: "heart" }), ).rejects.toThrow(); }); it("persists reaction state on the user's beat-state doc", async () => { await setBeatReaction(payload, { user, beatId: publishedBeatId, reaction: "salute" }); const result = (await payload.find({ collection: "story-beat-states", where: { and: [{ user: { equals: userId } }, { beat: { equals: publishedBeatId } }] }, overrideAccess: false, user, depth: 0, })) as unknown as { docs: StoryBeatState[] }; expect(result.docs).toHaveLength(1); expect(result.docs[0]?.reaction).toBe("salute"); expect(result.docs[0]?.readAt).toBeTruthy(); }); }); describe("voiceovers", () => { let voiceoverId: number; it("blocks normal users from creating voiceovers", async () => { await expect( payload.create({ collection: "story-voiceovers", data: {}, file: { data: mp3FileData(), mimetype: "audio/mpeg", name: `${RUN}-ch0.mp3`, size: 67, }, overrideAccess: false, user, depth: 0, }), ).rejects.toThrow(); }); it("allows admin authors to upload audio files", async () => { const created = (await payload.create({ collection: "story-voiceovers", data: {}, file: { data: mp3FileData(), mimetype: "audio/mpeg", name: `${RUN}-ch1.mp3`, size: 67, }, overrideAccess: false, user: admin, depth: 0, })) as unknown as StoryVoiceover; voiceoverId = created.id; voiceoverIds.push(created.id); expect(created.filename).toBe(`${RUN}-ch1.mp3`); expect(created.mimeType).toBe("audio/mpeg"); expect(created.url).toBeTruthy(); }); it("hides voiceovers from guests but lets any logged-in user read them", async () => { await expect( payload.find({ collection: "story-voiceovers", overrideAccess: false }), ).rejects.toThrow(); const result = (await payload.find({ collection: "story-voiceovers", overrideAccess: false, user, depth: 0, })) as unknown as { docs: StoryVoiceover[] }; expect(result.docs.map((doc) => doc.id)).toContain(voiceoverId); }); it("flags AI-generated narrations and defaults the checkbox to false", async () => { const ai = (await payload.create({ collection: "story-voiceovers", data: { aiGenerated: true }, file: { data: mp3FileData(), mimetype: "audio/mpeg", name: `${RUN}-ch2-ai.mp3`, size: 67, }, overrideAccess: false, user: admin, depth: 0, })) as unknown as StoryVoiceover; voiceoverIds.push(ai.id); expect(ai.aiGenerated).toBe(true); const human = (await payload.findByID({ collection: "story-voiceovers", id: voiceoverId, overrideAccess: false, user, depth: 0, })) as unknown as StoryVoiceover; expect(human.aiGenerated).toBe(false); }); it("roundtrips a beat voiceover as a hydrated upload doc at depth 1", async () => { const beat = (await payload.create({ collection: "story-beats", data: { title: `${RUN} Voiceover beat`, status: "published", body: lexicalBody("Narrated chapter."), voiceover: voiceoverId, }, overrideAccess: false, user: admin, depth: 0, })) as unknown as StoryBeat; beatIds.push(beat.id); expect(beat.voiceover).toBe(voiceoverId); const found = (await payload.findByID({ collection: "story-beats", id: beat.id, depth: 1, overrideAccess: false, user, })) as unknown as StoryBeat; const doc = found.voiceover && typeof found.voiceover !== "number" ? found.voiceover : null; expect(doc).toBeTruthy(); expect(doc?.url).toBeTruthy(); expect(doc?.mimeType).toBe("audio/mpeg"); expect(doc?.aiGenerated).toBe(false); }); it("rejects non-audio file types", async () => { await expect( payload.create({ collection: "story-voiceovers", data: {}, file: { data: textFileData("plain text, definitely not audio"), mimetype: "text/plain", name: `${RUN}-note.txt`, size: 34, }, overrideAccess: false, user: admin, depth: 0, }), ).rejects.toThrow(); }); }); }); describe("sortBeats (chapter ordering)", () => { const baseBeat = { status: "published" as const, body: {} as StoryBeat["body"], }; function beat(overrides: Partial & { id: number }): StoryBeat { return { ...baseBeat, ...overrides } as unknown as StoryBeat; } it("defaults to real creation date ascending, ignoring in-story timestamps", () => { // Fictional in-story dates deliberately out of release order. const prologue = beat({ id: 1, title: "Little Green Light", createdAt: "2026-09-20T10:00:00Z", occurredAt: new Date("2156-03-18T08:00:00Z").toISOString(), }); const followUp = beat({ id: 2, title: "What Makes Us Human", createdAt: "2026-09-24T10:00:00Z", occurredAt: new Date("2034-06-12T08:00:00Z").toISOString(), }); const sorted = sortBeats([followUp, prologue]); expect(sorted.map((beat) => beat.id)).toEqual([1, 2]); }); it("puts explicitly ordered beats first, sorted by their order value", () => { const old = beat({ id: 1, createdAt: "2026-01-01T00:00:00Z" }); const newer = beat({ id: 2, createdAt: "2026-02-01T00:00:00Z", order: 1 }); const pinnedLast = beat({ id: 3, createdAt: "2026-03-01T00:00:00Z", order: 2 }); // order = 0 is a valid curation value and must still lead the list. const pinnedFirst = beat({ id: 4, createdAt: "2026-04-01T00:00:00Z", order: 0 }); const sorted = sortBeats([old, pinnedFirst, newer, pinnedLast]); expect(sorted.map((beat) => beat.id)).toEqual([4, 2, 3, 1]); }); it("does not mutate the input array", () => { const a = beat({ id: 1, createdAt: "2026-01-01T00:00:00Z" }); const b = beat({ id: 2, createdAt: "2026-02-01T00:00:00Z" }); const input = [b, a]; sortBeats(input); expect(input.map((beat) => beat.id)).toEqual([2, 1]); }); }); describe("chronicleDisplayOrder (page display)", () => { const baseBeat = { status: "published" as const, body: {} as StoryBeat["body"], }; function beat(overrides: Partial & { id: number }): StoryBeat { return { ...baseBeat, ...overrides } as unknown as StoryBeat; } it("shows the newest chapter first by default, with chapter numbers still ascending", () => { const ch1 = beat({ id: 1, createdAt: "2026-09-20T10:00:00Z" }); const ch2 = beat({ id: 2, createdAt: "2026-09-21T10:00:00Z" }); const ch3 = beat({ id: 3, createdAt: "2026-09-22T10:00:00Z" }); const { beats, chapterById } = chronicleDisplayOrder([ch1, ch2, ch3]); expect(beats.map((beat) => beat.id)).toEqual([3, 2, 1]); expect(chapterById.get(1)).toBe(1); expect(chapterById.get(2)).toBe(2); expect(chapterById.get(3)).toBe(3); }); it("switches to book mode when any beat is hand-ordered", () => { const a = beat({ id: 1, createdAt: "2026-09-20T10:00:00Z" }); const b = beat({ id: 2, createdAt: "2026-09-21T10:00:00Z" }); const c = beat({ id: 3, createdAt: "2026-09-22T10:00:00Z", order: 1 }); const { beats, chapterById } = chronicleDisplayOrder([a, b, c]); expect(beats.map((beat) => beat.id)).toEqual([3, 1, 2]); expect(chapterById.get(3)).toBe(1); }); }); describe("bookLayout (Book view paging)", () => { it("counts one page when content fits the viewport", () => { // One 2-up page: scrollWidth = column width = (1200 - 32) / 2 = 584. expect(bookPageCount(584, 1200, 2)).toBe(1); // scrollWidth == viewport width means both visible pages are exactly full: 2 pages. expect(bookPageCount(1200, 1200, 2)).toBe(2); }); it("counts pages from the measured multicol scrollWidth", () => { // 3 pages at 2-up: scrollWidth = 3 * pitch - gap = 3 * 616 - 32 = 1816. expect(bookPageCount(1816, 1200, 2)).toBe(3); // A lone right page (2.5 pages of content) still counts as its own page. expect(bookPageCount(1816 + 300, 1200, 2)).toBe(4); }); it("pays the gap once per page in single-column mode", () => { // 2 pages at 1-up, viewport 700: scrollWidth = 2 * 732 - 32 = 1432. expect(bookPageCount(1432, 700, 1)).toBe(2); }); it("computes the flip offset as pageIndex * pitch", () => { expect(bookColumnOffset(0, 1200, 2)).toBe(0); expect(bookColumnOffset(2, 1200, 2)).toBe(1232); expect(bookColumnOffset(1, 700, 1)).toBe(732); }); it("decides swipe intent from direction and travel", () => { // Swipe left (negative dx) flips forward once past the threshold. expect(bookSwipeDirection(-100, 0, 400)).toBe(1); // Swipe right flips back. expect(bookSwipeDirection(100, 0, 400)).toBe(-1); // Threshold is max(48, 18% of the page width): 72px at 400 wide. expect(bookSwipeDirection(-72, 0, 400)).toBe(1); expect(bookSwipeDirection(-30, 0, 400)).toBe(0); // Vertical or diagonal drags never flip pages. expect(bookSwipeDirection(0, 100, 400)).toBe(0); expect(bookSwipeDirection(60, 80, 400)).toBe(0); }); });