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 @@
{"title":"测试公告","content":"这是测试内容","authorId":"sys-admin","targetAudience":"all"}

View File

@@ -0,0 +1 @@
{"items":[{"userId":"batch-idem-1","type":"system","title":"幂等1","content":"测试","eventId":"evt-batch-idem-001"}],"groupId":"batch-idem-group"}

View File

@@ -0,0 +1 @@
{"items":[{"userId":"batch-mix-1","type":"system","title":"混合1","content":"有eventId","eventId":"evt-mix-001"},{"userId":"batch-mix-2","type":"exam","title":"混合2","content":"无eventId"},{"userId":"batch-mix-3","type":"homework","title":"混合3","content":"有eventId","eventId":"evt-mix-003"}],"groupId":"batch-mix-group"}

View File

@@ -0,0 +1 @@
{"items":[{"userId":"batch-noid-1","type":"system","title":"无groupId","content":"测试"}]}

View File

@@ -0,0 +1 @@
{"items":[{"userId":"batch-test-1","type":"system","title":"批量测试1","content":"第一条","senderId":"sys-admin"},{"userId":"batch-test-2","type":"exam","title":"批量测试2","content":"第二条","senderId":"sys-admin"},{"userId":"batch-test-3","type":"homework","title":"批量测试3","content":"第三条","senderId":"sys-admin"}],"groupId":"batch-group-001"}

View File

@@ -0,0 +1 @@
{"userId":"student-001"}

View File

@@ -0,0 +1 @@
{"userId":"student-001"}

View File

@@ -0,0 +1 @@
{"userId":"student-001","type":"system","title":"测试通知","content":"这是一条测试通知","senderId":"sys-admin"}

View File

@@ -0,0 +1 @@
{"userId":"student-001","preferences":[{"type":"system","channels":["in_app","email"],"enabled":true},{"type":"exam","channels":["in_app"],"enabled":true}]}

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

View File

