Files
Edu/services/msg/test/unit/notifications.service.spec.ts

797 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
});
});
});