96 lines
4.5 KiB
TypeScript
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);
|
|
});
|
|
});
|