@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// ============================================================
// Mock 外部依赖
// ============================================================
// Mock cuid2
vi.mock("@paralleldrive/cuid2", () => ({
createId: vi.fn(() => "mock-idempotency-key"),
}));
// Mock Redis client —— getRedis 返回模拟 redis 或 null
const mockRedis = {
set: vi.fn(),
exists: vi.fn(),
};
let redisReturnValue: unknown = null;
vi.mock("../../src/shared/redis/redis.client.js", () => ({
getRedis: () => redisReturnValue,
}));
// Mock database —— getDb 返回模拟 db
const mockInsertChain = {
values: vi.fn().mockResolvedValue(undefined),
};
const mockSelectChain = {
from: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
limit: vi.fn().mockResolvedValue([]),
};
const mockDb = {
insert: vi.fn(() => mockInsertChain),
select: vi.fn(() => mockSelectChain),
};
vi.mock("../../src/config/database.js", () => ({
getDb: () => mockDb,
}));
// Mock logger
vi.mock("../../src/shared/observability/logger.js", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// 导入被测模块(在 mock 之后)
import {
checkAndMark,
isProcessed,
generateIdempotencyKey,
} from "../../src/shared/redis/idempotency.guard.js";
// ============================================================
// Tests
// ============================================================
describe("idempotency.guard", () => {
beforeEach(() => {
vi.clearAllMocks();
redisReturnValue = null;
mockInsertChain.values.mockResolvedValue(undefined);
// 重置 select chain
mockSelectChain.from.mockReturnThis();
mockSelectChain.where.mockReturnThis();
mockSelectChain.limit.mockResolvedValue([]);
});
// ----------------------------------------------------------
// checkAndMark
// ----------------------------------------------------------
describe("checkAndMark", () => {
it("Redis SETNX 成功(返回 OK→ isFirst=true", async () => {
redisReturnValue = mockRedis;
mockRedis.set.mockResolvedValue("OK");
const result = await checkAndMark("evt-1");
expect(result.isFirst).toBe(true);
expect(result.key).toBe("msg:processed:evt-1");
expect(mockRedis.set).toHaveBeenCalledWith(
"msg:processed:evt-1",
"1",
"EX",
7 * 24 * 60 * 60,
"NX",
);
// 不应走 DB 降级
expect(mockDb.insert).not.toHaveBeenCalled();
});
it("Redis SETNX 失败(返回 null→ isFirst=false已处理", async () => {
redisReturnValue = mockRedis;
mockRedis.set.mockResolvedValue(null);
const result = await checkAndMark("evt-1");
expect(result.isFirst).toBe(false);
expect(result.key).toBe("msg:processed:evt-1");
// 不应走 DB 降级
expect(mockDb.insert).not.toHaveBeenCalled();
});
it("Redis 异常 → 降级到 DB 插入,成功时 isFirst=true", async () => {
redisReturnValue = mockRedis;
mockRedis.set.mockRejectedValue(new Error("Redis connection lost"));
mockInsertChain.values.mockResolvedValue(undefined);
const result = await checkAndMark("evt-1", "edu.notification.sent");
expect(result.isFirst).toBe(true);
expect(result.key).toBe("msg:processed:evt-1");
// 应走 DB 降级
expect(mockDb.insert).toHaveBeenCalledTimes(1);
expect(mockInsertChain.values).toHaveBeenCalledWith({
eventId: "evt-1",
topic: "edu.notification.sent",
});
});
it("Redis 异常 + DB 唯一索引冲突 → isFirst=false", async () => {
redisReturnValue = mockRedis;
mockRedis.set.mockRejectedValue(new Error("Redis down"));
// DB 插入冲突
mockInsertChain.values.mockRejectedValue(new Error("Duplicate entry"));
const result = await checkAndMark("evt-1");
expect(result.isFirst).toBe(false);
expect(mockDb.insert).toHaveBeenCalledTimes(1);
});
it("Redis 不可用getRedis 返回 null→ 直接走 DB 降级", async () => {
redisReturnValue = null;
mockInsertChain.values.mockResolvedValue(undefined);
const result = await checkAndMark("evt-1", "edu.notification.sent");
expect(result.isFirst).toBe(true);
expect(mockRedis.set).not.toHaveBeenCalled();
expect(mockDb.insert).toHaveBeenCalledTimes(1);
});
it("Redis 不可用 + DB 冲突 → isFirst=false", async () => {
redisReturnValue = null;
mockInsertChain.values.mockRejectedValue(new Error("Duplicate"));
const result = await checkAndMark("evt-1");
expect(result.isFirst).toBe(false);
});
it("无 topic 参数时 DB 降级应使用 'unknown'", async () => {
redisReturnValue = null;
mockInsertChain.values.mockResolvedValue(undefined);
await checkAndMark("evt-1");
expect(mockInsertChain.values).toHaveBeenCalledWith({
eventId: "evt-1",
topic: "unknown",
});
});
it("key 格式应为 msg:processed:{eventId}", async () => {
redisReturnValue = mockRedis;
mockRedis.set.mockResolvedValue("OK");
const result = await checkAndMark("my-event-123");
expect(result.key).toBe("msg:processed:my-event-123");
});
});
// ----------------------------------------------------------
// isProcessed
// ----------------------------------------------------------
describe("isProcessed", () => {
it("Redis exists=1 → true已处理", async () => {
redisReturnValue = mockRedis;
mockRedis.exists.mockResolvedValue(1);
const result = await isProcessed("evt-1");
expect(result).toBe(true);
expect(mockRedis.exists).toHaveBeenCalledWith("msg:processed:evt-1");
});
it("Redis exists=0 → false未处理", async () => {
redisReturnValue = mockRedis;
mockRedis.exists.mockResolvedValue(0);
const result = await isProcessed("evt-1");
expect(result).toBe(false);
});
it("Redis 异常 → 降级到 DB 查询", async () => {
redisReturnValue = mockRedis;
mockRedis.exists.mockRejectedValue(new Error("Redis error"));
mockSelectChain.limit.mockResolvedValue([{ eventId: "evt-1" }]);
const result = await isProcessed("evt-1");
expect(result).toBe(true);
expect(mockDb.select).toHaveBeenCalledTimes(1);
});
it("Redis 异常 + DB 无记录 → false", async () => {
redisReturnValue = mockRedis;
mockRedis.exists.mockRejectedValue(new Error("Redis error"));
mockSelectChain.limit.mockResolvedValue([]);
const result = await isProcessed("evt-1");
expect(result).toBe(false);
});
it("Redis 不可用 → 直接走 DB 查询", async () => {
redisReturnValue = null;
mockSelectChain.limit.mockResolvedValue([{ eventId: "evt-1" }]);
const result = await isProcessed("evt-1");
expect(result).toBe(true);
expect(mockRedis.exists).not.toHaveBeenCalled();
expect(mockDb.select).toHaveBeenCalledTimes(1);
});
it("Redis 不可用 + DB 无记录 → false", async () => {
redisReturnValue = null;
mockSelectChain.limit.mockResolvedValue([]);
const result = await isProcessed("evt-1");
expect(result).toBe(false);
});
});
// ----------------------------------------------------------
// generateIdempotencyKey
// ----------------------------------------------------------
describe("generateIdempotencyKey", () => {
it("应返回一个字符串", () => {
const key = generateIdempotencyKey();
expect(typeof key).toBe("string");
expect(key.length).toBeGreaterThan(0);
});
it("应使用 cuid2 生成", () => {
const key = generateIdempotencyKey();
// createId 被 mock 为返回 "mock-idempotency-key"
expect(key).toBe("mock-idempotency-key");
});
});
});

View File

@@ -0,0 +1,473 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Mock } from "vitest";
// ============================================================
// Mock 外部依赖
// ============================================================
// Mock database —— getDb 返回模拟的 db 对象
const mockDb = {
insert: vi.fn(),
select: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
vi.mock("../../src/config/database.js", () => ({
getDb: () => mockDb,
}));
// Mock logger避免 pino 初始化副作用)
vi.mock("../../src/shared/observability/logger.js", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// 导入被测模块(在 mock 之后)
import {
insertNotification,
insertNotifications,
findById,
findByEventId,
listByUser,
getUnreadCount,
markAsRead,
batchMarkAsRead,
markAllAsRead,
recallByGroup,
deleteById,
} from "../../src/notifications/notifications.repository.js";
// ============================================================
// 辅助:创建 drizzle 链式 mock
// ============================================================
/**
* 创建一个可链式调用且可 await 的 mock 对象。
* drizzle 查询构建器方法from/where/limit/offset/orderBy/values/set
* 全部返回链本身await 时解析为 resolveValue。
*/
function createChain(resolveValue: unknown) {
const chain: Record<string, unknown> = {
then(resolve: (v: unknown) => void, reject?: (e: unknown) => void) {
return Promise.resolve(resolveValue).then(resolve, reject);
},
};
for (const method of [
"from",
"where",
"limit",
"offset",
"orderBy",
"values",
"set",
]) {
chain[method] = vi.fn().mockReturnValue(chain);
}
return chain;
}
// ============================================================
// Tests
// ============================================================
describe("notifications.repository", () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ----------------------------------------------------------
// insertNotification
// ----------------------------------------------------------
describe("insertNotification", () => {
it("应插入通知行并返回该行", async () => {
const chain = createChain(undefined);
(mockDb.insert as Mock).mockReturnValue(chain);
const row = {
id: "notif-1",
userId: "user-1",
type: "system" as const,
title: "Test",
content: "Content",
channel: "in_app" as const,
isRead: false,
status: "pending" as const,
metadata: null,
relatedEntityType: null,
relatedEntityId: null,
groupId: null,
senderId: null,
templateId: null,
eventId: null,
};
const result = await insertNotification(row);
expect(mockDb.insert).toHaveBeenCalledTimes(1);
expect(chain.values).toHaveBeenCalledWith(row);
expect(result).toEqual(row);
});
});
// ----------------------------------------------------------
// insertNotifications (batch)
// ----------------------------------------------------------
describe("insertNotifications", () => {
it("应批量插入多行通知", async () => {
const chain = createChain(undefined);
(mockDb.insert as Mock).mockReturnValue(chain);
const rows = [
{
id: "notif-1",
userId: "user-1",
type: "system" as const,
title: "T1",
content: "C1",
channel: "in_app" as const,
isRead: false,
status: "pending" as const,
metadata: null,
relatedEntityType: null,
relatedEntityId: null,
groupId: null,
senderId: null,
templateId: null,
eventId: null,
},
{
id: "notif-2",
userId: "user-2",
type: "exam" as const,
title: "T2",
content: "C2",
channel: "in_app" as const,
isRead: false,
status: "pending" as const,
metadata: null,
relatedEntityType: null,
relatedEntityId: null,
groupId: null,
senderId: null,
templateId: null,
eventId: null,
},
];
await insertNotifications(rows);
expect(mockDb.insert).toHaveBeenCalledTimes(1);
expect(chain.values).toHaveBeenCalledWith(rows);
});
it("空数组应直接返回,不调用 db", async () => {
await insertNotifications([]);
expect(mockDb.insert).not.toHaveBeenCalled();
});
});
// ----------------------------------------------------------
// findById
// ----------------------------------------------------------
describe("findById", () => {
it("应根据 id 查询并返回通知行", async () => {
const mockRow = { id: "notif-1", userId: "user-1", title: "Test" };
const chain = createChain([mockRow]);
(mockDb.select as Mock).mockReturnValue(chain);
const result = await findById("notif-1");
expect(mockDb.select).toHaveBeenCalledTimes(1);
expect(chain.from).toHaveBeenCalledTimes(1);
expect(chain.where).toHaveBeenCalledTimes(1);
expect(chain.limit).toHaveBeenCalledWith(1);
expect(result).toEqual(mockRow);
});
it("未找到时应返回 undefined", async () => {
const chain = createChain([]);
(mockDb.select as Mock).mockReturnValue(chain);
const result = await findById("not-exist");
expect(result).toBeUndefined();
});
});
// ----------------------------------------------------------
// findByEventId
// ----------------------------------------------------------
describe("findByEventId", () => {
it("应根据 eventId 查询并返回通知行", async () => {
const mockRow = { id: "notif-1", eventId: "evt-1", status: "sent" };
const chain = createChain([mockRow]);
(mockDb.select as Mock).mockReturnValue(chain);
const result = await findByEventId("evt-1");
expect(mockDb.select).toHaveBeenCalledTimes(1);
expect(chain.from).toHaveBeenCalledTimes(1);
expect(chain.where).toHaveBeenCalledTimes(1);
expect(chain.limit).toHaveBeenCalledWith(1);
expect(result).toEqual(mockRow);
});
it("未找到时应返回 undefined", async () => {
const chain = createChain([]);
(mockDb.select as Mock).mockReturnValue(chain);
const result = await findByEventId("not-exist");
expect(result).toBeUndefined();
});
});
// ----------------------------------------------------------
// listByUser
// ----------------------------------------------------------
describe("listByUser", () => {
it("应分页查询用户通知并返回 items + total", async () => {
const mockItems = [
{ id: "notif-1", userId: "user-1" },
{ id: "notif-2", userId: "user-1" },
];
const mockCountRow = [{ value: 25 }];
// 第一次 select → items 查询, 第二次 select → count 查询
(mockDb.select as Mock)
.mockReturnValueOnce(createChain(mockItems))
.mockReturnValueOnce(createChain(mockCountRow));
const result = await listByUser("user-1", {
page: 2,
pageSize: 10,
});
expect(result.items).toEqual(mockItems);
expect(result.total).toBe(25);
expect(mockDb.select).toHaveBeenCalledTimes(2);
});
it("onlyUnread=true 应添加未读过滤条件", async () => {
const itemsChain = createChain([]);
const countChain = createChain([{ value: 0 }]);
(mockDb.select as Mock)
.mockReturnValueOnce(itemsChain)
.mockReturnValueOnce(countChain);
await listByUser("user-1", {
onlyUnread: true,
page: 1,
pageSize: 20,
});
// 两次查询都调用 where
expect(itemsChain.where).toHaveBeenCalledTimes(1);
expect(countChain.where).toHaveBeenCalledTimes(1);
});
it("type 过滤应生效", async () => {
const itemsChain = createChain([]);
const countChain = createChain([{ value: 0 }]);
(mockDb.select as Mock)
.mockReturnValueOnce(itemsChain)
.mockReturnValueOnce(countChain);
await listByUser("user-1", {
type: "exam",
page: 1,
pageSize: 20,
});
expect(itemsChain.where).toHaveBeenCalledTimes(1);
expect(countChain.where).toHaveBeenCalledTimes(1);
});
it("count 为 undefined 时 total 应为 0", async () => {
const itemsChain = createChain([]);
const countChain = createChain([undefined]);
(mockDb.select as Mock)
.mockReturnValueOnce(itemsChain)
.mockReturnValueOnce(countChain);
const result = await listByUser("user-1", {
page: 1,
pageSize: 10,
});
expect(result.total).toBe(0);
});
it("offset 应根据 page 和 pageSize 计算", async () => {
const itemsChain = createChain([]);
const countChain = createChain([{ value: 0 }]);
(mockDb.select as Mock)
.mockReturnValueOnce(itemsChain)
.mockReturnValueOnce(countChain);
await listByUser("user-1", {
page: 3,
pageSize: 15,
});
// offset = (3-1) * 15 = 30
expect(itemsChain.offset).toHaveBeenCalledWith(30);
});
});
// ----------------------------------------------------------
// getUnreadCount
// ----------------------------------------------------------
describe("getUnreadCount", () => {
it("应返回用户未读通知数", async () => {
const chain = createChain([{ value: 7 }]);
(mockDb.select as Mock).mockReturnValue(chain);
const result = await getUnreadCount("user-1");
expect(result).toBe(7);
expect(mockDb.select).toHaveBeenCalledTimes(1);
expect(chain.from).toHaveBeenCalledTimes(1);
expect(chain.where).toHaveBeenCalledTimes(1);
});
it("无未读时应返回 0", async () => {
const chain = createChain([undefined]);
(mockDb.select as Mock).mockReturnValue(chain);
const result = await getUnreadCount("user-1");
expect(result).toBe(0);
});
});
// ----------------------------------------------------------
// markAsRead
// ----------------------------------------------------------
describe("markAsRead", () => {
it("应将指定通知标记为已读", async () => {
const chain = createChain(undefined);
(mockDb.update as Mock).mockReturnValue(chain);
await markAsRead("notif-1", "user-1");
expect(mockDb.update).toHaveBeenCalledTimes(1);
expect(chain.set).toHaveBeenCalledTimes(1);
expect(chain.where).toHaveBeenCalledTimes(1);
// 验证 set 的参数包含 isRead: true
const setArg = (chain.set as Mock).mock.calls[0][0];
expect(setArg.isRead).toBe(true);
expect(setArg.status).toBe("read");
expect(setArg.readAt).toBeInstanceOf(Date);
});
});
// ----------------------------------------------------------
// batchMarkAsRead
// ----------------------------------------------------------
describe("batchMarkAsRead", () => {
it("应批量标记多条通知为已读", async () => {
const chain = createChain(undefined);
(mockDb.update as Mock).mockReturnValue(chain);
await batchMarkAsRead(["notif-1", "notif-2", "notif-3"], "user-1");
expect(mockDb.update).toHaveBeenCalledTimes(1);
expect(chain.set).toHaveBeenCalledTimes(1);
expect(chain.where).toHaveBeenCalledTimes(1);
const setArg = (chain.set as Mock).mock.calls[0][0];
expect(setArg.isRead).toBe(true);
expect(setArg.status).toBe("read");
});
});
// ----------------------------------------------------------
// markAllAsRead
// ----------------------------------------------------------
describe("markAllAsRead", () => {
it("应将用户所有未读通知标记为已读,返回受影响行数", async () => {
const chain = createChain({ affectedRows: 5 });
(mockDb.update as Mock).mockReturnValue(chain);
const result = await markAllAsRead("user-1");
expect(result).toBe(5);
expect(mockDb.update).toHaveBeenCalledTimes(1);
expect(chain.where).toHaveBeenCalledTimes(1);
});
it("before 参数应添加时间过滤条件", async () => {
const chain = createChain({ affectedRows: 3 });
(mockDb.update as Mock).mockReturnValue(chain);
const before = Date.now();
const result = await markAllAsRead("user-1", before);
expect(result).toBe(3);
expect(chain.where).toHaveBeenCalledTimes(1);
});
it("无受影响行时应返回 0", async () => {
const chain = createChain({});
(mockDb.update as Mock).mockReturnValue(chain);
const result = await markAllAsRead("user-1");
expect(result).toBe(0);
});
});
// ----------------------------------------------------------
// recallByGroup
// ----------------------------------------------------------
describe("recallByGroup", () => {
it("应按 groupId 撤回通知,返回受影响行数", async () => {
const chain = createChain({ affectedRows: 10 });
(mockDb.update as Mock).mockReturnValue(chain);
const result = await recallByGroup("group-1");
expect(result).toBe(10);
expect(mockDb.update).toHaveBeenCalledTimes(1);
const setArg = (chain.set as Mock).mock.calls[0][0];
expect(setArg.status).toBe("recalled");
});
it("无匹配行时应返回 0", async () => {
const chain = createChain({});
(mockDb.update as Mock).mockReturnValue(chain);
const result = await recallByGroup("empty-group");
expect(result).toBe(0);
});
});
// ----------------------------------------------------------
// deleteById
// ----------------------------------------------------------
describe("deleteById", () => {
it("应根据 id 删除通知", async () => {
const chain = createChain(undefined);
(mockDb.delete as Mock).mockReturnValue(chain);
await deleteById("notif-1");
expect(mockDb.delete).toHaveBeenCalledTimes(1);
expect(chain.where).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,796 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { Mock } from "vitest";
import type { ChannelSendResult } from "../../src/channels/channel.types.js";
// ============================================================
// Mock 外部依赖 —— 使用 vi.hoisted 确保 mock 变量在 hoisted 的 vi.mock 中可用
// ============================================================
const mocks = vi.hoisted(() => {
// Mock database用于 getUserChannels / updateStatus
const mockDb = {
select: vi.fn(),
update: vi.fn(),
};
// Mock elasticsearch
const mockSafeIndex = vi.fn();
const mockSafeSearch = vi.fn();
const mockSafeDelete = vi.fn();
// Mock outbox publish
const mockOutboxPublish = vi.fn();
// Mock repository
const mockRepo = {
insertNotification: vi.fn(),
insertNotifications: vi.fn(),
findById: vi.fn(),
findByEventId: vi.fn(),
findExistingEventIds: vi.fn(),
listByUser: vi.fn(),
getUnreadCount: vi.fn(),
markAsRead: vi.fn(),
batchMarkAsRead: vi.fn(),
markAllAsRead: vi.fn(),
recallByGroup: vi.fn(),
deleteById: vi.fn(),
};
return {
mockDb,
mockSafeIndex,
mockSafeSearch,
mockSafeDelete,
mockOutboxPublish,
mockRepo,
};
});
// Mock cuid2 —— 固定 ID 便于断言
vi.mock("@paralleldrive/cuid2", () => ({
createId: vi.fn(() => "mock-cuid-id"),
}));
vi.mock("../../src/config/database.js", () => ({
getDb: () => mocks.mockDb,
}));
vi.mock("../../src/config/elasticsearch.js", () => ({
safeIndex: mocks.mockSafeIndex,
safeSearch: mocks.mockSafeSearch,
safeDelete: mocks.mockSafeDelete,
}));
vi.mock("../../src/shared/outbox/outbox.service.js", () => ({
publish: mocks.mockOutboxPublish,
}));
vi.mock("../../src/shared/observability/logger.js", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock(
"../../src/notifications/notifications.repository.js",
() => mocks.mockRepo,
);
// 导入被测模块(在 mock 之后)
import { NotificationsService } from "../../src/notifications/notifications.service.js";
// ============================================================
// 辅助
// ============================================================
const {
mockDb,
mockSafeIndex,
mockSafeSearch,
mockSafeDelete,
mockOutboxPublish,
mockRepo,
} = mocks;
function createMockDispatcher(
results: ChannelSendResult[] = [{ channel: "in_app", sent: true }],
) {
return {
dispatch: vi.fn().mockResolvedValue(results),
};
}
/** 创建 select 链式 mock用于 getUserChannels */
function createSelectChain(resolveValue: unknown) {
const chain: Record<string, unknown> = {
then(resolve: (v: unknown) => void, reject?: (e: unknown) => void) {
return Promise.resolve(resolveValue).then(resolve, reject);
},
};
for (const method of ["from", "where", "limit"]) {
chain[method] = vi.fn().mockReturnValue(chain);
}
return chain;
}
/** 创建 update 链式 mock用于 updateStatus */
function createUpdateChain(resolveValue: unknown) {
const chain: Record<string, unknown> = {
then(resolve: (v: unknown) => void, reject?: (e: unknown) => void) {
return Promise.resolve(resolveValue).then(resolve, reject);
},
};
chain.set = vi.fn().mockReturnValue(chain);
chain.where = vi.fn().mockReturnValue(chain);
return chain;
}
// ============================================================
// Tests
// ============================================================
describe("NotificationsService", () => {
let service: NotificationsService;
beforeEach(() => {
vi.clearAllMocks();
// 重置 repo mocks 到默认值
mockRepo.findByEventId.mockResolvedValue(undefined);
mockRepo.findExistingEventIds.mockResolvedValue(new Set<string>());
mockRepo.insertNotification.mockResolvedValue(undefined);
mockRepo.listByUser.mockResolvedValue({ items: [], total: 0 });
mockRepo.getUnreadCount.mockResolvedValue(0);
mockRepo.markAsRead.mockResolvedValue(undefined);
mockRepo.batchMarkAsRead.mockResolvedValue(undefined);
mockRepo.markAllAsRead.mockResolvedValue(0);
mockRepo.recallByGroup.mockResolvedValue(0);
mockRepo.deleteById.mockResolvedValue(undefined);
// 重置 es mocks
mockSafeIndex.mockResolvedValue(true);
mockSafeSearch.mockResolvedValue({ hits: [], total: 0 });
mockSafeDelete.mockResolvedValue(true);
// 重置 outbox
mockOutboxPublish.mockResolvedValue({
eventId: "evt-id",
topic: "edu.notification.sent",
});
});
// ----------------------------------------------------------
// send
// ----------------------------------------------------------
describe("send", () => {
it("幂等跳过eventId 已存在时应返回已有通知,不重复写入", async () => {
const existing = {
id: "existing-id",
status: "sent",
channel: "in_app",
};
mockRepo.findByEventId.mockResolvedValue(existing);
const dispatcher = createMockDispatcher();
service = new NotificationsService(dispatcher as never);
const result = await service.send({
userId: "user-1",
type: "system",
title: "Test",
content: "Content",
eventId: "evt-1",
});
expect(result).toEqual({
id: "existing-id",
status: "sent",
channels: ["in_app"],
});
expect(mockRepo.findByEventId).toHaveBeenCalledWith("evt-1");
expect(mockRepo.insertNotification).not.toHaveBeenCalled();
expect(dispatcher.dispatch).not.toHaveBeenCalled();
});
it("无 eventId 时应正常发送(不做幂等检查)", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
// getUserChannels 返回 null无偏好
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.send({
userId: "user-1",
type: "system",
title: "Test",
content: "Content",
});
expect(mockRepo.findByEventId).not.toHaveBeenCalled();
expect(mockRepo.insertNotification).toHaveBeenCalledTimes(1);
expect(result.id).toBe("mock-cuid-id");
expect(result.status).toBe("sent");
});
it("应写入 MySQL、ES、分发渠道、更新状态、写 outbox", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
{ channel: "email", sent: true },
]);
service = new NotificationsService(dispatcher as never);
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.send({
userId: "user-1",
type: "exam",
title: "Exam Published",
content: "Your exam is live",
channel: "in_app",
metadata: { examId: "exam-1" },
});
// 写入 MySQL
expect(mockRepo.insertNotification).toHaveBeenCalledWith(
expect.objectContaining({
id: "mock-cuid-id",
userId: "user-1",
type: "exam",
title: "Exam Published",
channel: "in_app",
status: "pending",
isRead: false,
}),
);
// 写入 ES
expect(mockSafeIndex).toHaveBeenCalledWith(
expect.objectContaining({
index: "notifications",
id: "mock-cuid-id",
}),
);
// 分发渠道
expect(dispatcher.dispatch).toHaveBeenCalledTimes(1);
const dispatchCtx = (dispatcher.dispatch as Mock).mock.calls[0][0];
expect(dispatchCtx.notificationId).toBe("mock-cuid-id");
expect(dispatchCtx.userId).toBe("user-1");
// 更新状态
expect(mockDb.update).toHaveBeenCalledTimes(1);
// 写 outbox
expect(mockOutboxPublish).toHaveBeenCalledWith(
"notification.sent",
expect.objectContaining({
notificationId: "mock-cuid-id",
userId: "user-1",
channel: "in_app",
}),
expect.objectContaining({
aggregateType: "Notification",
aggregateId: "mock-cuid-id",
}),
);
expect(result.status).toBe("sent");
expect(result.channels).toEqual(["in_app", "email"]);
});
it("所有渠道失败时状态应为 failed", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: false, error: "failed" },
]);
service = new NotificationsService(dispatcher as never);
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.send({
userId: "user-1",
type: "system",
title: "Test",
content: "Content",
});
expect(result.status).toBe("failed");
});
it("默认渠道应为 in_app未指定 channel 时)", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
await service.send({
userId: "user-1",
type: "system",
title: "Test",
content: "Content",
});
const insertArg = mockRepo.insertNotification.mock.calls[0][0];
expect(insertArg.channel).toBe("in_app");
});
it("应使用用户偏好渠道getUserChannels 返回渠道列表)", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
{ channel: "email", sent: true },
]);
service = new NotificationsService(dispatcher as never);
// 模拟用户偏好返回 email 渠道
mockDb.select.mockReturnValue(
createSelectChain([
{
channels: ["email"],
userId: "user-1",
type: "system",
enabled: true,
},
]),
);
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.send({
userId: "user-1",
type: "system",
title: "Test",
content: "Content",
});
// dispatch 第二个参数应包含 email
const enabledChannels = (dispatcher.dispatch as Mock).mock.calls[0][1];
expect(enabledChannels).toContain("email");
expect(result.channels).toEqual(["in_app", "email"]);
});
it("ES 写入失败不应阻断主流程", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
// safeIndex 内部 try/catch失败时返回 false不抛出
mockSafeIndex.mockResolvedValue(false);
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
// 不应抛出
const result = await service.send({
userId: "user-1",
type: "system",
title: "Test",
content: "Content",
});
expect(result.status).toBe("sent");
});
});
// ----------------------------------------------------------
// sendBatch
// ----------------------------------------------------------
describe("sendBatch", () => {
it("应批量发送多条通知,返回成功 id 列表", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.sendBatch({
items: [
{ userId: "user-1", type: "system", title: "T1", content: "C1" },
{ userId: "user-2", type: "system", title: "T2", content: "C2" },
],
groupId: "group-1",
});
expect(result.ids).toHaveLength(2);
expect(result.failed).toHaveLength(0);
// 批量 INSERT 应调用 insertNotifications复数
expect(mockRepo.insertNotifications).toHaveBeenCalledTimes(1);
const batchRows = mockRepo.insertNotifications.mock.calls[0][0];
expect(batchRows).toHaveLength(2);
expect(batchRows[0].groupId).toBe("group-1");
expect(batchRows[1].groupId).toBe("group-1");
});
it("未指定 groupId 时应自动生成", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.sendBatch({
items: [
{ userId: "user-1", type: "system", title: "T1", content: "C1" },
],
});
expect(result.ids).toHaveLength(1);
// groupId 应使用 cuid2 生成的值
const batchRows = mockRepo.insertNotifications.mock.calls[0][0];
expect(batchRows[0].groupId).toBe("mock-cuid-id");
});
it("批量 INSERT 失败时全部标记为 failed", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
mockRepo.insertNotifications.mockRejectedValueOnce(new Error("DB error"));
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.sendBatch({
items: [
{ userId: "user-1", type: "system", title: "T1", content: "C1" },
{ userId: "user-2", type: "system", title: "T2", content: "C2" },
],
groupId: "group-1",
});
expect(result.ids).toHaveLength(0);
expect(result.failed).toHaveLength(2);
expect(result.failed[0].error).toBe("DB error");
expect(result.failed[1].error).toBe("DB error");
});
it("部分分发失败时应收集 failed 列表", async () => {
// 第一条分发成功,第二条分发抛异常
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
// 让 dispatch 第二次调用抛异常
dispatcher.dispatch
.mockResolvedValueOnce([{ channel: "in_app", sent: true }])
.mockRejectedValueOnce(new Error("Dispatch error"));
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.sendBatch({
items: [
{ userId: "user-1", type: "system", title: "T1", content: "C1" },
{ userId: "user-2", type: "system", title: "T2", content: "C2" },
],
groupId: "group-1",
});
expect(result.ids).toHaveLength(1);
expect(result.failed).toHaveLength(1);
expect(result.failed[0].userId).toBe("user-2");
expect(result.failed[0].error).toBe("Dispatch error");
});
it("eventId 已存在时应跳过(幂等过滤)", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
// 模拟 findExistingEventIds 返回已存在的 eventId
mockRepo.findExistingEventIds.mockResolvedValueOnce(
new Set(["evt-existing-1"]),
);
mockDb.select.mockReturnValue(createSelectChain([]));
mockDb.update.mockReturnValue(createUpdateChain(undefined));
const result = await service.sendBatch({
items: [
{
userId: "user-1",
type: "system",
title: "T1",
content: "C1",
eventId: "evt-existing-1",
},
{
userId: "user-2",
type: "system",
title: "T2",
content: "C2",
eventId: "evt-new-1",
},
],
groupId: "group-1",
});
// 应只插入 1 条evt-existing-1 被跳过)
expect(result.ids).toHaveLength(1);
expect(result.failed).toHaveLength(0);
expect(mockRepo.insertNotifications).toHaveBeenCalledTimes(1);
const batchRows = mockRepo.insertNotifications.mock.calls[0][0];
expect(batchRows).toHaveLength(1);
expect(batchRows[0].eventId).toBe("evt-new-1");
});
it("所有 eventId 都已存在时应返回空 ids全跳过", async () => {
const dispatcher = createMockDispatcher([
{ channel: "in_app", sent: true },
]);
service = new NotificationsService(dispatcher as never);
mockRepo.findExistingEventIds.mockResolvedValueOnce(
new Set(["evt-1", "evt-2"]),
);
const result = await service.sendBatch({
items: [
{
userId: "user-1",
type: "system",
title: "T1",
content: "C1",
eventId: "evt-1",
},
{
userId: "user-2",
type: "system",
title: "T2",
content: "C2",
eventId: "evt-2",
},
],
groupId: "group-1",
});
expect(result.ids).toHaveLength(0);
expect(result.failed).toHaveLength(0);
expect(mockRepo.insertNotifications).not.toHaveBeenCalled();
expect(dispatcher.dispatch).not.toHaveBeenCalled();
});
});
// ----------------------------------------------------------
// listByUser
// ----------------------------------------------------------
describe("listByUser", () => {
it("应返回分页结果", async () => {
service = new NotificationsService(createMockDispatcher() as never);
const mockItems = [{ id: "n1" }, { id: "n2" }];
mockRepo.listByUser.mockResolvedValue({ items: mockItems, total: 25 });
const result = await service.listByUser("user-1", {
page: 2,
pageSize: 10,
});
expect(result.items).toEqual(mockItems);
expect(result.total).toBe(25);
expect(result.page).toBe(2);
expect(result.pageSize).toBe(10);
});
});
// ----------------------------------------------------------
// getUnreadCount
// ----------------------------------------------------------
describe("getUnreadCount", () => {
it("应返回未读计数", async () => {
service = new NotificationsService(createMockDispatcher() as never);
mockRepo.getUnreadCount.mockResolvedValue(7);
const result = await service.getUnreadCount("user-1");
expect(result).toBe(7);
});
});
// ----------------------------------------------------------
// markAsRead
// ----------------------------------------------------------
describe("markAsRead", () => {
it("应标记已读并发布 notification.read 事件", async () => {
service = new NotificationsService(createMockDispatcher() as never);
await service.markAsRead("notif-1", "user-1");
expect(mockRepo.markAsRead).toHaveBeenCalledWith("notif-1", "user-1");
expect(mockOutboxPublish).toHaveBeenCalledWith(
"notification.read",
{ notificationId: "notif-1", userId: "user-1" },
expect.objectContaining({
aggregateType: "Notification",
aggregateId: "notif-1",
}),
);
});
});
// ----------------------------------------------------------
// batchMarkAsRead
// ----------------------------------------------------------
describe("batchMarkAsRead", () => {
it("应批量标记已读并为每条发布 read 事件", async () => {
service = new NotificationsService(createMockDispatcher() as never);
await service.batchMarkAsRead(["n1", "n2", "n3"], "user-1");
expect(mockRepo.batchMarkAsRead).toHaveBeenCalledWith(
["n1", "n2", "n3"],
"user-1",
);
expect(mockOutboxPublish).toHaveBeenCalledTimes(3);
// 验证每条 id 都发布了事件
for (let i = 0; i < 3; i++) {
const payload = mockOutboxPublish.mock.calls[i][1];
expect(payload.notificationId).toBe(["n1", "n2", "n3"][i]);
}
});
});
// ----------------------------------------------------------
// markAllAsRead
// ----------------------------------------------------------
describe("markAllAsRead", () => {
it("应标记全部已读并返回受影响行数", async () => {
service = new NotificationsService(createMockDispatcher() as never);
mockRepo.markAllAsRead.mockResolvedValue(15);
const result = await service.markAllAsRead("user-1");
expect(result).toBe(15);
expect(mockOutboxPublish).toHaveBeenCalledWith(
"notification.read",
expect.objectContaining({ bulk: true, userId: "user-1" }),
expect.objectContaining({
aggregateType: "Notification",
aggregateId: "user-1",
}),
);
});
it("before 参数应传递给 outbox 事件", async () => {
service = new NotificationsService(createMockDispatcher() as never);
mockRepo.markAllAsRead.mockResolvedValue(5);
const before = 1700000000000;
await service.markAllAsRead("user-1", before);
const payload = mockOutboxPublish.mock.calls[0][1];
expect(payload.before).toBe(before);
});
});
// ----------------------------------------------------------
// search
// ----------------------------------------------------------
describe("search", () => {
it("应通过 ES 搜索并返回结果", async () => {
service = new NotificationsService(createMockDispatcher() as never);
const mockHits = [
{ _id: "n1", _source: { id: "n1", title: "Hello" } },
{ _id: "n2", _source: { id: "n2", title: "Hello World" } },
];
mockSafeSearch.mockResolvedValue({ hits: mockHits, total: 2 });
const result = await service.search("user-1", "Hello", {
page: 1,
pageSize: 20,
});
expect(result.items).toEqual([
{ id: "n1", title: "Hello" },
{ id: "n2", title: "Hello World" },
]);
expect(result.total).toBe(2);
expect(result.page).toBe(1);
expect(result.pageSize).toBe(20);
expect(mockSafeSearch).toHaveBeenCalledWith(
"notifications",
expect.any(Object),
0, // from = (1-1) * 20
20,
);
});
it("type 过滤应添加到查询条件", async () => {
service = new NotificationsService(createMockDispatcher() as never);
mockSafeSearch.mockResolvedValue({ hits: [], total: 0 });
await service.search("user-1", "test", {
type: "exam",
page: 1,
pageSize: 10,
});
const queryArg = mockSafeSearch.mock.calls[0][1];
// must 数组应包含 type term
const must = (queryArg as { bool: { must: unknown[] } }).bool.must;
expect(must).toHaveLength(3); // user_id + multi_match + type
});
it("page=2 时 from 应正确计算", async () => {
service = new NotificationsService(createMockDispatcher() as never);
mockSafeSearch.mockResolvedValue({ hits: [], total: 0 });
await service.search("user-1", "test", {
page: 2,
pageSize: 15,
});
// from = (2-1) * 15 = 15
expect(mockSafeSearch).toHaveBeenCalledWith(
"notifications",
expect.any(Object),
15,
15,
);
});
});
// ----------------------------------------------------------
// recall
// ----------------------------------------------------------
describe("recall", () => {
it("应撤回通知组并发布 notification.recalled 事件", async () => {
service = new NotificationsService(createMockDispatcher() as never);
mockRepo.recallByGroup.mockResolvedValue(5);
const result = await service.recall("group-1");
expect(result).toBe(5);
expect(mockRepo.recallByGroup).toHaveBeenCalledWith("group-1");
expect(mockOutboxPublish).toHaveBeenCalledWith(
"notification.recalled",
{ groupId: "group-1", recalledCount: 5 },
expect.objectContaining({
aggregateType: "Notification",
aggregateId: "group-1",
}),
);
});
it("撤回 0 条时也应正常发布事件", async () => {
service = new NotificationsService(createMockDispatcher() as never);
mockRepo.recallByGroup.mockResolvedValue(0);
const result = await service.recall("empty-group");
expect(result).toBe(0);
expect(mockOutboxPublish).toHaveBeenCalledTimes(1);
});
});
// ----------------------------------------------------------
// delete
// ----------------------------------------------------------
describe("delete", () => {
it("应删除 MySQL 记录和 ES 索引", async () => {
service = new NotificationsService(createMockDispatcher() as never);
await service.delete("notif-1");
expect(mockRepo.deleteById).toHaveBeenCalledWith("notif-1");
expect(mockSafeDelete).toHaveBeenCalledWith("notifications", "notif-1");
});
it("ES 删除失败不应阻断", async () => {
service = new NotificationsService(createMockDispatcher() as never);
// safeDelete 内部 try/catch失败时返回 false不抛出
mockSafeDelete.mockResolvedValue(false);
// 不应抛出
await service.delete("notif-1");
expect(mockRepo.deleteById).toHaveBeenCalledWith("notif-1");
});
});
});

