Files
Edu/services/msg/test/unit/channel-dispatcher.service.spec.ts

359 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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> = {},
): 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");
});
});
});