feat(msg): announcements 公告模块 + sendBatch 批量优化 + 权限扩展 + nextstep 文档

This commit is contained in:
SpecialX
2026-07-14 15:57:41 +08:00
parent 7dd5c44406
commit fb23c5234e
28 changed files with 3644 additions and 7 deletions

View 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");
});
});
});