import { describe, it, expect, vi, beforeEach } from "vitest"; import type { ChannelSendContext } from "../../src/channels/channel.types.js"; // ============================================================ // Mock 外部依赖 —— 使用 vi.hoisted 确保 mock 变量在 hoisted 的 vi.mock 中可用 // ============================================================ const mocks = vi.hoisted(() => { // Mock 各渠道策略 const mockInAppChannel = { name: "in_app" as const, send: vi.fn(), }; const mockEmailChannel = { name: "email" as const, send: vi.fn(), }; const mockSmsChannel = { name: "sms" as const, send: vi.fn(), }; const mockPushChannel = { name: "push" as const, send: vi.fn(), }; // Mock database insert chain(用于 recordDeliveries) const mockInsertChain = { values: vi.fn(), }; const mockDb = { insert: vi.fn(() => mockInsertChain), }; return { mockInAppChannel, mockEmailChannel, mockSmsChannel, mockPushChannel, mockInsertChain, mockDb, }; }); // Mock cuid2 vi.mock("@paralleldrive/cuid2", () => ({ createId: vi.fn(() => "mock-delivery-id"), })); // Mock database vi.mock("../../src/config/database.js", () => ({ getDb: () => mocks.mockDb, })); // Mock logger vi.mock("../../src/shared/observability/logger.js", () => ({ logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), }, })); vi.mock("../../src/channels/in-app.channel.js", () => ({ inAppChannel: mocks.mockInAppChannel, })); vi.mock("../../src/channels/email.channel.js", () => ({ emailChannel: mocks.mockEmailChannel, })); vi.mock("../../src/channels/sms.channel.js", () => ({ smsChannel: mocks.mockSmsChannel, })); vi.mock("../../src/channels/push.channel.js", () => ({ pushChannel: mocks.mockPushChannel, })); // 导入被测模块(在 mock 之后) import { ChannelDispatcherService } from "../../src/channels/channel-dispatcher.service.js"; // ============================================================ // 辅助 // ============================================================ const { mockInAppChannel, mockEmailChannel, mockSmsChannel, mockPushChannel, mockInsertChain, mockDb, } = mocks; function createContext( overrides: Partial = {}, ): ChannelSendContext { return { notificationId: "notif-1", userId: "user-1", title: "Test", content: "Content", type: "system", metadata: null, ...overrides, }; } // ============================================================ // Tests // ============================================================ describe("ChannelDispatcherService", () => { let dispatcher: ChannelDispatcherService; beforeEach(() => { vi.clearAllMocks(); // 重置 insert chain mockInsertChain.values.mockResolvedValue(undefined); // 重置渠道策略默认行为 mockInAppChannel.send.mockResolvedValue({ channel: "in_app", sent: true, }); mockEmailChannel.send.mockResolvedValue({ channel: "email", sent: false, error: "SMTP not configured", }); mockSmsChannel.send.mockResolvedValue({ channel: "sms", sent: false, error: "SMS not configured", }); mockPushChannel.send.mockResolvedValue({ channel: "push", sent: true, }); dispatcher = new ChannelDispatcherService(); }); // ---------------------------------------------------------- // dispatch —— 基本行为 // ---------------------------------------------------------- describe("dispatch", () => { it("无偏好时应只发 in_app(默认渠道)", async () => { const ctx = createContext(); const results = await dispatcher.dispatch(ctx, null); expect(results).toHaveLength(1); expect(results[0].channel).toBe("in_app"); expect(mockInAppChannel.send).toHaveBeenCalledTimes(1); expect(mockEmailChannel.send).not.toHaveBeenCalled(); }); it("空数组偏好时应只发 in_app", async () => { const ctx = createContext(); const results = await dispatcher.dispatch(ctx, []); expect(results).toHaveLength(1); expect(results[0].channel).toBe("in_app"); }); it("in_app 应总是包含在发送渠道中(即使用户偏好不含 in_app)", async () => { const ctx = createContext(); const results = await dispatcher.dispatch(ctx, ["email", "sms"]); const channels = results.map((r) => r.channel); expect(channels).toContain("in_app"); expect(channels).toContain("email"); expect(channels).toContain("sms"); expect(mockInAppChannel.send).toHaveBeenCalledTimes(1); expect(mockEmailChannel.send).toHaveBeenCalledTimes(1); expect(mockSmsChannel.send).toHaveBeenCalledTimes(1); }); it("应并行发送所有渠道", async () => { const ctx = createContext(); // 让每个 channel send 有微小延迟,验证并行 const callOrder: string[] = []; mockInAppChannel.send.mockImplementation(async () => { await new Promise((r) => setTimeout(r, 50)); callOrder.push("in_app-done"); return { channel: "in_app", sent: true }; }); mockEmailChannel.send.mockImplementation(async () => { await new Promise((r) => setTimeout(r, 10)); callOrder.push("email-done"); return { channel: "email", sent: true }; }); const start = Date.now(); const results = await dispatcher.dispatch(ctx, ["email"]); const elapsed = Date.now() - start; // 并行执行总时间应小于串行(50+10=60ms),约 50ms 左右 expect(elapsed).toBeLessThan(80); expect(results).toHaveLength(2); }); it("多个渠道时应返回所有渠道的结果", async () => { const ctx = createContext(); const results = await dispatcher.dispatch(ctx, ["email", "sms", "push"]); expect(results).toHaveLength(4); // in_app + email + sms + push const channels = results.map((r) => r.channel).sort(); expect(channels).toEqual(["email", "in_app", "push", "sms"]); }); }); // ---------------------------------------------------------- // dispatch —— 软失败 // ---------------------------------------------------------- describe("dispatch 软失败", () => { it("渠道 send reject 时应转为 failed 结果,不抛出", async () => { const ctx = createContext(); mockEmailChannel.send.mockRejectedValue(new Error("Network timeout")); const results = await dispatcher.dispatch(ctx, ["email"]); const emailResult = results.find((r) => r.channel === "email"); expect(emailResult).toBeDefined(); expect(emailResult!.sent).toBe(false); expect(emailResult!.error).toBe("Network timeout"); }); it("渠道 send reject 非 Error 对象时应转字符串", async () => { const ctx = createContext(); mockEmailChannel.send.mockRejectedValue("string error"); const results = await dispatcher.dispatch(ctx, ["email"]); const emailResult = results.find((r) => r.channel === "email"); expect(emailResult!.sent).toBe(false); expect(emailResult!.error).toBe("string error"); }); it("in_app 失败时仍应返回结果", async () => { const ctx = createContext(); mockInAppChannel.send.mockResolvedValue({ channel: "in_app", sent: false, error: "User offline", }); const results = await dispatcher.dispatch(ctx, null); expect(results[0].channel).toBe("in_app"); expect(results[0].sent).toBe(false); }); }); // ---------------------------------------------------------- // dispatch —— recordDeliveries // ---------------------------------------------------------- describe("recordDeliveries", () => { it("应将投递结果异步写入 msg_notification_deliveries 表", async () => { const ctx = createContext(); await dispatcher.dispatch(ctx, null); // recordDeliveries 是 void 异步调用,等待微任务 await new Promise((r) => setTimeout(r, 10)); expect(mockDb.insert).toHaveBeenCalledTimes(1); const rowsArg = mockInsertChain.values.mock.calls[0][0]; expect(rowsArg).toHaveLength(1); expect(rowsArg[0].notificationId).toBe("notif-1"); expect(rowsArg[0].channel).toBe("in_app"); expect(rowsArg[0].id).toBe("mock-delivery-id"); }); it("多个渠道时应写入多行投递记录", async () => { const ctx = createContext(); await dispatcher.dispatch(ctx, ["email", "push"]); await new Promise((r) => setTimeout(r, 10)); const rowsArg = mockInsertChain.values.mock.calls[0][0]; expect(rowsArg).toHaveLength(3); // in_app + email + push }); it("成功投递 status=sent,失败投递 status=failed", async () => { const ctx = createContext(); mockEmailChannel.send.mockResolvedValue({ channel: "email", sent: false, error: "SMTP error", }); await dispatcher.dispatch(ctx, ["email"]); await new Promise((r) => setTimeout(r, 10)); const rowsArg = mockInsertChain.values.mock.calls[0][0]; const inAppRow = rowsArg.find( (r: { channel: string }) => r.channel === "in_app", ); const emailRow = rowsArg.find( (r: { channel: string }) => r.channel === "email", ); expect(inAppRow.status).toBe("sent"); expect(inAppRow.deliveredAt).toBeInstanceOf(Date); expect(emailRow.status).toBe("failed"); expect(emailRow.deliveredAt).toBeNull(); expect(emailRow.lastError).toBe("SMTP error"); }); it("recordDeliveries 失败不应阻断 dispatch 返回(软失败)", async () => { const ctx = createContext(); mockInsertChain.values.mockRejectedValue(new Error("DB down")); // dispatch 不应抛出 const results = await dispatcher.dispatch(ctx, null); expect(results).toHaveLength(1); expect(results[0].channel).toBe("in_app"); }); }); // ---------------------------------------------------------- // resolveChannels 逻辑(通过 dispatch 间接测试) // ---------------------------------------------------------- describe("resolveChannels 逻辑", () => { it("null 偏好 → 默认 [in_app]", async () => { const results = await dispatcher.dispatch(createContext(), null); expect(results).toHaveLength(1); expect(results[0].channel).toBe("in_app"); }); it("空数组偏好 → 默认 [in_app]", async () => { const results = await dispatcher.dispatch(createContext(), []); expect(results).toHaveLength(1); }); it("偏好含 in_app → 不重复", async () => { const results = await dispatcher.dispatch(createContext(), [ "in_app", "email", ]); expect(results).toHaveLength(2); // in_app 不重复 }); it("偏好不含 in_app → 自动添加 in_app", async () => { const results = await dispatcher.dispatch(createContext(), ["email"]); const channels = results.map((r) => r.channel); expect(channels).toContain("in_app"); expect(channels).toContain("email"); }); it("wechat 渠道应返回未实现错误", async () => { const results = await dispatcher.dispatch(createContext(), ["wechat"]); const wechatResult = results.find((r) => r.channel === "wechat"); expect(wechatResult).toBeDefined(); expect(wechatResult!.sent).toBe(false); expect(wechatResult!.error).toContain("not implemented"); }); }); });