feat(msg): announcements 公告模块 + sendBatch 批量优化 + 权限扩展 + nextstep 文档
This commit is contained in:
358
services/msg/test/unit/channel-dispatcher.service.spec.ts
Normal file
358
services/msg/test/unit/channel-dispatcher.service.spec.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user