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

474 lines
14 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";
// ============================================================
// 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);
});
});
});