View File

@@ -0,0 +1,458 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { OutboxEvent } from "../../src/shared/outbox/outbox.schema.js";
// ============================================================
// Mock 外部依赖 —— 使用 vi.hoisted 确保 mock 变量在 hoisted 的 vi.mock 中可用
// ============================================================
const mocks = vi.hoisted(() => {
const mockProducer = {
send: vi.fn(),
};
const mockFindPending = vi.fn();
const mockMarkPublished = vi.fn();
const mockMarkFailed = vi.fn();
const mockIncrementRetry = vi.fn();
return {
mockProducer,
mockFindPending,
mockMarkPublished,
mockMarkFailed,
mockIncrementRetry,
};
});
// Mock logger
vi.mock("../../src/shared/observability/logger.js", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
// Mock Kafka client
vi.mock("../../src/shared/kafka/kafka.client.js", () => ({
getProducer: () => mocks.mockProducer,
isKafkaHealthy: vi.fn(() => true),
}));
// Mock topic-map
vi.mock("../../src/shared/kafka/topic-map.js", () => ({
resolveTopic: vi.fn((eventType: string) => {
const map: Record<string, string> = {
"notification.sent": "edu.notification.sent",
"notification.read": "edu.notification.read",
"notification.recalled": "edu.notification.recalled",
"notification.failed": "edu.notification.failed",
};
return map[eventType] ?? "edu.notification.events";
}),
}));
// Mock outbox repository
vi.mock("../../src/shared/outbox/outbox.repository.js", () => ({
findPending: mocks.mockFindPending,
markPublished: mocks.mockMarkPublished,
markFailed: mocks.mockMarkFailed,
incrementRetry: mocks.mockIncrementRetry,
}));
// 导入被测模块(在 mock 之后)
import { outboxPublisher } from "../../src/shared/outbox/outbox.publisher.js";
// ============================================================
// 辅助
// ============================================================
const {
mockProducer,
mockFindPending,
mockMarkPublished,
mockMarkFailed,
mockIncrementRetry,
} = mocks;
function createMessage(overrides: Partial<OutboxEvent> = {}): OutboxEvent {
return {
eventId: "evt-1",
aggregateType: "Notification",
aggregateId: "agg-1",
eventType: "notification.sent",
topic: "edu.notification.sent",
payload: { notificationId: "n1", userId: "u1" },
status: "pending",
retryCount: 0,
maxRetryCount: 5,
createdAt: new Date("2026-01-01T00:00:00Z"),
publishedAt: null,
nextRetryAt: null,
lastError: null,
metadata: null,
...overrides,
} as OutboxEvent;
}
// 访问私有方法
function poll(): Promise<void> {
return (outboxPublisher as unknown as { poll: () => Promise<void> }).poll();
}
function dispatch(message: OutboxEvent): Promise<void> {
return (
outboxPublisher as unknown as {
dispatch: (m: OutboxEvent) => Promise<void>;
}
).dispatch(message);
}
// ============================================================
// Tests
// ============================================================
describe("OutboxPublisher", () => {
beforeEach(async () => {
vi.clearAllMocks();
vi.useFakeTimers();
// 重置 singleton 状态:确保 intervalId 被清除
await outboxPublisher.stop();
mockProducer.send.mockResolvedValue({} as never);
mockMarkPublished.mockResolvedValue(undefined);
mockMarkFailed.mockResolvedValue(undefined);
mockIncrementRetry.mockResolvedValue(undefined);
mockFindPending.mockResolvedValue([]);
});
afterEach(() => {
vi.useRealTimers();
});
// ----------------------------------------------------------
// start / stop
// ----------------------------------------------------------
describe("start / stop", () => {
it("start 应设置定时轮询", async () => {
await outboxPublisher.start();
// 推进定时器触发 poll
await vi.advanceTimersByTimeAsync(5000);
expect(mockFindPending).toHaveBeenCalled();
});
it("重复 start 不应创建多个定时器", async () => {
await outboxPublisher.start();
await outboxPublisher.start();
// 推进 5 秒,应只 poll 一次(第二个 start 是 no-op
await vi.advanceTimersByTimeAsync(5000);
// 只有一个 intervalpoll 应只被调用一次
expect(mockFindPending).toHaveBeenCalledTimes(1);
});
it("stop 应清除定时器", async () => {
await outboxPublisher.start();
await outboxPublisher.stop();
mockFindPending.mockClear();
await vi.advanceTimersByTimeAsync(10000);
expect(mockFindPending).not.toHaveBeenCalled();
});
it("无定时器时 stop 不应报错", async () => {
await outboxPublisher.stop();
// 不抛出即可
});
});
// ----------------------------------------------------------
// poll
// ----------------------------------------------------------
describe("poll", () => {
it("findPending 返回消息时应逐条 dispatch", async () => {
const messages = [
createMessage({ eventId: "evt-1" }),
createMessage({ eventId: "evt-2" }),
];
mockFindPending.mockResolvedValue(messages);
await poll();
expect(mockFindPending).toHaveBeenCalledWith(100); // BATCH_SIZE
expect(mockProducer.send).toHaveBeenCalledTimes(2);
expect(mockMarkPublished).toHaveBeenCalledTimes(2);
expect(mockMarkPublished).toHaveBeenCalledWith("evt-1");
expect(mockMarkPublished).toHaveBeenCalledWith("evt-2");
});
it("findPending 返回空数组时不应 dispatch", async () => {
mockFindPending.mockResolvedValue([]);
await poll();
expect(mockProducer.send).not.toHaveBeenCalled();
expect(mockMarkPublished).not.toHaveBeenCalled();
});
it("findPending 抛出异常时应记录日志不中断", async () => {
mockFindPending.mockRejectedValue(new Error("DB connection lost"));
// 不应抛出
await poll();
// logger.error 应被调用(由 mock 拦截)
});
it("并发 poll 保护:正在 poll 时不应重复执行", async () => {
const messages = [createMessage()];
// 让 producer.send 返回一个未完成的 Promise
let resolveSend: () => void;
mockProducer.send.mockReturnValue(
new Promise((resolve) => {
resolveSend = resolve as () => void;
}),
);
mockFindPending.mockResolvedValue(messages);
// 启动第一次 poll未完成
const firstPoll = poll();
// 尝试第二次 poll应被跳过
await poll();
// 只有第一次的 findPending 被调用了一次
// (第二次 poll 因为 isPolling=true 直接返回)
expect(mockFindPending).toHaveBeenCalledTimes(1);
// 完成
resolveSend!();
await firstPoll;
});
});
// ----------------------------------------------------------
// dispatch —— 成功路径
// ----------------------------------------------------------
describe("dispatch 成功", () => {
it("应发送到正确的 topic 并标记 published", async () => {
const message = createMessage({
eventType: "notification.read",
aggregateId: "notif-1",
});
await dispatch(message);
expect(mockProducer.send).toHaveBeenCalledWith({
topic: "edu.notification.read",
messages: [
{
key: "notif-1",
value: JSON.stringify({ notificationId: "n1", userId: "u1" }),
headers: expect.objectContaining({
eventId: "evt-1",
eventType: "notification.read",
aggregateType: "Notification",
aggregateId: "notif-1",
}),
},
],
});
expect(mockMarkPublished).toHaveBeenCalledWith("evt-1");
});
it("payload 为字符串时应直接使用", async () => {
const message = createMessage({
payload: "raw-string-payload" as unknown,
} as OutboxEvent);
await dispatch(message);
const sendArg = mockProducer.send.mock.calls[0][0];
expect(sendArg.messages[0].value).toBe("raw-string-payload");
});
it("payload 为对象时应 JSON 序列化", async () => {
const payload = { key: "value", num: 42 };
const message = createMessage({ payload });
await dispatch(message);
const sendArg = mockProducer.send.mock.calls[0][0];
expect(sendArg.messages[0].value).toBe(JSON.stringify(payload));
});
it("metadata 应合并到 headers", async () => {
const message = createMessage({
metadata: { userId: "u1", source: "test" },
});
await dispatch(message);
const sendArg = mockProducer.send.mock.calls[0][0];
expect(sendArg.messages[0].headers).toEqual(
expect.objectContaining({
eventId: "evt-1",
eventType: "notification.sent",
aggregateType: "Notification",
aggregateId: "agg-1",
userId: "u1",
source: "test",
}),
);
});
it("metadata 为 null 时 headers 只含基础字段", async () => {
const message = createMessage({ metadata: null });
await dispatch(message);
const sendArg = mockProducer.send.mock.calls[0][0];
const headers = sendArg.messages[0].headers;
expect(Object.keys(headers)).toEqual(
expect.arrayContaining([
"eventId",
"eventType",
"aggregateType",
"aggregateId",
]),
);
expect(Object.keys(headers)).toHaveLength(4);
});
});
// ----------------------------------------------------------
// dispatch —— 重试逻辑
// ----------------------------------------------------------
describe("dispatch 重试逻辑", () => {
it("失败且 retryCount < MAX_RETRY → incrementRetry", async () => {
const message = createMessage({ retryCount: 2 });
mockProducer.send.mockRejectedValue(new Error("Kafka timeout"));
await dispatch(message);
// retryCount=2, +1=3 < MAX_RETRY(5)
expect(mockIncrementRetry).toHaveBeenCalledTimes(1);
expect(mockIncrementRetry).toHaveBeenCalledWith(
"evt-1",
"Kafka timeout",
2000 * 2 ** 2, // RETRY_BACKOFF_MS * 2^retryCount = 2000 * 4 = 8000
);
expect(mockMarkFailed).not.toHaveBeenCalled();
});
it("失败且 retryCount+1 >= MAX_RETRY → markFailed", async () => {
const message = createMessage({ retryCount: 4 }); // 4+1=5 >= 5
mockProducer.send.mockRejectedValue(new Error("Kafka down"));
await dispatch(message);
expect(mockMarkFailed).toHaveBeenCalledTimes(1);
expect(mockMarkFailed).toHaveBeenCalledWith("evt-1", "Kafka down");
expect(mockIncrementRetry).not.toHaveBeenCalled();
});
it("失败且 retryCount=5超过 MAX_RETRY→ markFailed", async () => {
const message = createMessage({ retryCount: 5 });
mockProducer.send.mockRejectedValue(new Error("Still failing"));
await dispatch(message);
expect(mockMarkFailed).toHaveBeenCalledWith("evt-1", "Still failing");
});
it("非 Error 类型的异常应转字符串", async () => {
const message = createMessage({ retryCount: 0 });
mockProducer.send.mockRejectedValue("string error");
await dispatch(message);
expect(mockIncrementRetry).toHaveBeenCalledWith(
"evt-1",
"string error",
2000, // 2000 * 2^0 = 2000
);
});
it("指数退避应正确计算retryCount=0 → 2000ms, retryCount=1 → 4000ms", async () => {
mockProducer.send.mockRejectedValue(new Error("fail"));
// retryCount=0 → 2000ms
await dispatch(createMessage({ retryCount: 0 }));
expect(mockIncrementRetry).toHaveBeenLastCalledWith(
"evt-1",
"fail",
2000,
);
// retryCount=1 → 4000ms
await dispatch(createMessage({ retryCount: 1 }));
expect(mockIncrementRetry).toHaveBeenLastCalledWith(
"evt-1",
"fail",
4000,
);
});
});
// ----------------------------------------------------------
// 完整 poll → dispatch → markPublished 流程
// ----------------------------------------------------------
describe("完整流程", () => {
it("poll → dispatch 多条 → 全部 markPublished", async () => {
const messages = [
createMessage({ eventId: "evt-1", eventType: "notification.sent" }),
createMessage({ eventId: "evt-2", eventType: "notification.read" }),
createMessage({
eventId: "evt-3",
eventType: "notification.recalled",
}),
];
mockFindPending.mockResolvedValue(messages);
await poll();
expect(mockProducer.send).toHaveBeenCalledTimes(3);
expect(mockMarkPublished).toHaveBeenCalledTimes(3);
// 验证每条消息发送到正确 topic
const topics = mockProducer.send.mock.calls.map(
(call) => (call[0] as { topic: string }).topic,
);
expect(topics).toEqual([
"edu.notification.sent",
"edu.notification.read",
"edu.notification.recalled",
]);
});
it("poll 中部分 dispatch 失败不应中断后续消息", async () => {
const messages = [
createMessage({ eventId: "evt-1" }),
createMessage({ eventId: "evt-2" }),
createMessage({ eventId: "evt-3" }),
];
mockFindPending.mockResolvedValue(messages);
// 第二条失败
mockProducer.send
.mockResolvedValueOnce({} as never)
.mockRejectedValueOnce(new Error("Kafka error"))
.mockResolvedValueOnce({} as never);
await poll();
// 第一条和第三条成功 markPublished第二条 incrementRetry
expect(mockMarkPublished).toHaveBeenCalledTimes(2);
expect(mockMarkPublished).toHaveBeenCalledWith("evt-1");
expect(mockMarkPublished).toHaveBeenCalledWith("evt-3");
expect(mockIncrementRetry).toHaveBeenCalledTimes(1);
expect(mockIncrementRetry).toHaveBeenCalledWith(
"evt-2",
"Kafka error",
2000,
);
});
});
});

View File

@@ -0,0 +1,12 @@
/**
* Vitest 全局 setup —— 在所有测试文件之前设置环境变量。
*
* env.ts 在模块加载时调用 loadEnv() 解析 process.env
* 需要 DATABASE_URL必填。此处设置测试用值避免模块加载报错。
*/
process.env.DATABASE_URL = "mysql://test:test@localhost:3306/msg_test";
process.env.NODE_ENV = "test";
process.env.LOG_LEVEL = "error";
process.env.KAFKA_BROKERS = "localhost:9092";
process.env.KAFKA_CLIENT_ID = "msg-service-test";
process.env.KAFKA_CONSUMER_GROUP_ID = "msg-service-test-group";