import { randomInt, randomUUID } from "node:crypto"; import { eq, inArray } from "drizzle-orm"; import sharp from "sharp"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { after } from "next/server"; vi.mock("server-only", () => ({})); vi.mock("@/lib/notifications/events", () => ({ notifyNotificationChange: vi.fn() })); vi.mock("next/server", () => ({ after: vi.fn() })); const pushTransport = vi.hoisted(() => ({ sendNotification: vi.fn(), setVapidDetails: vi.fn() })); vi.mock("web-push", () => ({ default: pushTransport })); const notify = vi.hoisted(() => vi.fn()); vi.mock("@/lib/comments/events", () => ({ notifyCommentChange: notify })); const storage = vi.hoisted(() => ({ write: vi.fn(), delete: vi.fn() })); const rateLimit = vi.hoisted(() => vi.fn()); vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: rateLimit })); const session = vi.hoisted(() => ({ get: vi.fn() })); vi.mock("@/lib/media/storage", () => ({ getMediaStorage: async () => storage, publicMediaUrl: (key: string) => `https://storage.example.test/${key}` })); vi.mock("@/lib/auth/server", () => ({ getCustomerSession: session.get })); const { getDb, closeDb } = await import("@/db"); const schema = await import("@/db/schema"); const { publishComment } = await import("@/lib/comments/publish"); const { authorizeComment, commentHistory, commentImage, getCommentViewer, listComments, mutateComment } = await import("@/lib/comments/repository"); const commentsRoute = await import("@/app/api/comments/route"); const itemRoute = await import("@/app/api/comments/[id]/route"); const actionRoute = await import("@/app/api/comments/[id]/[action]/route"); const inboxRoute = await import("@/app/api/admin/comments/route"); const { sendCommentPush } = await import("@/lib/comments/push"); const { notifyNotificationChange } = await import("@/lib/notifications/events"); const { createNotifications, listNotifications, readNotifications } = await import("@/lib/notifications/repository"); const { sendCommissionMessagePush } = await import("@/lib/commission/push"); const { unreadCommentCounts, markCommentsRead } = await import("@/lib/comments/unread"); const subscriptionRoute = await import("@/app/api/comments/push-subscription/route"); const imageRoute = await import("@/app/api/comments/images/[id]/route"); const databaseUrl = process.env.DATABASE_INTEGRATION_URL; const describeDatabase = databaseUrl ? describe : describe.skip; describeDatabase("guide comments against PostgreSQL", { timeout: 30000 }, () => { const suffix = randomUUID(); const author = { id: `comment-user-${suffix}`, admin: false }; const other = { id: `comment-other-${suffix}`, admin: false }; const admin = { id: `comment-admin-${suffix}`, admin: true }; const guideId = randomUUID(), otherGuideId = randomUUID(), privateGuideId = randomUUID(), mediaId = randomUUID(); const scheduleId = randomInt(80_000_000, 81_000_000); const target = `guide:${guideId}`; const originalUrl = process.env.DATABASE_URL; const originalOrigin = process.env.BETTER_AUTH_URL; const pushEnvironment = Object.fromEntries(["WEB_PUSH_PUBLIC_KEY", "WEB_PUSH_PRIVATE_KEY", "WEB_PUSH_SUBJECT"].map((key) => [key, process.env[key]])); const subscription = { endpoint: `https://fcm.googleapis.com/fcm/send/${suffix}`, keys: { p256dh: "a".repeat(87), auth: "b".repeat(22) } }; const saveSubscription = (userId: string, endpoint = subscription.endpoint) => getDb().insert(schema.commentPushSubscriptions).values({ userId, endpoint, ...subscription.keys }); let png: Buffer; function request(text: string, options: { reply?: string; version?: number; keep?: string[]; images?: Uint8Array[] } = {}, url = "https://guide.example.test/api/comments") { const body = new FormData(); body.set("text", text); if (options.reply) body.set("replyToId", options.reply); if (options.version) body.set("version", String(options.version)); for (const id of options.keep ?? []) body.append("keepImageId", id); for (const bytes of options.images ?? []) body.append("image", new File([bytes as Uint8Array], "image.png", { type: "image/png" })); return new Request(url, { method: "POST", headers: { Origin: "https://guide.example.test" }, body }); } const post = (text = "Original", options: Parameters[1] = {}) => publishComment(request(text, options), author, { target }); const page = () => listComments({ target, viewer: author }); beforeAll(async () => { process.env.WEB_PUSH_PUBLIC_KEY = "test-public-key"; process.env.WEB_PUSH_PRIVATE_KEY = "test-private-key"; process.env.WEB_PUSH_SUBJECT = "https://guide.example.test"; process.env.DATABASE_URL = databaseUrl; process.env.BETTER_AUTH_URL = "https://guide.example.test"; png = await sharp({ create: { width: 2, height: 2, channels: 3, background: "red" } }).png().toBuffer(); const db = getDb(); await db.insert(schema.users).values([author, other, admin].map((u) => ({ id: u.id, name: u.admin ? "Admin" : "Reader", email: `${u.id}@example.test`, emailVerified: true, role: u.admin ? "admin" : "user" }))); await db.insert(schema.catalogCharacters).values([guideId, otherGuideId, privateGuideId].map((id) => ({ key: `comment-${id}`, name: "Test", imageKey: "test.png" }))); await db.insert(schema.media).values({ id: mediaId, objectKey: `test/comments/${suffix}`, fileName: "test.png", mimeType: "image/png", byteSize: 1, status: "ready" }); await db.insert(schema.guides).values([guideId, otherGuideId, privateGuideId].map((id) => ({ id, characterKey: `comment-${id}`, slug: `comment-test-${id}`, name: "Test Guide", overview: "", coverMediaId: mediaId, isPublic: id !== privateGuideId }))); await db.insert(schema.stygianSchedules).values([scheduleId, scheduleId + 1].map((id) => ({ scheduleId: id, challengeName: "Test", scheduleStartTime: new Date(), scheduleEndTime: new Date() }))); }); beforeEach(async () => { notify.mockReset().mockResolvedValue(undefined); pushTransport.sendNotification.mockReset().mockResolvedValue(undefined); pushTransport.setVapidDetails.mockReset(); await getDb().delete(schema.commentPushSubscriptions).where(inArray(schema.commentPushSubscriptions.userId, [author.id, other.id, admin.id])); await getDb().delete(schema.notifications).where(inArray(schema.notifications.userId, [author.id, other.id, admin.id])); await getDb().delete(schema.commentReadState).where(inArray(schema.commentReadState.userId, [author.id, other.id, admin.id])); await getDb().delete(schema.commentThreads).where(inArray(schema.commentThreads.guideId, [guideId, otherGuideId, privateGuideId])); await getDb().delete(schema.commentThreads).where(inArray(schema.commentThreads.scheduleId, [scheduleId, scheduleId + 1])); storage.write.mockReset().mockResolvedValue(undefined); storage.delete.mockReset().mockResolvedValue(undefined); session.get.mockResolvedValue(null); rateLimit.mockReset().mockResolvedValue(undefined); vi.mocked(after).mockClear(); }); afterAll(async () => { const db = getDb(); await db.delete(schema.guides).where(inArray(schema.guides.id, [guideId, otherGuideId, privateGuideId])); await db.delete(schema.stygianSchedules).where(inArray(schema.stygianSchedules.scheduleId, [scheduleId, scheduleId + 1])); await db.delete(schema.media).where(eq(schema.media.id, mediaId)); await db.delete(schema.catalogCharacters).where(inArray(schema.catalogCharacters.key, [guideId, otherGuideId, privateGuideId].map((id) => `comment-${id}`))); await db.delete(schema.users).where(inArray(schema.users.id, [author.id, other.id, admin.id])); await db.delete(schema.auditLog).where(eq(schema.auditLog.authorId, admin.id)); await closeDb(); for (const [key, value] of Object.entries(pushEnvironment)) { if (value === undefined) delete process.env[key]; else process.env[key] = value; } if (originalUrl === undefined) delete process.env.DATABASE_URL; else process.env.DATABASE_URL = originalUrl; if (originalOrigin === undefined) delete process.env.BETTER_AUTH_URL; else process.env.BETTER_AUTH_URL = originalOrigin; }); it("notifies readers only after successful commits and normalizes guide topics", async () => { const root = await publishComment(request("Live comment"), author, { target: target.toUpperCase().replace("GUIDE:", "guide:") }); expect(notify).toHaveBeenLastCalledWith(target); expect((await page()).items[0].text).toBe("Live comment"); notify.mockClear(); await expect(publishComment(request("Stale edit", { version: 2 }), author, { id: root.id })).rejects.toMatchObject({ status: 409 }); expect(notify).not.toHaveBeenCalled(); await mutateComment(root.id, other, "reaction", 1); expect(notify).toHaveBeenLastCalledWith(target); expect((await page()).items[0].likes).toBe(1); }); it("allows public reading while denying private and trashed guide access", async () => { await post(); expect((await listComments({ target, viewer: null })).items).toHaveLength(1); await expect(listComments({ target: `guide:${privateGuideId}`, viewer: author })).rejects.toMatchObject({ status: 404 }); await getDb().update(schema.guides).set({ trashedAt: new Date() }).where(eq(schema.guides.id, guideId)); await expect(page()).rejects.toMatchObject({ status: 404 }); await expect(publishComment(request("No"), admin, { target })).rejects.toMatchObject({ status: 409 }); await getDb().update(schema.guides).set({ trashedAt: null }).where(eq(schema.guides.id, guideId)); }); it("records the direct recipient while flattening reply-to-reply threads", async () => { const root = await post(); const reply = await post("First reply", { reply: root.id }); const nested = await post("Reply to reply", { reply: reply.id }); const result = await listComments({ viewer: author, rootId: root.id }); expect(result.items.map((c) => c.id)).toEqual([reply.id, nested.id]); expect(result.items[1]).toMatchObject({ rootId: root.id, replyToId: reply.id, replyToName: "Reader" }); expect((await page()).items[0].replyCount).toBe(2); await expect(publishComment(request("Bad reply", { reply: reply.id }), author, { target: `guide:${otherGuideId}` })).rejects.toMatchObject({ status: 400 }); }); it("groups the admin inbox and keeps nested admin replies attached to their recipient", async () => { const root = await post(); const reply = await post("Reader reply", { reply: root.id }); const nested = await publishComment(request("Admin reply", { reply: reply.id }), admin, { target }); const second = await post("Second conversation"); const grouped = await listComments({ viewer: admin, inbox: true, grouped: true, target }); expect(grouped.items.map((item) => item.id)).toEqual([second.id, root.id]); expect(grouped.items[1].replyCount).toBe(2); const replies = await listComments({ viewer: admin, rootId: root.id }); expect(replies.items[1]).toMatchObject({ id: nested.id, rootId: root.id, replyToId: reply.id, authorAdmin: true }); expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, unanswered: true })).items.map((item) => item.id)).toEqual([second.id]); expect((await listComments({ viewer: admin, inbox: true, target })).items).toHaveLength(4); expect((await listComments({ viewer: admin, inbox: true, grouped: true, target: `guide:${otherGuideId}` })).items).toHaveLength(0); session.get.mockResolvedValue({ user: { id: admin.id } }); const response = await inboxRoute.GET(new Request(`https://guide.example.test/api/admin/comments?grouped=true&target=${target}`)); expect(response.status).toBe(200); expect(response.headers.get("Cache-Control")).toBe("private, no-store"); expect((await response.json()).items.map((item: { id: string }) => item.id)).toEqual([second.id, root.id]); }); it("finds grouped conversations by reply visibility without filtering their context", async () => { const root = await post(); const reply = await post("Reply", { reply: root.id }); const nested = await post("Nested reply", { reply: reply.id }); await mutateComment(nested.id, admin, "moderation", true); const hidden = await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "hidden" }); expect(hidden.items.map((item) => item.id)).toEqual([root.id]); expect(hidden.items[0].hidden).toBe(false); expect((await listComments({ viewer: admin, rootId: root.id })).items).toHaveLength(2); expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "visible" })).items.map((item) => item.id)).toEqual([root.id]); await mutateComment(root.id, admin, "moderation", true); expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "visible" })).items).toHaveLength(0); expect((await listComments({ viewer: admin, inbox: true, grouped: true, target, status: "hidden" })).items).toHaveLength(1); }); it("keeps Stygian schedule threads independent", async () => { await publishComment(request("Schedule A"), author, { target: `stygian:${scheduleId}` }); expect((await listComments({ viewer: author, target: `stygian:${scheduleId + 1}` })).items).toHaveLength(0); expect((await listComments({ viewer: admin, inbox: true })).items.some((c) => c.target === `stygian:${scheduleId}`)).toBe(true); }); it("switches and removes reactions without exposing dislike totals", async () => { const root = await post(); await mutateComment(root.id, author, "reaction", 1); expect((await page()).items[0]).toMatchObject({ likes: 1, reaction: 1 }); await mutateComment(root.id, author, "reaction", -1); expect((await page()).items[0]).toMatchObject({ likes: 0, reaction: -1 }); expect((await page()).items[0]).not.toHaveProperty("dislikes"); await mutateComment(root.id, author, "reaction", 0); expect((await page()).items[0].reaction).toBe(0); }); it("restricts hearts and moderation to admins and records moderation", async () => { const root = await post(); await expect(mutateComment(root.id, author, "heart", true)).rejects.toMatchObject({ status: 403 }); await expect(mutateComment(root.id, author, "moderation", true)).rejects.toMatchObject({ status: 403 }); await mutateComment(root.id, admin, "heart", true); await mutateComment(root.id, admin, "reaction", 1); expect((await page()).items[0]).toMatchObject({ hearted: true, likes: 1 }); await mutateComment(root.id, admin, "moderation", true); const audit = await getDb().select().from(schema.auditLog).where(eq(schema.auditLog.authorId, admin.id)); expect(audit.map((event) => event.details?.target)).toContainEqual({ type: "comment", id: root.id }); }); it("preserves public text and images in immutable edit history", async () => { const root = await post("Version one", { images: [png] }); const oldImage = (await page()).items[0].images[0]; await publishComment(request("Version two", { version: 1, images: [png] }), author, { id: root.id }); const history = await commentHistory(root.id, null); expect(history.items.map((revision) => revision.text)).toEqual(["Version two", "Version one"]); expect(history.items[1].images[0].id).toBe(oldImage.id); expect((await commentImage(oldImage.id, null)).commentId).toBe(root.id); expect(storage.delete).not.toHaveBeenCalled(); expect(storage.write).toHaveBeenCalledWith(expect.stringMatching(/^comments\//), expect.any(Uint8Array), expect.objectContaining({ acl: "public-read" })); }); it("enforces ownership, retained-image ownership, and edit conflicts", async () => { const root = await post("First", { images: [png] }); const image = (await page()).items[0].images[0]; await expect(publishComment(request("Stolen", { version: 1 }), other, { id: root.id })).rejects.toMatchObject({ status: 403 }); await publishComment(request("Second", { version: 1, keep: [image.id] }), author, { id: root.id }); await expect(publishComment(request("Stale", { version: 1 }), author, { id: root.id })).rejects.toMatchObject({ status: 409 }); const otherRoot = await post("Other"); await expect(publishComment(request("Foreign image", { version: 1, keep: [image.id] }), author, { id: otherRoot.id })).rejects.toMatchObject({ status: 400 }); await expect(mutateComment(root.id, other, "delete")).rejects.toMatchObject({ status: 403 }); }); it("edits images through PATCH while preserving retained images and revision history", async () => { const root = await post("Original", { images: [png] }); const originalImage = (await page()).items[0].images[0]; session.get.mockResolvedValue({ user: { id: author.id } }); const context = { params: Promise.resolve({ id: root.id }) }; const edit = request("Added image", { version: 1, keep: [originalImage.id], images: [png] }); const response = await itemRoute.PATCH(new Request(edit, { method: "PATCH" }), context); expect(await response.json()).toMatchObject({ id: root.id }); expect(response.status).toBe(200); const updated = (await page()).items[0]; expect(updated).toMatchObject({ version: 2, text: "Added image" }); expect(updated.images).toHaveLength(2); expect(updated.images[0]).toEqual(originalImage); const replacement = request("", { version: 2, images: [png] }); expect((await itemRoute.PATCH(new Request(replacement, { method: "PATCH" }), context)).status).toBe(200); expect((await page()).items[0].images).toHaveLength(1); const history = await commentHistory(root.id, author); expect(history.items.map((item) => item.images.length)).toEqual([1, 2, 1]); }); it("retries temporary image-storage failures without duplicating the edit", async () => { const root = await post("Original"); storage.write.mockRejectedValueOnce(Object.assign(new Error("Temporary storage failure"), { code: "ECONNRESET" })); await publishComment(request("Edited", { version: 1, images: [png] }), author, { id: root.id }); expect(storage.write).toHaveBeenCalledTimes(2); expect(storage.write.mock.calls[0][0]).toBe(storage.write.mock.calls[1][0]); expect((await page()).items[0]).toMatchObject({ text: "Edited", version: 2 }); expect((await commentHistory(root.id, author)).items).toHaveLength(2); expect(storage.delete).not.toHaveBeenCalled(); }); it("keeps the original revision and cleans up images when storage retries fail", async () => { const root = await post("Original"); storage.write.mockRejectedValue(Object.assign(new Error("Storage unavailable"), { statusCode: 503 })); session.get.mockResolvedValue({ user: { id: author.id } }); const response = await itemRoute.PATCH(request("Failed edit", { version: 1, images: [png] }), { params: Promise.resolve({ id: root.id }) }); expect(response.status).toBe(503); expect(await response.json()).toEqual({ error: "comment-image-upload-failed" }); expect(storage.write).toHaveBeenCalledTimes(3); expect(storage.delete).toHaveBeenCalledTimes(1); expect((await page()).items[0]).toMatchObject({ text: "Original", version: 1 }); expect(after).not.toHaveBeenCalled(); }); it("blocks hidden-root replies, history, and attachments and restores access", async () => { const root = await post("Root", { images: [png] }); const reply = await post("Reply", { reply: root.id, images: [png] }); const image = (await page()).items[0].images[0].id; await mutateComment(root.id, admin, "moderation", true); expect((await page()).items).toHaveLength(0); await expect(commentHistory(reply.id, author)).rejects.toMatchObject({ status: 404 }); await expect(commentImage(image, null)).rejects.toMatchObject({ status: 404 }); await expect(post("New reply", { reply: root.id })).rejects.toMatchObject({ status: 404 }); const inbox = await listComments({ viewer: admin, inbox: true, target, status: "hidden" }); expect(inbox.items).toHaveLength(2); await mutateComment(root.id, admin, "moderation", false); expect((await page()).items).toHaveLength(1); expect((await commentHistory(reply.id, null)).items).toHaveLength(1); }); it("preserves hidden-reply placeholders without exposing text, images, or history", async () => { const root = await post(); const reply = await post("Hidden reply", { reply: root.id, images: [png] }); const nested = await post("Visible follow-up", { reply: reply.id }); await mutateComment(reply.id, admin, "moderation", true); const replies = await listComments({ viewer: null, rootId: root.id }); expect(replies.items[0]).toMatchObject({ id: reply.id, hidden: true, text: "", images: [], canInteract: false }); expect(replies.items[1]).toMatchObject({ id: nested.id, text: "Visible follow-up" }); await expect(commentHistory(reply.id, null)).rejects.toMatchObject({ status: 404 }); }); it("permanently deletes a root and every reply with all associated data and S3 images", async () => { const root = await post("Root", { images: [png] }); const reply = await post("Reply", { reply: root.id, images: [png] }); const nested = await publishComment(request("Nested", { reply: reply.id, images: [png] }), other, { target }); await mutateComment(nested.id, other, "reaction", 1); const keys = storage.write.mock.calls.map((call) => call[0]); const images = await getDb().select().from(schema.commentAttachments); await mutateComment(root.id, author, "delete"); expect((await page()).items).toEqual([]); for (const id of [root.id, reply.id, nested.id]) { expect(await getDb().select().from(schema.comments).where(eq(schema.comments.id, id))).toEqual([]); expect(await getDb().select().from(schema.commentRevisions).where(eq(schema.commentRevisions.commentId, id))).toEqual([]); expect(await getDb().select().from(schema.commentAttachments).where(eq(schema.commentAttachments.commentId, id))).toEqual([]); expect(await getDb().select().from(schema.commentReactions).where(eq(schema.commentReactions.commentId, id))).toEqual([]); await expect(commentHistory(id, admin)).rejects.toMatchObject({ status: 404 }); } for (const image of images.filter((image) => [root.id, reply.id, nested.id].includes(image.commentId))) { await expect(commentImage(image.id, author)).rejects.toMatchObject({ status: 404 }); expect(await getDb().select().from(schema.commentRevisionAttachments).where(eq(schema.commentRevisionAttachments.attachmentId, image.id))).toEqual([]); } for (const key of keys) expect(storage.delete).toHaveBeenCalledWith(key); await expect(listComments({ viewer: null, rootId: root.id })).rejects.toMatchObject({ status: 404 }); }); it("deletes only the selected reply subtree and preserves its root and sibling", async () => { const root = await post("Root"); const reply = await post("Delete reply", { reply: root.id }); const nested = await post("Delete nested", { reply: reply.id }); const sibling = await post("Keep sibling", { reply: root.id }); await mutateComment(reply.id, author, "delete"); expect((await page()).items[0]).toMatchObject({ id: root.id, replyCount: 1 }); expect((await listComments({ rootId: root.id, viewer: null })).items.map((item) => item.id)).toEqual([sibling.id]); await expect(authorizeComment(nested.id, author)).rejects.toMatchObject({ status: 404 }); }); it("paginates roots, replies, and top comments with stable tie ordering", async () => { const firstRoot = await post("Root 0"); const [thread] = await getDb().select().from(schema.commentThreads).where(eq(schema.commentThreads.guideId, guideId)); const roots = [firstRoot, ...Array.from({ length: 22 }, () => ({ id: randomUUID() }))]; await getDb().transaction(async (tx) => { await tx.insert(schema.comments).values(roots.slice(1).map((root) => ({ id: root.id, threadId: thread.id, authorId: author.id }))); await tx.insert(schema.commentRevisions).values(roots.slice(1).map((root, i) => ({ commentId: root.id, version: 1, text: `Root ${i + 1}` }))); }); const first = await page(); const second = await listComments({ target, viewer: author, cursor: first.nextCursor }); expect(first.items).toHaveLength(20); expect(second.items).toHaveLength(3); expect(new Set([...first.items, ...second.items].map((item) => item.id)).size).toBe(23); await mutateComment(roots[0].id, other, "reaction", 1); const top = await listComments({ target, viewer: author, sort: "top" }); expect(top.items[0].id).toBe(roots[0].id); const topNext = await listComments({ target, viewer: author, sort: "top", cursor: top.nextCursor }); expect(new Set([...top.items, ...topNext.items].map((item) => item.id)).size).toBe(23); const replyIds = Array.from({ length: 22 }, () => randomUUID()); await getDb().transaction(async (tx) => { await tx.insert(schema.comments).values(replyIds.map((id) => ({ id, threadId: thread.id, authorId: author.id, rootId: roots[0].id, replyToId: roots[0].id }))); await tx.insert(schema.commentRevisions).values(replyIds.map((id, i) => ({ commentId: id, version: 1, text: `Reply ${i}` }))); }); const replies = await listComments({ viewer: author, rootId: roots[0].id }); expect(replies.items).toHaveLength(20); expect((await listComments({ viewer: author, rootId: roots[0].id, cursor: replies.nextCursor })).items).toHaveLength(2); const grouped = await listComments({ viewer: admin, inbox: true, grouped: true, target }); const groupedNext = await listComments({ viewer: admin, inbox: true, grouped: true, target, cursor: grouped.nextCursor }); expect(grouped.items).toHaveLength(20); expect(groupedNext.items).toHaveLength(3); expect(new Set([...grouped.items, ...groupedNext.items].map((item) => item.id))).toEqual(new Set(roots.map((item) => item.id))); }, 30000); it("filters unanswered roots and treats an admin reply as an answer", async () => { const root = await post(); const unanswered = await listComments({ viewer: admin, inbox: true, target, unanswered: true }); expect(unanswered.items).toHaveLength(1); await publishComment(request("Admin answer", { reply: root.id }), admin, { target }); expect((await listComments({ viewer: admin, inbox: true, target, unanswered: true })).items).toHaveLength(0); }); it("rejects fake image contents and cleans up a partially uploaded batch", async () => { await expect(post("Fake", { images: [new Uint8Array([0, 1, 2])] })).rejects.toMatchObject({ status: 422 }); expect(storage.write).not.toHaveBeenCalled(); storage.write.mockRejectedValueOnce(new Error("storage unavailable")); await expect(post("Failed", { images: [png] })).rejects.toMatchObject({ status: 503, message: "comment-image-upload-failed" }); expect(storage.delete).toHaveBeenCalledWith(storage.write.mock.calls[0][0]); expect((await page()).items).toHaveLength(0); }); it("allows only a single winner for concurrent edits", async () => { const root = await post(); const result = await Promise.allSettled([publishComment(request("Edit A", { version: 1 }), author, { id: root.id }), publishComment(request("Edit B", { version: 1 }), author, { id: root.id })]); expect(result.filter((entry) => entry.status === "fulfilled")).toHaveLength(1); expect((await commentHistory(root.id, author)).items).toHaveLength(2); }); it("uses current account permissions and rejects banned accounts", async () => { session.get.mockResolvedValue({ user: { id: author.id }, session: { id: "session" } }); expect(await getCommentViewer()).toMatchObject({ ...author, name: "Reader", image: null }); await getDb().update(schema.users).set({ banned: true }).where(eq(schema.users.id, author.id)); expect(await getCommentViewer()).toBeNull(); await getDb().update(schema.users).set({ banned: false }).where(eq(schema.users.id, author.id)); await expect(listComments({ viewer: author, inbox: true })).rejects.toMatchObject({ status: 403 }); await expect(authorizeComment(randomUUID(), author)).rejects.toMatchObject({ status: 404 }); }); it("requires sign-in and same origin at the HTTP boundary", async () => { const url = `https://guide.example.test/api/comments?target=${target}`; expect((await commentsRoute.POST(request("Guest", {}, url))).status).toBe(401); session.get.mockResolvedValue({ user: { id: author.id }, session: { id: "session" } }); const foreign = request("Foreign", {}, url); foreign.headers.set("origin", "https://evil.example"); expect((await commentsRoute.POST(foreign)).status).toBe(403); const created = await commentsRoute.POST(request("Signed in", {}, url)); expect(created.status).toBe(201); expect(rateLimit).toHaveBeenCalledWith("comment-publish", author.id, 20); session.get.mockResolvedValue(null); const read = await commentsRoute.GET(new Request(url)); expect(read.status).toBe(200); expect(read.headers.get("cache-control")).toContain("no-store"); expect((await read.json()).items).toHaveLength(1); }); it("enforces author and admin rights through mutation endpoints", async () => { const root = await post(); session.get.mockResolvedValue({ user: { id: other.id }, session: { id: "session" } }); const context = { params: Promise.resolve({ id: root.id }) }; expect((await itemRoute.PATCH(request("Not mine", { version: 1 }), context)).status).toBe(403); expect((await itemRoute.DELETE(new Request(`https://guide.example.test/api/comments/${root.id}`, { method: "DELETE", headers: { Origin: "https://guide.example.test" } }), context)).status).toBe(403); const actionContext = { params: Promise.resolve({ id: root.id, action: "heart" }) }; expect((await actionRoute.PUT(new Request("https://guide.example.test/api/comments/heart", { method: "PUT", headers: { Origin: "https://guide.example.test", "Content-Type": "application/json" }, body: JSON.stringify({ hearted: true }), }), actionContext)).status).toBe(403); expect((await inboxRoute.GET(new Request("https://guide.example.test/api/admin/comments"))).status).toBe(403); }); it("rejects oversized streamed bodies and malformed actions before saving", async () => { session.get.mockResolvedValue({ user: { id: author.id }, session: { id: "session" } }); const url = `https://guide.example.test/api/comments?target=${target}`; const oversized = request("Too large", {}, url); oversized.headers.set("content-length", String(60 * 1024 * 1024)); expect((await commentsRoute.POST(oversized)).status).toBe(413); const root = await post(); const action = { params: Promise.resolve({ id: root.id, action: "reaction" }) }; const invalid = new Request("https://guide.example.test/api/comments/reaction", { method: "PUT", headers: { Origin: "https://guide.example.test", "Content-Type": "application/json" }, body: JSON.stringify({ value: 2 }) }); expect((await actionRoute.PUT(invalid, action)).status).toBe(400); const missing = await itemRoute.GET(new Request(url), { params: Promise.resolve({ id: "invalid" }) }); expect(missing.status).toBe(404); }); it("returns direct S3 CDN URLs after authorization and redirects legacy image links", async () => { const root = await post("Image", { images: [png] }); const image = (await page()).items[0].images[0]; expect(image.url).toMatch(/^https:\/\/storage\.example\.test\/comments\//); expect(image.url).not.toContain("/api/comments/images/"); const url = `https://guide.example.test/api/comments/images/${image.id}`; const response = await imageRoute.GET(new Request(url), { params: Promise.resolve({ id: image.id }) }); expect(response.status).toBe(307); expect(response.headers.get("location")).toBe(image.url); await mutateComment(root.id, admin, "moderation", true); expect((await listComments({ target, viewer: null })).items).toEqual([]); }); it("denies attachment HTTP access once the owning comment is hidden", async () => { const root = await post("Image", { images: [png] }); const image = (await page()).items[0].images[0].id; await mutateComment(root.id, admin, "moderation", true); const response = await imageRoute.GET(new Request(`https://guide.example.test/api/comments/images/${image}`), { params: Promise.resolve({ id: image }) }); expect(response.status).toBe(404); expect(response.headers.get("cache-control")).toContain("no-store"); }); it("stores comment push subscriptions for the signed-in account and protects unsubscribe ownership", async () => { expect((await subscriptionRoute.GET()).status).toBe(401); session.get.mockResolvedValue({ user: { id: author.id } }); expect(await (await subscriptionRoute.GET()).json()).toEqual({ publicKey: "test-public-key" }); const request = (method: string, body: unknown, origin = "https://guide.example.test") => new Request("https://guide.example.test/api/comments/push-subscription", { method, headers: { Origin: origin, "Content-Type": "application/json" }, body: JSON.stringify(body) }); expect((await subscriptionRoute.POST(request("POST", subscription))).status).toBe(204); expect((await subscriptionRoute.POST(request("POST", { ...subscription, endpoint: "https://127.0.0.1/push" }))).status).toBe(400); expect((await subscriptionRoute.POST(request("POST", subscription, "https://evil.example"))).status).toBe(403); session.get.mockResolvedValue({ user: { id: other.id } }); expect((await subscriptionRoute.DELETE(request("DELETE", { endpoint: subscription.endpoint }))).status).toBe(204); expect(await getDb().select().from(schema.commentPushSubscriptions).where(eq(schema.commentPushSubscriptions.endpoint, subscription.endpoint))).toHaveLength(1); session.get.mockResolvedValue({ user: { id: author.id } }); expect((await subscriptionRoute.DELETE(request("DELETE", { endpoint: subscription.endpoint }))).status).toBe(204); expect(await getDb().select().from(schema.commentPushSubscriptions).where(eq(schema.commentPushSubscriptions.endpoint, subscription.endpoint))).toHaveLength(0); }); it("pushes only to the direct reply recipient and links to the exact nested reply", async () => { await saveSubscription(author.id); await saveSubscription(other.id, `${subscription.endpoint}-other`); const root = await post("Root"); const reply = await publishComment(request("Reply", { reply: root.id }), other, { target }); await sendCommentPush(reply.id); expect(pushTransport.sendNotification).toHaveBeenCalledTimes(1); expect(pushTransport.sendNotification.mock.calls[0][0].endpoint).toBe(subscription.endpoint); expect(JSON.parse(pushTransport.sendNotification.mock.calls[0][1])).toMatchObject({ body: "Reply", url: `/comment-test-${guideId}/comment?reply=${reply.id}#comment-${root.id}` }); pushTransport.sendNotification.mockClear(); const nested = await post("Nested", { reply: reply.id }); expect((await listComments({ viewer: admin, rootId: root.id })).items.find((item) => item.id === reply.id)?.hasReplies).toBe(true); expect((await listComments({ viewer: admin, rootId: root.id })).items.find((item) => item.id === nested.id)?.hasReplies).toBe(false); await sendCommentPush(nested.id); expect(pushTransport.sendNotification).toHaveBeenCalledTimes(1); expect(pushTransport.sendNotification.mock.calls[0][0].endpoint).toBe(`${subscription.endpoint}-other`); expect(JSON.parse(pushTransport.sendNotification.mock.calls[0][1]).url).toContain(`reply=${nested.id}#comment-${root.id}`); pushTransport.sendNotification.mockClear(); await sendCommentPush((await post("Self", { reply: root.id })).id); expect(pushTransport.sendNotification).not.toHaveBeenCalled(); }); it("notifies subscribed admins about new comments, checks current roles, and skips the author", async () => { await saveSubscription(admin.id, `${subscription.endpoint}-admin`); await saveSubscription(other.id, `${subscription.endpoint}-reader`); const root = await post("New root"); const adminCalls = () => pushTransport.sendNotification.mock.calls.filter(([recipient]) => recipient.endpoint === `${subscription.endpoint}-admin`); await sendCommentPush(root.id); expect(adminCalls()).toHaveLength(1); expect(JSON.parse(adminCalls()[0][1])).toMatchObject({ body: "New root", icon: "/icon/nav/Comment.webp", url: `/admin/comments?target=${encodeURIComponent(target)}&comment=${root.id}` }); expect(pushTransport.sendNotification.mock.calls.some(([recipient]) => recipient.endpoint === `${subscription.endpoint}-reader`)).toBe(false); pushTransport.sendNotification.mockClear(); await sendCommentPush((await publishComment(request("Admin's own"), admin, { target })).id); expect(adminCalls()).toHaveLength(0); try { for (const state of [{ role: "user" }, { role: "admin", emailVerified: false }, { role: "admin", emailVerified: true, banned: true }]) { await getDb().update(schema.users).set(state).where(eq(schema.users.id, admin.id)); await sendCommentPush(root.id); expect(adminCalls()).toHaveLength(0); } } finally { await getDb().update(schema.users).set({ role: "admin", emailVerified: true, banned: false }).where(eq(schema.users.id, admin.id)); } }, 60000); it("links Stygian admin notifications and suppresses removed comments and expired subscriptions", async () => { const endpoint = `${subscription.endpoint}-admin`; await saveSubscription(admin.id, endpoint); const root = await publishComment(request("New Stygian comment"), author, { target: `stygian:${scheduleId}` }); const adminCalls = () => pushTransport.sendNotification.mock.calls.filter(([recipient]) => recipient.endpoint === endpoint); await sendCommentPush(root.id); expect(JSON.parse(adminCalls()[0][1]).url).toBe(`/admin/comments?target=stygian%3A${scheduleId}&comment=${root.id}`); pushTransport.sendNotification.mockClear(); await mutateComment(root.id, admin, "moderation", true); await sendCommentPush(root.id); expect(adminCalls()).toHaveLength(0); await mutateComment(root.id, admin, "moderation", false); pushTransport.sendNotification.mockImplementation(async (recipient) => { if (recipient.endpoint === endpoint) throw { statusCode: 410 }; }); await sendCommentPush(root.id); expect(await getDb().select().from(schema.commentPushSubscriptions).where(eq(schema.commentPushSubscriptions.endpoint, endpoint))).toHaveLength(0); await saveSubscription(admin.id, endpoint); await mutateComment(root.id, author, "delete"); pushTransport.sendNotification.mockClear(); await sendCommentPush(root.id); expect(adminCalls()).toHaveLength(0); }, 60000); it("does not push hidden or deleted replies and removes expired browser subscriptions", async () => { await saveSubscription(author.id); const root = await post("Root"); const reply = await publishComment(request("Reply", { reply: root.id }), other, { target }); await mutateComment(root.id, admin, "moderation", true); await sendCommentPush(reply.id); expect(pushTransport.sendNotification).not.toHaveBeenCalled(); await mutateComment(root.id, admin, "moderation", false); pushTransport.sendNotification.mockRejectedValueOnce({ statusCode: 410 }); await sendCommentPush(reply.id); expect(await getDb().select().from(schema.commentPushSubscriptions).where(eq(schema.commentPushSubscriptions.userId, author.id))).toHaveLength(0); await mutateComment(root.id, author, "delete"); pushTransport.sendNotification.mockClear(); await sendCommentPush(reply.id); expect(pushTransport.sendNotification).not.toHaveBeenCalled(); }); it("persists comment notifications without push permission and keeps history scoped to its owner", async () => { const root = await post("Bell root"); await sendCommentPush(root.id); const adminUser = { ...admin, role: "admin", emailVerified: true }; const authorUser = { ...author, role: "user", emailVerified: true }; expect((await listNotifications(adminUser)).items[0]).toMatchObject({ commentId: root.id, kind: "comment_new", readAt: null }); expect((await listNotifications(authorUser)).unread).toBe(0); const reply = await publishComment(request("Bell reply", { reply: root.id }), other, { target }); const key = process.env.WEB_PUSH_PUBLIC_KEY; delete process.env.WEB_PUSH_PUBLIC_KEY; try { await sendCommentPush(reply.id); await sendCommentPush(reply.id); } finally { process.env.WEB_PUSH_PUBLIC_KEY = key; } const inbox = await listNotifications(authorUser); expect(inbox.unread).toBe(1); expect(inbox.items).toHaveLength(1); await readNotifications(other.id, inbox.items[0].id); expect((await listNotifications(authorUser)).unread).toBe(1); await readNotifications(author.id, inbox.items[0].id); expect((await listNotifications(authorUser)).unread).toBe(0); expect((await listNotifications({ ...adminUser, role: "user" })).items).toHaveLength(0); await mutateComment(root.id, admin, "moderation", true); expect((await listNotifications(authorUser)).items).toHaveLength(0); await mutateComment(root.id, admin, "moderation", false); vi.mocked(notifyNotificationChange).mockClear(); await mutateComment(root.id, author, "delete"); expect(notifyNotificationChange).toHaveBeenCalledWith(author.id); expect(notifyNotificationChange).toHaveBeenCalledWith(admin.id); expect((await listNotifications(adminUser)).items).toHaveLength(0); }, 60000); it("notifies admins for each saved edit without duplicating the same revision", async () => { const root = await post("Original"); const adminUser = { ...admin, role: "admin", emailVerified: true }; await sendCommentPush(root.id, 1); await readNotifications(admin.id); session.get.mockResolvedValue({ user: { id: author.id } }); const response = await itemRoute.PATCH(request("Edited with image", { version: 1, images: [png] }), { params: Promise.resolve({ id: root.id }) }); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ id: root.id, version: 2 }); expect(after).toHaveBeenCalledTimes(1); const task = vi.mocked(after).mock.calls[0][0]; if (typeof task !== "function") throw new Error("Expected scheduled notification callback"); await task(); await task(); const feed = await listNotifications(adminUser); expect(feed.unread).toBe(1); expect(feed.items[0]).toMatchObject({ kind: "comment_edit", eventKey: `comment:${root.id}:edit:2`, body: "Edited with image", readAt: null }); await publishComment(request("Third revision", { version: 2 }), author, { id: root.id }); await sendCommentPush(root.id, 3); expect((await listNotifications(adminUser)).unread).toBe(2); // A delayed notification must use the revision that triggered it. await sendCommentPush(root.id, 2); expect((await listNotifications(adminUser)).unread).toBe(2); }); it("notifies the direct recipient of edited replies and suppresses hidden edits", async () => { await saveSubscription(author.id); const root = await post("Root"); const reply = await publishComment(request("Reply", { reply: root.id }), other, { target }); await sendCommentPush(reply.id, 1); await readNotifications(author.id); pushTransport.sendNotification.mockClear(); await publishComment(request("Edited reply", { version: 1, images: [png] }), other, { id: reply.id }); await sendCommentPush(reply.id, 2); expect(pushTransport.sendNotification).toHaveBeenCalledTimes(1); expect(JSON.parse(pushTransport.sendNotification.mock.calls[0][1])).toMatchObject({ body: "Edited reply", title: "Reader แก้ไขคำตอบกลับความคิดเห็นของคุณ" }); const feed = await listNotifications({ ...author, role: "user", emailVerified: true }); expect(feed.unread).toBe(1); expect(feed.items[0]).toMatchObject({ kind: "comment_edit", body: "Edited reply" }); expect(feed.items[0].url).toContain(`reply=${reply.id}#comment-${root.id}`); await publishComment(request("Hidden revision", { version: 2 }), other, { id: reply.id }); await mutateComment(root.id, admin, "moderation", true); pushTransport.sendNotification.mockClear(); await sendCommentPush(reply.id, 3); expect(pushTransport.sendNotification).not.toHaveBeenCalled(); expect((await getDb().select().from(schema.notifications).where(eq(schema.notifications.eventKey, `comment:${reply.id}:edit:3`)))).toHaveLength(0); }); it("pages notification history without dropping notifications sharing a timestamp", async () => { const user = { ...author, role: "user", emailVerified: true }; for (let index = 0; index < 25; index++) await createNotifications([{ userId: author.id, url: "/" }], { eventKey: `fixture:${index}`, kind: "future", title: "Future notification", body: "Text" }); await getDb().update(schema.notifications).set({ createdAt: new Date("2026-01-01T00:00:00Z") }).where(eq(schema.notifications.userId, author.id)); const first = await listNotifications(user); const [time, id] = first.nextCursor!.split("|"); const second = await listNotifications(user, { time, id }); expect(first.items).toHaveLength(20); expect(second.items).toHaveLength(5); expect(new Set([...first.items, ...second.items].map(item => item.id)).size).toBe(25); await readNotifications(author.id); expect((await listNotifications(user)).unread).toBe(0); }, 60000); it("records commission notifications for customers and admins without browser subscriptions", async () => { const ticketId = randomUUID(), messageId = randomUUID(), checkoutId = randomUUID(); await getDb().insert(schema.commissionCheckouts).values({ id: checkoutId, userId: author.id, amountBaht: 20, request: { teams: [], weapons: [], constellations: [{ characterKey: "test", levels: [0] }] } }); await getDb().insert(schema.commissionTickets).values({ id: ticketId, checkoutId, userId: author.id, title: "Notification ticket" }); await getDb().insert(schema.commissionMessages).values({ id: messageId, ticketId, authorId: other.id, text: "Commission bell" }); try { await sendCommissionMessagePush({ id: messageId, ticketId, customerId: author.id, authorId: other.id, authorName: "Reader", ticketTitle: "Ticket", text: "Commission bell" }); expect((await listNotifications({ ...author, role: "user", emailVerified: true })).items[0]).toMatchObject({ kind: "commission_message", url: `/commission/tickets/${ticketId}` }); expect((await listNotifications({ ...admin, role: "admin", emailVerified: true })).items[0]).toMatchObject({ kind: "commission_message", url: `/admin/commission/${ticketId}` }); expect((await listNotifications({ ...other, role: "user", emailVerified: true })).items).toHaveLength(0); } finally { await getDb().delete(schema.commissionMessages).where(eq(schema.commissionMessages.id, messageId)); await getDb().delete(schema.commissionTickets).where(eq(schema.commissionTickets.id, ticketId)); await getDb().delete(schema.commissionCheckouts).where(eq(schema.commissionCheckouts.id, checkoutId)); } }, 60000); it("counts unread comments per guide for admins and clears only the selected guide", async () => { await markCommentsRead(admin, "all"); await post("Unread"); await publishComment(request("Own admin comment"), admin, { target }); await publishComment(request("Other guide"), author, { target: `guide:${otherGuideId}` }); expect(await unreadCommentCounts(admin)).toEqual({ total: 2, counts: { [target]: 1, [`guide:${otherGuideId}`]: 1 } }); await expect(unreadCommentCounts(author)).rejects.toMatchObject({ status: 403 }); await expect(markCommentsRead(author, "all")).rejects.toMatchObject({ status: 403 }); await markCommentsRead(admin, target); expect(await unreadCommentCounts(admin)).toEqual({ total: 1, counts: { [`guide:${otherGuideId}`]: 1 } }); await post("New after reading"); expect((await unreadCommentCounts(admin)).counts[target]).toBe(1); await markCommentsRead(admin, "all"); expect(await unreadCommentCounts(admin)).toEqual({ total: 0, counts: {} }); }); });