797 lines
25 KiB
TypeScript
797 lines
25 KiB
TypeScript
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");
|
||
});
|
||
});
|
||
});
|