474 lines
14 KiB
TypeScript
474 lines
14 KiB
TypeScript
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);
|
||
});
|
||
});
|
||
});
|