import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Mock } from "vitest"; import type { ChannelSendResult } from "../../src/channels/channel.types.js"; // ============================================================ // Mock 外部依赖 —— 使用 vi.hoisted 确保 mock 变量在 hoisted 的 vi.mock 中可用 // ============================================================ const mocks = vi.hoisted(() => { // Mock database(用于 getUserChannels / updateStatus) const mockDb = { select: vi.fn(), update: vi.fn(), }; // Mock elasticsearch const mockSafeIndex = vi.fn(); const mockSafeSearch = vi.fn(); const mockSafeDelete = vi.fn(); // Mock outbox publish const mockOutboxPublish = vi.fn(); // Mock repository const mockRepo = { insertNotification: vi.fn(), insertNotifications: vi.fn(), findById: vi.fn(), findByEventId: vi.fn(), findExistingEventIds: vi.fn(), listByUser: vi.fn(), getUnreadCount: vi.fn(), markAsRead: vi.fn(), batchMarkAsRead: vi.fn(), markAllAsRead: vi.fn(), recallByGroup: vi.fn(), deleteById: vi.fn(), }; return { mockDb, mockSafeIndex, mockSafeSearch, mockSafeDelete, mockOutboxPublish, mockRepo, }; }); // Mock cuid2 —— 固定 ID 便于断言 vi.mock("@paralleldrive/cuid2", () => ({ createId: vi.fn(() => "mock-cuid-id"), })); vi.mock("../../src/config/database.js", () => ({ getDb: () => mocks.mockDb, })); vi.mock("../../src/config/elasticsearch.js", () => ({ safeIndex: mocks.mockSafeIndex, safeSearch: mocks.mockSafeSearch, safeDelete: mocks.mockSafeDelete, })); vi.mock("../../src/shared/outbox/outbox.service.js", () => ({ publish: mocks.mockOutboxPublish, })); vi.mock("../../src/shared/observability/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, })); vi.mock( "../../src/notifications/notifications.repository.js", () => mocks.mockRepo, ); // 导入被测模块(在 mock 之后) import { NotificationsService } from "../../src/notifications/notifications.service.js"; // ============================================================ // 辅助 // ============================================================ const { mockDb, mockSafeIndex, mockSafeSearch, mockSafeDelete, mockOutboxPublish, mockRepo, } = mocks; function createMockDispatcher( results: ChannelSendResult[] = [{ channel: "in_app", sent: true }], ) { return { dispatch: vi.fn().mockResolvedValue(results), }; } /** 创建 select 链式 mock(用于 getUserChannels) */ function createSelectChain(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"]) { chain[method] = vi.fn().mockReturnValue(chain); } return chain; } /** 创建 update 链式 mock(用于 updateStatus) */ function createUpdateChain(resolveValue: unknown) { const chain: Record = { then(resolve: (v: unknown) => void, reject?: (e: unknown) => void) { return Promise.resolve(resolveValue).then(resolve, reject); }, }; chain.set = vi.fn().mockReturnValue(chain); chain.where = vi.fn().mockReturnValue(chain); return chain; } // ============================================================ // Tests // ============================================================ describe("NotificationsService", () => { let service: NotificationsService; beforeEach(() => { vi.clearAllMocks(); // 重置 repo mocks 到默认值 mockRepo.findByEventId.mockResolvedValue(undefined); mockRepo.findExistingEventIds.mockResolvedValue(new Set()); mockRepo.insertNotification.mockResolvedValue(undefined); mockRepo.listByUser.mockResolvedValue({ items: [], total: 0 }); mockRepo.getUnreadCount.mockResolvedValue(0); mockRepo.markAsRead.mockResolvedValue(undefined); mockRepo.batchMarkAsRead.mockResolvedValue(undefined); mockRepo.markAllAsRead.mockResolvedValue(0); mockRepo.recallByGroup.mockResolvedValue(0); mockRepo.deleteById.mockResolvedValue(undefined); // 重置 es mocks mockSafeIndex.mockResolvedValue(true); mockSafeSearch.mockResolvedValue({ hits: [], total: 0 }); mockSafeDelete.mockResolvedValue(true); // 重置 outbox mockOutboxPublish.mockResolvedValue({ eventId: "evt-id", topic: "edu.notification.sent", }); }); // ---------------------------------------------------------- // send // ---------------------------------------------------------- describe("send", () => { it("幂等跳过:eventId 已存在时应返回已有通知,不重复写入", async () => { const existing = { id: "existing-id", status: "sent", channel: "in_app", }; mockRepo.findByEventId.mockResolvedValue(existing); const dispatcher = createMockDispatcher(); service = new NotificationsService(dispatcher as never); const result = await service.send({ userId: "user-1", type: "system", title: "Test", content: "Content", eventId: "evt-1", }); expect(result).toEqual({ id: "existing-id", status: "sent", channels: ["in_app"], }); expect(mockRepo.findByEventId).toHaveBeenCalledWith("evt-1"); expect(mockRepo.insertNotification).not.toHaveBeenCalled(); expect(dispatcher.dispatch).not.toHaveBeenCalled(); }); it("无 eventId 时应正常发送(不做幂等检查)", async () => { const dispatcher = createMockDispatcher([ { channel: "in_app", sent: true }, ]); service = new NotificationsService(dispatcher as never); // getUserChannels 返回 null(无偏好) mockDb.select.mockReturnValue(createSelectChain([])); mockDb.update.mockReturnValue(createUpdateChain(undefined)); const result = await service.send({ userId: "user-1", type: "system", title: "Test", content: "Content", }); expect(mockRepo.findByEventId).not.toHaveBeenCalled(); expect(mockRepo.insertNotification).toHaveBeenCalledTimes(1); expect(result.id).toBe("mock-cuid-id"); expect(result.status).toBe("sent"); }); it("应写入 MySQL、ES、分发渠道、更新状态、写 outbox", async () => { const dispatcher = createMockDispatcher([ { channel: "in_app", sent: true }, { channel: "email", sent: true }, ]); service = new NotificationsService(dispatcher as never); mockDb.select.mockReturnValue(createSelectChain([])); mockDb.update.mockReturnValue(createUpdateChain(undefined)); const result = await service.send({ userId: "user-1", type: "exam", title: "Exam Published", content: "Your exam is live", channel: "in_app", metadata: { examId: "exam-1" }, }); // 写入 MySQL expect(mockRepo.insertNotification).toHaveBeenCalledWith( expect.objectContaining({ id: "mock-cuid-id", userId: "user-1", type: "exam", title: "Exam Published", channel: "in_app", status: "pending", isRead: false, }), ); // 写入 ES expect(mockSafeIndex).toHaveBeenCalledWith( expect.objectContaining({ index: "notifications", id: "mock-cuid-id", }), ); // 分发渠道 expect(dispatcher.dispatch).toHaveBeenCalledTimes(1); const dispatchCtx = (dispatcher.dispatch as Mock).mock.calls[0][0]; expect(dispatchCtx.notificationId).toBe("mock-cuid-id"); expect(dispatchCtx.userId).toBe("user-1"); // 更新状态 expect(mockDb.update).toHaveBeenCalledTimes(1); // 写 outbox expect(mockOutboxPublish).toHaveBeenCalledWith( "notification.sent", expect.objectContaining({ notificationId: "mock-cuid-id", userId: "user-1", channel: "in_app", }), expect.objectContaining({ aggregateType: "Notification", aggregateId: "mock-cuid-id", }), ); expect(result.status).toBe("sent"); expect(result.channels).toEqual(["in_app", "email"]); }); it("所有渠道失败时状态应为 failed", async () => { const dispatcher = createMockDispatcher([ { channel: "in_app", sent: false, error: "failed" }, ]); service = new NotificationsService(dispatcher as never); mockDb.select.mockReturnValue(createSelectChain([])); mockDb.update.mockReturnValue(createUpdateChain(undefined)); const result = await service.send({ userId: "user-1", type: "system", title: "Test", content: "Content", }); expect(result.status).toBe("failed"); }); it("默认渠道应为 in_app(未指定 channel 时)", async () => { const dispatcher = createMockDispatcher([ { channel: "in_app", sent: true }, ]); service = new NotificationsService(dispatcher as never); mockDb.select.mockReturnValue(createSelectChain([])); mockDb.update.mockReturnValue(createUpdateChain(undefined)); await service.send({ userId: "user-1", type: "system", title: "Test", content: "Content", }); const insertArg = mockRepo.insertNotification.mock.calls[0][0]; expect(insertArg.channel).toBe("in_app"); }); it("应使用用户偏好渠道(getUserChannels 返回渠道列表)", async () => { const dispatcher = createMockDispatcher([ { channel: "in_app", sent: true }, { channel: "email", sent: true }, ]); service = new NotificationsService(dispatcher as never); // 模拟用户偏好返回 email 渠道 mockDb.select.mockReturnValue( createSelectChain([ { channels: ["email"], userId: "user-1", type: "system", enabled: true, }, ]), ); mockDb.update.mockReturnValue(createUpdateChain(undefined)); const result = await service.send({ userId: "user-1", type: "system", title: "Test", content: "Content", }); // dispatch 第二个参数应包含 email const enabledChannels = (dispatcher.dispatch as Mock).mock.calls[0][1]; expect(enabledChannels).toContain("email"); expect(result.channels).toEqual(["in_app", "email"]); }); it("ES 写入失败不应阻断主流程", async () => { const dispatcher = createMockDispatcher([ { channel: "in_app", sent: true }, ]); service = new NotificationsService(dispatcher as never); // safeIndex 内部 try/catch,失败时返回 false(不抛出) mockSafeIndex.mockResolvedValue(false); mockDb.select.mockReturnValue(createSelectChain([])); mockDb.update.mockReturnValue(createUpdateChain(undefined)); // 不应抛出 const result = await service.send({ userId: "user-1", type: "system", title: "Test", content: "Content", }); expect(result.status).toBe("sent"); }); }); // ---------------------------------------------------------- // sendBatch // ---------------------------------------------------------- describe("sendBatch", () => { it("应批量发送多条通知,返回成功 id 列表", async () => { const dispatcher = createMockDispatcher([ { channel: "in_app", sent: true }, ]); service = new NotificationsService(dispatcher as never); mockDb.select.mockReturnValue(createSelectChain([])); mockDb.update.mockReturnValue(createUpdateChain(undefined)); const result = await service.sendBatch({ items: [ { userId: "user-1", type: "system", title: "T1", content: "C1" }, { userId: "user-2", type: "system", title: "T2", content: "C2" }, ], groupId: "group-1", }); expect(result.ids).toHaveLength(2); expect(result.failed).toHaveLength(0); // 批量 INSERT 应调用 insertNotifications(复数) expect(mockRepo.insertNotifications).toHaveBeenCalledTimes(1); const batchRows = mockRepo.insertNotifications.mock.calls[0][0]; expect(batchRows).toHaveLength(2); expect(batchRows[0].groupId).toBe("group-1"); expect(batchRows[1].groupId).toBe("group-1"); }); it("未指定 groupId 时应自动生成", async () => { const dispatcher = createMockDispatcher([ { channel: "in_app", sent: true }, ]); service = new NotificationsService(dispatcher as never); mockDb.select.mockReturnValue(createSelectChain([])); mockDb.update.mockReturnValue(createUpdateChain(undefined)); const result = await service.sendBatch({ items: [ { userId: "user-1", type: "system", title: "T1", content: "C1" }, ], }); expect(result.ids).toHaveLength(1); // groupId 应使用 cuid2 生成的值 const batchRows = mockRepo.insertNotifications.mock.calls[0][0]; expect(batchRows[0].groupId).toBe("mock-cuid-id"); }); it("批量 INSERT 失败时全部标记为 failed", async () => { const dispatcher = createMockDispatcher([ { channel: "in_app", sent: true }, ]); service = new NotificationsService(dispatcher as never); mockRepo.insertNotifications.mockRejectedValueOnce(new Error("DB error")); mockDb.select.mockReturnValue(createSelectChain([])); mockDb.update.mockReturnValue(createUpdateChain(undefined)); const result = await service.sendBatch({ items: [ { userId: "user-1", type: "system", title: "T1", content: "C1" }, { userId: "user-2", type: "system", title: "T2", content: "C2" }, ], groupId: "group-1", }); expect(result.ids).toHaveLength(0); expect(result.failed).toHaveLength(2); expect(result.failed[0].error).toBe("DB error"); expect(result.failed[1].error).toBe("DB error"); }); it("部分分发失败时应收集 failed 列表", async () => { // 第一条分发成功,第二条分发抛异常 const dispatcher = createMockDispatcher([ { channel: "in_app", sent: true }, ]); service = new NotificationsService(dispatcher as never); // 让 dispatch 第二次调用抛异常 dispatcher.dispatch .mockResolvedValueOnce([{ channel: "in_app", sent: true }]) .mockRejectedValueOnce(new Error("Dispatch error")); mockDb.select.mockReturnValue(createSelectChain([])); mockDb.update.mockReturnValue(createUpdateChain(undefined)); const result = await service.sendBatch({ items: [ { userId: "user-1", type: "system", title: "T1", content: "C1" }, { userId: "user-2", type: "system", title: "T2", content: "C2" }, ], groupId: "group-1", }); expect(result.ids).toHaveLength(1); expect(result.failed).toHaveLength(1); expect(result.failed[0].userId).toBe("user-2"); expect(result.failed[0].error).toBe("Dispatch error"); }); it("eventId 已存在时应跳过(幂等过滤)", async () => { const dispatcher = createMockDispatcher([ { channel: "in_app", sent: true }, ]); service = new NotificationsService(dispatcher as never); // 模拟 findExistingEventIds 返回已存在的 eventId mockRepo.findExistingEventIds.mockResolvedValueOnce( new Set(["evt-existing-1"]), ); mockDb.select.mockReturnValue(createSelectChain([])); mockDb.update.mockReturnValue(createUpdateChain(undefined)); const result = await service.sendBatch({ items: [ { userId: "user-1", type: "system", title: "T1", content: "C1", eventId: "evt-existing-1", }, { userId: "user-2", type: "system", title: "T2", content: "C2", eventId: "evt-new-1", }, ], groupId: "group-1", }); // 应只插入 1 条(evt-existing-1 被跳过) expect(result.ids).toHaveLength(1); expect(result.failed).toHaveLength(0); expect(mockRepo.insertNotifications).toHaveBeenCalledTimes(1); const batchRows = mockRepo.insertNotifications.mock.calls[0][0]; expect(batchRows).toHaveLength(1); expect(batchRows[0].eventId).toBe("evt-new-1"); }); it("所有 eventId 都已存在时应返回空 ids(全跳过)", async () => { const dispatcher = createMockDispatcher([ { channel: "in_app", sent: true }, ]); service = new NotificationsService(dispatcher as never); mockRepo.findExistingEventIds.mockResolvedValueOnce( new Set(["evt-1", "evt-2"]), ); const result = await service.sendBatch({ items: [ { userId: "user-1", type: "system", title: "T1", content: "C1", eventId: "evt-1", }, { userId: "user-2", type: "system", title: "T2", content: "C2", eventId: "evt-2", }, ], groupId: "group-1", }); expect(result.ids).toHaveLength(0); expect(result.failed).toHaveLength(0); expect(mockRepo.insertNotifications).not.toHaveBeenCalled(); expect(dispatcher.dispatch).not.toHaveBeenCalled(); }); }); // ---------------------------------------------------------- // listByUser // ---------------------------------------------------------- describe("listByUser", () => { it("应返回分页结果", async () => { service = new NotificationsService(createMockDispatcher() as never); const mockItems = [{ id: "n1" }, { id: "n2" }]; mockRepo.listByUser.mockResolvedValue({ items: mockItems, total: 25 }); const result = await service.listByUser("user-1", { page: 2, pageSize: 10, }); expect(result.items).toEqual(mockItems); expect(result.total).toBe(25); expect(result.page).toBe(2); expect(result.pageSize).toBe(10); }); }); // ---------------------------------------------------------- // getUnreadCount // ---------------------------------------------------------- describe("getUnreadCount", () => { it("应返回未读计数", async () => { service = new NotificationsService(createMockDispatcher() as never); mockRepo.getUnreadCount.mockResolvedValue(7); const result = await service.getUnreadCount("user-1"); expect(result).toBe(7); }); }); // ---------------------------------------------------------- // markAsRead // ---------------------------------------------------------- describe("markAsRead", () => { it("应标记已读并发布 notification.read 事件", async () => { service = new NotificationsService(createMockDispatcher() as never); await service.markAsRead("notif-1", "user-1"); expect(mockRepo.markAsRead).toHaveBeenCalledWith("notif-1", "user-1"); expect(mockOutboxPublish).toHaveBeenCalledWith( "notification.read", { notificationId: "notif-1", userId: "user-1" }, expect.objectContaining({ aggregateType: "Notification", aggregateId: "notif-1", }), ); }); }); // ---------------------------------------------------------- // batchMarkAsRead // ---------------------------------------------------------- describe("batchMarkAsRead", () => { it("应批量标记已读并为每条发布 read 事件", async () => { service = new NotificationsService(createMockDispatcher() as never); await service.batchMarkAsRead(["n1", "n2", "n3"], "user-1"); expect(mockRepo.batchMarkAsRead).toHaveBeenCalledWith( ["n1", "n2", "n3"], "user-1", ); expect(mockOutboxPublish).toHaveBeenCalledTimes(3); // 验证每条 id 都发布了事件 for (let i = 0; i < 3; i++) { const payload = mockOutboxPublish.mock.calls[i][1]; expect(payload.notificationId).toBe(["n1", "n2", "n3"][i]); } }); }); // ---------------------------------------------------------- // markAllAsRead // ---------------------------------------------------------- describe("markAllAsRead", () => { it("应标记全部已读并返回受影响行数", async () => { service = new NotificationsService(createMockDispatcher() as never); mockRepo.markAllAsRead.mockResolvedValue(15); const result = await service.markAllAsRead("user-1"); expect(result).toBe(15); expect(mockOutboxPublish).toHaveBeenCalledWith( "notification.read", expect.objectContaining({ bulk: true, userId: "user-1" }), expect.objectContaining({ aggregateType: "Notification", aggregateId: "user-1", }), ); }); it("before 参数应传递给 outbox 事件", async () => { service = new NotificationsService(createMockDispatcher() as never); mockRepo.markAllAsRead.mockResolvedValue(5); const before = 1700000000000; await service.markAllAsRead("user-1", before); const payload = mockOutboxPublish.mock.calls[0][1]; expect(payload.before).toBe(before); }); }); // ---------------------------------------------------------- // search // ---------------------------------------------------------- describe("search", () => { it("应通过 ES 搜索并返回结果", async () => { service = new NotificationsService(createMockDispatcher() as never); const mockHits = [ { _id: "n1", _source: { id: "n1", title: "Hello" } }, { _id: "n2", _source: { id: "n2", title: "Hello World" } }, ]; mockSafeSearch.mockResolvedValue({ hits: mockHits, total: 2 }); const result = await service.search("user-1", "Hello", { page: 1, pageSize: 20, }); expect(result.items).toEqual([ { id: "n1", title: "Hello" }, { id: "n2", title: "Hello World" }, ]); expect(result.total).toBe(2); expect(result.page).toBe(1); expect(result.pageSize).toBe(20); expect(mockSafeSearch).toHaveBeenCalledWith( "notifications", expect.any(Object), 0, // from = (1-1) * 20 20, ); }); it("type 过滤应添加到查询条件", async () => { service = new NotificationsService(createMockDispatcher() as never); mockSafeSearch.mockResolvedValue({ hits: [], total: 0 }); await service.search("user-1", "test", { type: "exam", page: 1, pageSize: 10, }); const queryArg = mockSafeSearch.mock.calls[0][1]; // must 数组应包含 type term const must = (queryArg as { bool: { must: unknown[] } }).bool.must; expect(must).toHaveLength(3); // user_id + multi_match + type }); it("page=2 时 from 应正确计算", async () => { service = new NotificationsService(createMockDispatcher() as never); mockSafeSearch.mockResolvedValue({ hits: [], total: 0 }); await service.search("user-1", "test", { page: 2, pageSize: 15, }); // from = (2-1) * 15 = 15 expect(mockSafeSearch).toHaveBeenCalledWith( "notifications", expect.any(Object), 15, 15, ); }); }); // ---------------------------------------------------------- // recall // ---------------------------------------------------------- describe("recall", () => { it("应撤回通知组并发布 notification.recalled 事件", async () => { service = new NotificationsService(createMockDispatcher() as never); mockRepo.recallByGroup.mockResolvedValue(5); const result = await service.recall("group-1"); expect(result).toBe(5); expect(mockRepo.recallByGroup).toHaveBeenCalledWith("group-1"); expect(mockOutboxPublish).toHaveBeenCalledWith( "notification.recalled", { groupId: "group-1", recalledCount: 5 }, expect.objectContaining({ aggregateType: "Notification", aggregateId: "group-1", }), ); }); it("撤回 0 条时也应正常发布事件", async () => { service = new NotificationsService(createMockDispatcher() as never); mockRepo.recallByGroup.mockResolvedValue(0); const result = await service.recall("empty-group"); expect(result).toBe(0); expect(mockOutboxPublish).toHaveBeenCalledTimes(1); }); }); // ---------------------------------------------------------- // delete // ---------------------------------------------------------- describe("delete", () => { it("应删除 MySQL 记录和 ES 索引", async () => { service = new NotificationsService(createMockDispatcher() as never); await service.delete("notif-1"); expect(mockRepo.deleteById).toHaveBeenCalledWith("notif-1"); expect(mockSafeDelete).toHaveBeenCalledWith("notifications", "notif-1"); }); it("ES 删除失败不应阻断", async () => { service = new NotificationsService(createMockDispatcher() as never); // safeDelete 内部 try/catch,失败时返回 false(不抛出) mockSafeDelete.mockResolvedValue(false); // 不应抛出 await service.delete("notif-1"); expect(mockRepo.deleteById).toHaveBeenCalledWith("notif-1"); }); }); });