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