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