Files
buzz-sheet/lib/comments/heart-notification.test.ts
T
gunshiz 72e9f525cc
CI / Verify (push) Successful in 1m42s
CI / Build immutable images and deploy (push) Successful in 3m3s
feat(notifications) : notify guide publications and admin hearts; simplify Stygian navigation
2026-10-08 04:04:02 +07:00

96 lines
4.5 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
comment: {} as Record<string, unknown>, author: {} as Record<string, unknown> | null,
publicGuide: true, notifications: [] as Record<string, unknown>[],
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<string, unknown>) => {
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<string, unknown>) => ({ 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: "[email protected]", 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);
});
});