feat(msg): announcements 公告模块 + sendBatch 批量优化 + 权限扩展 + nextstep 文档
This commit is contained in:
473
services/msg/test/unit/notifications.repository.spec.ts
Normal file
473
services/msg/test/unit/notifications.repository.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user