import { beforeEach, describe, expect, it, vi } from "vitest" const mocks = vi.hoisted(() => ({ getNotificationPreferences: vi.fn(), getUserContactInfo: vi.fn(), logNotificationSendBatch: vi.fn(), createNotification: vi.fn(), inAppSend: vi.fn(), smsSend: vi.fn(), wechatSend: vi.fn(), emailSend: vi.fn(), })) vi.mock("./data-access", () => ({ getUserContactInfo: mocks.getUserContactInfo, logNotificationSendBatch: mocks.logNotificationSendBatch, createNotification: mocks.createNotification, })) vi.mock("./preferences", () => ({ getNotificationPreferences: mocks.getNotificationPreferences, })) vi.mock("./channels/sms-channel", () => ({ createSmsSender: () => ({ channel: "sms", send: mocks.smsSend, sendBatch: vi.fn(), }), })) vi.mock("./channels/wechat-channel", () => ({ createWechatSender: () => ({ channel: "wechat", send: mocks.wechatSend, sendBatch: vi.fn(), }), })) vi.mock("./channels/email-channel", () => ({ createEmailSender: () => ({ channel: "email", send: mocks.emailSend, sendBatch: vi.fn(), }), })) vi.mock("./channels/in-app-channel", () => ({ createInAppSender: () => ({ channel: "in_app", send: mocks.inAppSend, sendBatch: vi.fn(), }), })) import { sendNotification } from "./dispatcher" import type { NotificationPayload } from "./types" describe("sendNotification", () => { beforeEach(() => { // mockReset (from vitest config) clears implementations before beforeEach. // Re-establish channel send implementations so the cached sender registry // (module-level singleton in dispatcher.ts) keeps working across tests. mocks.inAppSend.mockImplementation(async (payload: NotificationPayload) => { const id = await mocks.createNotification({ userId: payload.userId, type: "message", title: payload.title, content: payload.content, link: payload.actionUrl ?? null, }) return { channel: "in_app" as const, success: true, messageId: id, sentAt: new Date(), } }) mocks.smsSend.mockResolvedValue({ channel: "sms", success: true, sentAt: new Date(), }) mocks.wechatSend.mockResolvedValue({ channel: "wechat", success: true, sentAt: new Date(), }) mocks.emailSend.mockResolvedValue({ channel: "email", success: true, sentAt: new Date(), }) }) it("should select in_app channel when pushEnabled is true and no contact info", async () => { mocks.getNotificationPreferences.mockResolvedValue({ smsEnabled: false, emailEnabled: false, pushEnabled: true, }) mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1" }) mocks.createNotification.mockResolvedValue("notif-1") mocks.logNotificationSendBatch.mockResolvedValue(undefined) const payload: NotificationPayload = { userId: "user-1", title: "Test notification", content: "Test content", type: "info", } const results = await sendNotification(payload) expect(results).toHaveLength(1) expect(results[0].channel).toBe("in_app") expect(results[0].success).toBe(true) expect(mocks.createNotification).toHaveBeenCalledWith( expect.objectContaining({ userId: "user-1", title: "Test notification", }) ) }) it("should select sms channel when smsEnabled and phone provided", async () => { mocks.getNotificationPreferences.mockResolvedValue({ smsEnabled: true, emailEnabled: false, pushEnabled: true, }) mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1", phone: "13800138000" }) mocks.createNotification.mockResolvedValue("notif-1") mocks.logNotificationSendBatch.mockResolvedValue(undefined) const payload: NotificationPayload = { userId: "user-1", title: "Test", content: "Content", type: "info", } const results = await sendNotification(payload) expect(results).toHaveLength(2) const channels = results.map((r) => r.channel) expect(channels).toContain("in_app") expect(channels).toContain("sms") }) it("should select email channel when emailEnabled and email provided", async () => { mocks.getNotificationPreferences.mockResolvedValue({ smsEnabled: false, emailEnabled: true, pushEnabled: true, }) mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1", email: "test@example.com" }) mocks.createNotification.mockResolvedValue("notif-1") mocks.logNotificationSendBatch.mockResolvedValue(undefined) const payload: NotificationPayload = { userId: "user-1", title: "Test", content: "Content", type: "info", } const results = await sendNotification(payload) expect(results).toHaveLength(2) const channels = results.map((r) => r.channel) expect(channels).toContain("in_app") expect(channels).toContain("email") }) it("should fallback to in_app when all channels disabled", async () => { mocks.getNotificationPreferences.mockResolvedValue({ smsEnabled: false, emailEnabled: false, pushEnabled: false, }) mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1" }) mocks.createNotification.mockResolvedValue("notif-1") mocks.logNotificationSendBatch.mockResolvedValue(undefined) const payload: NotificationPayload = { userId: "user-1", title: "Test", content: "Content", type: "info", } const results = await sendNotification(payload) // pushEnabled false 时,兜底逻辑应至少发 in_app expect(results).toHaveLength(1) expect(results[0].channel).toBe("in_app") }) it("should select wechat channel when pushEnabled and wechatOpenId provided", async () => { mocks.getNotificationPreferences.mockResolvedValue({ smsEnabled: false, emailEnabled: false, pushEnabled: true, }) mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1", wechatOpenId: "wx-open-id" }) mocks.createNotification.mockResolvedValue("notif-1") mocks.logNotificationSendBatch.mockResolvedValue(undefined) const payload: NotificationPayload = { userId: "user-1", title: "Test", content: "Content", type: "info", } const results = await sendNotification(payload) expect(results).toHaveLength(2) const channels = results.map((r) => r.channel) expect(channels).toContain("in_app") expect(channels).toContain("wechat") }) it("should call logNotificationSendBatch with results", async () => { mocks.getNotificationPreferences.mockResolvedValue({ smsEnabled: false, emailEnabled: false, pushEnabled: true, }) mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1" }) mocks.createNotification.mockResolvedValue("notif-1") mocks.logNotificationSendBatch.mockResolvedValue(undefined) const payload: NotificationPayload = { userId: "user-1", title: "Test", content: "Content", type: "info", } await sendNotification(payload) expect(mocks.logNotificationSendBatch).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ channel: "in_app", success: true }), ]), { userId: "user-1", title: "Test" } ) }) })