import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; const state = vi.hoisted(() => ({ select: vi.fn(), rows: [] as unknown[][], notifications: vi.fn(), send: vi.fn(), authorize: vi.fn(), publish: vi.fn(), after: vi.fn(), })); vi.mock("server-only", () => ({})); vi.mock("@/db", () => ({ getDb: () => ({ select: state.select }) })); vi.mock("web-push", () => ({ default: { sendNotification: state.send, setVapidDetails: vi.fn() } })); vi.mock("@/lib/commission/push", () => ({ pushPublicKey: () => "public-key", validPushEndpoint: () => true })); vi.mock("@/lib/notifications/repository", () => ({ createNotifications: state.notifications })); vi.mock("./repository", () => ({ authorizeComment: state.authorize, requireCommentViewer: async () => ({ id: "author", admin: false }), commentId: (id: string) => id, })); vi.mock("./publish", () => ({ publishComment: state.publish })); vi.mock("next/server", () => ({ after: state.after })); vi.mock("@/lib/security/rate-limit", () => ({ limitRequest: async () => {} })); import { sendCommentPush } from "./push"; import { PATCH } from "@/app/api/comments/[id]/route"; const id = "00000000-0000-4000-8000-000000000001"; const subscription = { endpoint: "https://fcm.googleapis.com/fcm/send/test", p256dh: "key", auth: "auth" }; beforeEach(() => { vi.clearAllMocks(); vi.stubEnv("BETTER_AUTH_URL", "https://guide.example.test"); state.rows = []; state.select.mockImplementation(() => { const rows = state.rows.shift() ?? []; const query = { from: () => query, where: () => query, limit: () => Promise.resolve(rows), then: (resolve: (rows: unknown[]) => unknown) => Promise.resolve(rows).then(resolve) }; return query; }); state.authorize.mockResolvedValue({ destination: { target: "guide:test", href: "/test/comment", name: "Test Guide", writable: true } }); state.publish.mockResolvedValue({ id, version: 2 }); state.notifications.mockResolvedValue(undefined); state.send.mockResolvedValue(undefined); }); afterEach(() => vi.unstubAllEnvs()); describe("comment notifications", () => { it.each([2, 3])("ignores an explicit edit revision %s before querying or sending", async (version) => { await sendCommentPush(id, version); expect(state.select).not.toHaveBeenCalled(); expect(state.notifications).not.toHaveBeenCalled(); expect(state.send).not.toHaveBeenCalled(); }); it.each([null, "parent-id"])("ignores an edited comment or reply when no revision is supplied (parent %s)", async (replyToId) => { state.rows = [[{ id, version: 2, hidden: false, deletedAt: null, replyToId }]]; await sendCommentPush(id); expect(state.select).toHaveBeenCalledTimes(1); expect(state.authorize).not.toHaveBeenCalled(); expect(state.notifications).not.toHaveBeenCalled(); expect(state.send).not.toHaveBeenCalled(); }); it.each([false, true])("still sends new-comment notifications with the author's profile picture (reply: %s)", async (reply) => { const recipient = { id: reply ? "reader" : "admin", admin: !reply }; state.rows = [ [{ id, version: 1, hidden: false, deletedAt: null, authorId: "author", replyToId: reply ? "parent-id" : null, rootId: reply ? "root-id" : null }], ...(reply ? [[{ authorId: recipient.id, hidden: false, deletedAt: null }], [{ id: recipient.id, role: "user", banned: false }]] : [[{ id: recipient.id }]]), [{ name: "Author", image: "https://profile.example/author.png" }], [{ text: "Original comment" }], [subscription], ]; await sendCommentPush(id, 1); expect(state.notifications).toHaveBeenCalledWith([expect.objectContaining({ userId: recipient.id, adminOnly: !reply })], expect.objectContaining({ kind: reply ? "comment_reply" : "comment_new", eventKey: `comment:${id}`, body: "Original comment", })); expect(state.send).toHaveBeenCalledTimes(1); expect(JSON.parse(state.send.mock.calls[0][1])).toMatchObject({ body: "Original comment", icon: "https://profile.example/author.png" }); expect(state.notifications.mock.calls[0][1].title).toBe(reply ? "Author ตอบกลับความคิดเห็นของคุณ" : "Author แสดงความคิดเห็นใหม่ในไกด์ Test Guide"); }); it("still delivers a delayed initial notification when the comment has since been edited", async () => { state.rows = [ [{ id, version: 2, hidden: false, deletedAt: null, authorId: "author", replyToId: null }], [{ id: "admin" }], [{ name: "Author" }], [{ text: "Original revision" }], [subscription], ]; await sendCommentPush(id, 1); expect(state.notifications).toHaveBeenCalledWith(expect.any(Array), expect.objectContaining({ kind: "comment_new", body: "Original revision" })); expect(state.send).toHaveBeenCalledTimes(1); expect(JSON.parse(state.send.mock.calls[0][1]).icon).toBe("/icon/nav/Comment.webp"); }); it("keeps Stygian notification titles separate from guide titles", async () => { state.authorize.mockResolvedValue({ destination: { target: "stygian:123", href: "/stygian/comment", name: "Test round", writable: true } }); state.rows = [ [{ id, version: 1, hidden: false, deletedAt: null, authorId: "author", replyToId: null }], [{ id: "admin" }], [{ name: "Author" }], [{ text: "Original comment" }], [subscription], ]; await sendCommentPush(id, 1); expect(state.notifications.mock.calls[0][1].title).toBe("Author แสดงความคิดเห็นใหม่ใน Test round"); }); it("saves edits through PATCH without scheduling a notification", async () => { const response = await PATCH(new Request(`https://guide.example.test/api/comments/${id}`, { method: "PATCH", headers: { Origin: "https://guide.example.test" }, }), { params: Promise.resolve({ id }) }); expect(response.status).toBe(200); expect(await response.json()).toEqual({ id, version: 2 }); expect(state.publish).toHaveBeenCalled(); expect(state.after).not.toHaveBeenCalled(); expect(state.notifications).not.toHaveBeenCalled(); expect(state.send).not.toHaveBeenCalled(); }); });