359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
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");
|
||
});
|
||
});
|
||
});
|