import { beforeEach, describe, expect, it, vi } from "vitest"; const state = vi.hoisted(() => ({ comment: {} as Record, author: {} as Record | null, publicGuide: true, notifications: [] as Record[], notify: vi.fn(), changed: vi.fn(), })); vi.mock("server-only", () => ({})); vi.mock("@/lib/notifications/events", () => ({ notifyNotificationChange: state.notify })); vi.mock("@/lib/comments/events", () => ({ notifyCommentChange: state.changed })); vi.mock("@/lib/auth/server", () => ({ getCustomerSession: vi.fn() })); vi.mock("@/lib/audit-log", () => ({ auditActor: vi.fn(), writeAuditLog: vi.fn() })); vi.mock("@/lib/media/storage", () => ({ getMediaStorage: vi.fn(), publicMediaUrl: vi.fn() })); vi.mock("@/db", () => ({ getDb: () => database })); import { comments, guides, users } from "@/db/schema"; import { mutateComment } from "./repository"; const id = "72df08ab-50dd-4cbd-9a69-70949d34cf9f"; const guideId = "72df08ab-50dd-4cbd-9a69-70949d34cf9e"; const admin = { id: "admin", admin: true }; const database = { transaction: async (task: (tx: unknown) => unknown) => task(database), select: (fields: Record) => { let table: unknown; const rows = () => { if (table === comments) return fields.comment ? [{ comment: state.comment, thread: { id: "thread", guideId } }] : [{ hidden: false }]; if (table === guides) return [{ id: guideId, name: "Amber", slug: "amber", public: state.publicGuide, trashedAt: null }]; if (table === users) return state.author ? [state.author] : []; return []; }; const query = { from(value: unknown) { table = value; return query; }, innerJoin() { return query; }, where() { return query; }, limit: async () => rows(), for: async () => rows(), }; return query; }, update: () => { const query = { set: () => query, where: async () => undefined }; return query; }, insert: () => ({ values: (value: Record) => ({ onConflictDoNothing: () => ({ returning: async () => { if (state.notifications.some(old => old.eventKey === value.eventKey)) return []; state.notifications.push(value); return [{ userId: value.userId }]; } }) }) }), }; beforeEach(() => { state.comment = { id, authorId: "reader", rootId: null, hidden: false, deletedAt: null, heartedById: null }; state.author = { id: "reader", email: "reader@example.test", role: "user", emailVerified: true, banned: false }; state.publicGuide = true; state.notifications.length = 0; state.notify.mockReset(); state.changed.mockReset(); }); describe("admin heart notifications", () => { it("notifies the comment author and refreshes their bell after saving", async () => { await mutateComment(id, admin, "heart", true); expect(state.notifications).toEqual([expect.objectContaining({ userId: "reader", kind: "comment_heart", title: "คุณได้รับหัวใจจากแอดมิน", url: `/amber/comment#comment-${id}`, commentId: id })]); expect(state.notify).toHaveBeenCalledWith("reader"); }); it("links a reply to its exact thread", async () => { state.comment.rootId = "root-id"; await mutateComment(id, admin, "heart", true); expect(state.notifications[0].url).toBe(`/amber/comment?reply=${id}#comment-root-id`); }); it.each(["removed", "already hearted", "self", "banned", "missing", "private guide"])("skips notifications for %s", async (scenario) => { if (scenario === "already hearted") state.comment.heartedById = "other-admin"; if (scenario === "self") state.comment.authorId = admin.id; if (scenario === "banned") state.author!.banned = true; if (scenario === "missing") state.author = null; if (scenario === "private guide") state.publicGuide = false; await mutateComment(id, admin, "heart", scenario !== "removed"); expect(state.notifications).toHaveLength(0); expect(state.notify).not.toHaveBeenCalled(); }); it("does not duplicate a notification when the heart is added again", async () => { await mutateComment(id, admin, "heart", true); await mutateComment(id, admin, "heart", false); await mutateComment(id, admin, "heart", true); expect(state.notifications).toHaveLength(1); expect(state.notify).toHaveBeenCalledTimes(1); }); it("rejects hearts from regular users", async () => { await expect(mutateComment(id, { id: "reader", admin: false }, "heart", true)).rejects.toMatchObject({ status: 403 }); expect(state.notifications).toHaveLength(0); }); });