1
0
Fork 0
polaris-task-force/tests/int/story-beats.int.spec.ts
Z8MB1E d00a5980a5 feat(story): add voiceover support for story beats
- Introduced voiceover relationship to StoryBeats with AI-generated flag.
- Added BeatVoiceover component for playing audio narrations with a minimal interface.
- Updated StoryPage, StoryViewer, and StoryTableOfContents to display and handle voiceovers.
- Created Voiceovers collection for uploading and managing audio narrations for beats.
- Included migration script to support voiceover functionality in the database.
2026-09-24 17:35:17 -04:00

471 lines
14 KiB
TypeScript

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";
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();
});
});
});