import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Mock } from "vitest"; // ============================================================ // Mock 外部依赖 // ============================================================ // Mock database —— getDb 返回模拟的 db 对象 const mockDb = { insert: vi.fn(), select: vi.fn(), update: vi.fn(), delete: vi.fn(), }; vi.mock("../../src/config/database.js", () => ({ getDb: () => mockDb, })); // Mock logger(避免 pino 初始化副作用) vi.mock("../../src/shared/observability/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, })); // 导入被测模块(在 mock 之后) import { insertNotification, insertNotifications, findById, findByEventId, listByUser, getUnreadCount, markAsRead, batchMarkAsRead, markAllAsRead, recallByGroup, deleteById, } from "../../src/notifications/notifications.repository.js"; // ============================================================ // 辅助:创建 drizzle 链式 mock // ============================================================ /** * 创建一个可链式调用且可 await 的 mock 对象。 * drizzle 查询构建器方法(from/where/limit/offset/orderBy/values/set) * 全部返回链本身,await 时解析为 resolveValue。 */ function createChain(resolveValue: unknown) { const chain: Record = { then(resolve: (v: unknown) => void, reject?: (e: unknown) => void) { return Promise.resolve(resolveValue).then(resolve, reject); }, }; for (const method of [ "from", "where", "limit", "offset", "orderBy", "values", "set", ]) { chain[method] = vi.fn().mockReturnValue(chain); } return chain; } // ============================================================ // Tests // ============================================================ describe("notifications.repository", () => { beforeEach(() => { vi.clearAllMocks(); }); // ---------------------------------------------------------- // insertNotification // ---------------------------------------------------------- describe("insertNotification", () => { it("应插入通知行并返回该行", async () => { const chain = createChain(undefined); (mockDb.insert as Mock).mockReturnValue(chain); const row = { id: "notif-1", userId: "user-1", type: "system" as const, title: "Test", content: "Content", channel: "in_app" as const, isRead: false, status: "pending" as const, metadata: null, relatedEntityType: null, relatedEntityId: null, groupId: null, senderId: null, templateId: null, eventId: null, }; const result = await insertNotification(row); expect(mockDb.insert).toHaveBeenCalledTimes(1); expect(chain.values).toHaveBeenCalledWith(row); expect(result).toEqual(row); }); }); // ---------------------------------------------------------- // insertNotifications (batch) // ---------------------------------------------------------- describe("insertNotifications", () => { it("应批量插入多行通知", async () => { const chain = createChain(undefined); (mockDb.insert as Mock).mockReturnValue(chain); const rows = [ { id: "notif-1", userId: "user-1", type: "system" as const, title: "T1", content: "C1", channel: "in_app" as const, isRead: false, status: "pending" as const, metadata: null, relatedEntityType: null, relatedEntityId: null, groupId: null, senderId: null, templateId: null, eventId: null, }, { id: "notif-2", userId: "user-2", type: "exam" as const, title: "T2", content: "C2", channel: "in_app" as const, isRead: false, status: "pending" as const, metadata: null, relatedEntityType: null, relatedEntityId: null, groupId: null, senderId: null, templateId: null, eventId: null, }, ]; await insertNotifications(rows); expect(mockDb.insert).toHaveBeenCalledTimes(1); expect(chain.values).toHaveBeenCalledWith(rows); }); it("空数组应直接返回,不调用 db", async () => { await insertNotifications([]); expect(mockDb.insert).not.toHaveBeenCalled(); }); }); // ---------------------------------------------------------- // findById // ---------------------------------------------------------- describe("findById", () => { it("应根据 id 查询并返回通知行", async () => { const mockRow = { id: "notif-1", userId: "user-1", title: "Test" }; const chain = createChain([mockRow]); (mockDb.select as Mock).mockReturnValue(chain); const result = await findById("notif-1"); expect(mockDb.select).toHaveBeenCalledTimes(1); expect(chain.from).toHaveBeenCalledTimes(1); expect(chain.where).toHaveBeenCalledTimes(1); expect(chain.limit).toHaveBeenCalledWith(1); expect(result).toEqual(mockRow); }); it("未找到时应返回 undefined", async () => { const chain = createChain([]); (mockDb.select as Mock).mockReturnValue(chain); const result = await findById("not-exist"); expect(result).toBeUndefined(); }); }); // ---------------------------------------------------------- // findByEventId // ---------------------------------------------------------- describe("findByEventId", () => { it("应根据 eventId 查询并返回通知行", async () => { const mockRow = { id: "notif-1", eventId: "evt-1", status: "sent" }; const chain = createChain([mockRow]); (mockDb.select as Mock).mockReturnValue(chain); const result = await findByEventId("evt-1"); expect(mockDb.select).toHaveBeenCalledTimes(1); expect(chain.from).toHaveBeenCalledTimes(1); expect(chain.where).toHaveBeenCalledTimes(1); expect(chain.limit).toHaveBeenCalledWith(1); expect(result).toEqual(mockRow); }); it("未找到时应返回 undefined", async () => { const chain = createChain([]); (mockDb.select as Mock).mockReturnValue(chain); const result = await findByEventId("not-exist"); expect(result).toBeUndefined(); }); }); // ---------------------------------------------------------- // listByUser // ---------------------------------------------------------- describe("listByUser", () => { it("应分页查询用户通知并返回 items + total", async () => { const mockItems = [ { id: "notif-1", userId: "user-1" }, { id: "notif-2", userId: "user-1" }, ]; const mockCountRow = [{ value: 25 }]; // 第一次 select → items 查询, 第二次 select → count 查询 (mockDb.select as Mock) .mockReturnValueOnce(createChain(mockItems)) .mockReturnValueOnce(createChain(mockCountRow)); const result = await listByUser("user-1", { page: 2, pageSize: 10, }); expect(result.items).toEqual(mockItems); expect(result.total).toBe(25); expect(mockDb.select).toHaveBeenCalledTimes(2); }); it("onlyUnread=true 应添加未读过滤条件", async () => { const itemsChain = createChain([]); const countChain = createChain([{ value: 0 }]); (mockDb.select as Mock) .mockReturnValueOnce(itemsChain) .mockReturnValueOnce(countChain); await listByUser("user-1", { onlyUnread: true, page: 1, pageSize: 20, }); // 两次查询都调用 where expect(itemsChain.where).toHaveBeenCalledTimes(1); expect(countChain.where).toHaveBeenCalledTimes(1); }); it("type 过滤应生效", async () => { const itemsChain = createChain([]); const countChain = createChain([{ value: 0 }]); (mockDb.select as Mock) .mockReturnValueOnce(itemsChain) .mockReturnValueOnce(countChain); await listByUser("user-1", { type: "exam", page: 1, pageSize: 20, }); expect(itemsChain.where).toHaveBeenCalledTimes(1); expect(countChain.where).toHaveBeenCalledTimes(1); }); it("count 为 undefined 时 total 应为 0", async () => { const itemsChain = createChain([]); const countChain = createChain([undefined]); (mockDb.select as Mock) .mockReturnValueOnce(itemsChain) .mockReturnValueOnce(countChain); const result = await listByUser("user-1", { page: 1, pageSize: 10, }); expect(result.total).toBe(0); }); it("offset 应根据 page 和 pageSize 计算", async () => { const itemsChain = createChain([]); const countChain = createChain([{ value: 0 }]); (mockDb.select as Mock) .mockReturnValueOnce(itemsChain) .mockReturnValueOnce(countChain); await listByUser("user-1", { page: 3, pageSize: 15, }); // offset = (3-1) * 15 = 30 expect(itemsChain.offset).toHaveBeenCalledWith(30); }); }); // ---------------------------------------------------------- // getUnreadCount // ---------------------------------------------------------- describe("getUnreadCount", () => { it("应返回用户未读通知数", async () => { const chain = createChain([{ value: 7 }]); (mockDb.select as Mock).mockReturnValue(chain); const result = await getUnreadCount("user-1"); expect(result).toBe(7); expect(mockDb.select).toHaveBeenCalledTimes(1); expect(chain.from).toHaveBeenCalledTimes(1); expect(chain.where).toHaveBeenCalledTimes(1); }); it("无未读时应返回 0", async () => { const chain = createChain([undefined]); (mockDb.select as Mock).mockReturnValue(chain); const result = await getUnreadCount("user-1"); expect(result).toBe(0); }); }); // ---------------------------------------------------------- // markAsRead // ---------------------------------------------------------- describe("markAsRead", () => { it("应将指定通知标记为已读", async () => { const chain = createChain(undefined); (mockDb.update as Mock).mockReturnValue(chain); await markAsRead("notif-1", "user-1"); expect(mockDb.update).toHaveBeenCalledTimes(1); expect(chain.set).toHaveBeenCalledTimes(1); expect(chain.where).toHaveBeenCalledTimes(1); // 验证 set 的参数包含 isRead: true const setArg = (chain.set as Mock).mock.calls[0][0]; expect(setArg.isRead).toBe(true); expect(setArg.status).toBe("read"); expect(setArg.readAt).toBeInstanceOf(Date); }); }); // ---------------------------------------------------------- // batchMarkAsRead // ---------------------------------------------------------- describe("batchMarkAsRead", () => { it("应批量标记多条通知为已读", async () => { const chain = createChain(undefined); (mockDb.update as Mock).mockReturnValue(chain); await batchMarkAsRead(["notif-1", "notif-2", "notif-3"], "user-1"); expect(mockDb.update).toHaveBeenCalledTimes(1); expect(chain.set).toHaveBeenCalledTimes(1); expect(chain.where).toHaveBeenCalledTimes(1); const setArg = (chain.set as Mock).mock.calls[0][0]; expect(setArg.isRead).toBe(true); expect(setArg.status).toBe("read"); }); }); // ---------------------------------------------------------- // markAllAsRead // ---------------------------------------------------------- describe("markAllAsRead", () => { it("应将用户所有未读通知标记为已读,返回受影响行数", async () => { const chain = createChain({ affectedRows: 5 }); (mockDb.update as Mock).mockReturnValue(chain); const result = await markAllAsRead("user-1"); expect(result).toBe(5); expect(mockDb.update).toHaveBeenCalledTimes(1); expect(chain.where).toHaveBeenCalledTimes(1); }); it("before 参数应添加时间过滤条件", async () => { const chain = createChain({ affectedRows: 3 }); (mockDb.update as Mock).mockReturnValue(chain); const before = Date.now(); const result = await markAllAsRead("user-1", before); expect(result).toBe(3); expect(chain.where).toHaveBeenCalledTimes(1); }); it("无受影响行时应返回 0", async () => { const chain = createChain({}); (mockDb.update as Mock).mockReturnValue(chain); const result = await markAllAsRead("user-1"); expect(result).toBe(0); }); }); // ---------------------------------------------------------- // recallByGroup // ---------------------------------------------------------- describe("recallByGroup", () => { it("应按 groupId 撤回通知,返回受影响行数", async () => { const chain = createChain({ affectedRows: 10 }); (mockDb.update as Mock).mockReturnValue(chain); const result = await recallByGroup("group-1"); expect(result).toBe(10); expect(mockDb.update).toHaveBeenCalledTimes(1); const setArg = (chain.set as Mock).mock.calls[0][0]; expect(setArg.status).toBe("recalled"); }); it("无匹配行时应返回 0", async () => { const chain = createChain({}); (mockDb.update as Mock).mockReturnValue(chain); const result = await recallByGroup("empty-group"); expect(result).toBe(0); }); }); // ---------------------------------------------------------- // deleteById // ---------------------------------------------------------- describe("deleteById", () => { it("应根据 id 删除通知", async () => { const chain = createChain(undefined); (mockDb.delete as Mock).mockReturnValue(chain); await deleteById("notif-1"); expect(mockDb.delete).toHaveBeenCalledTimes(1); expect(chain.where).toHaveBeenCalledTimes(1); }); }); });