Files
NextEdu/src/modules/notifications/dispatcher.test.ts
SpecialX f75602d14e feat(announcements,messaging,notifications): 实现所有长期问题 — SSE 实时推送 + 通知日志持久化 + 优先级/归档 + 消息星标/草稿 + 公告已读回执/置顶 + 分类筛选/桌面推送 + 测试覆盖
P1-8 通知实时推送(SSE):
- 新增 /api/notifications/stream SSE 端点(15 秒推送,5 分钟超时)
- 新增 useNotificationStream Hook(SSE + 轮询降级)
- NotificationDropdown 改用 SSE 实时推送

P2-12 测试覆盖:
- notifications/dispatcher.test.ts(6 个测试,渠道选择逻辑)
- notifications/channels/in-app-channel.test.ts(9 个测试,类型映射)
- messaging/schema.test.ts(34 个测试,Zod 校验)
- tests/e2e/messages.spec.ts(消息模块 E2E 测试)
- vitest.unit.config.ts 添加 server-only stub

P2-13a 通知发送日志持久化:
- 新增 notification_logs 表(userId/title/channel/status/messageId/error/sentAt)
- logNotificationSend 改为 async 写入 DB(失败降级 console)
- dispatcher 传递 payload 用于持久化

P2-13b 通知优先级和归档:
- messageNotifications 表新增 priority(low/normal/high/urgent)和 isArchived 字段
- getNotifications 支持归档和优先级筛选
- 新增 archiveNotificationAction
- NotificationList 显示优先级 Badge 和归档按钮

P2-13c 消息星标和草稿:
- messages 表新增 isStarred 字段
- 新增 message_drafts 表
- 新增 toggleMessageStar + 草稿 CRUD Server Actions
- 新增 5 个草稿 data-access 函数

P2-13d 公告已读回执和置顶:
- announcements 表新增 isPinned 字段
- 新增 announcement_reads 表(唯一索引保证幂等)
- 新增 toggleAnnouncementPinAction + markAnnouncementAsReadAction
- getAnnouncements 排序置顶优先

P2-13e 通知分类筛选和桌面推送:
- NotificationList 添加按类型筛选按钮组
- 新增 useDesktopNotifications Hook(浏览器 Notification API)
- NotificationDropdown 集成桌面推送(新通知触发)

架构图同步:
- 004 和 005 均已更新(新增表、Action、Hook、组件描述)
2026-06-23 10:13:57 +08:00

252 lines
7.2 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest"
const mocks = vi.hoisted(() => ({
getNotificationPreferences: vi.fn(),
getUserContactInfo: vi.fn(),
logNotificationSendBatch: vi.fn(),
createNotification: vi.fn(),
inAppSend: vi.fn(),
smsSend: vi.fn(),
wechatSend: vi.fn(),
emailSend: vi.fn(),
}))
vi.mock("./data-access", () => ({
getUserContactInfo: mocks.getUserContactInfo,
logNotificationSendBatch: mocks.logNotificationSendBatch,
createNotification: mocks.createNotification,
}))
vi.mock("./preferences", () => ({
getNotificationPreferences: mocks.getNotificationPreferences,
}))
vi.mock("./channels/sms-channel", () => ({
createSmsSender: () => ({
channel: "sms",
send: mocks.smsSend,
sendBatch: vi.fn(),
}),
}))
vi.mock("./channels/wechat-channel", () => ({
createWechatSender: () => ({
channel: "wechat",
send: mocks.wechatSend,
sendBatch: vi.fn(),
}),
}))
vi.mock("./channels/email-channel", () => ({
createEmailSender: () => ({
channel: "email",
send: mocks.emailSend,
sendBatch: vi.fn(),
}),
}))
vi.mock("./channels/in-app-channel", () => ({
createInAppSender: () => ({
channel: "in_app",
send: mocks.inAppSend,
sendBatch: vi.fn(),
}),
}))
import { sendNotification } from "./dispatcher"
import type { NotificationPayload } from "./types"
describe("sendNotification", () => {
beforeEach(() => {
// mockReset (from vitest config) clears implementations before beforeEach.
// Re-establish channel send implementations so the cached sender registry
// (module-level singleton in dispatcher.ts) keeps working across tests.
mocks.inAppSend.mockImplementation(async (payload: NotificationPayload) => {
const id = await mocks.createNotification({
userId: payload.userId,
type: "message",
title: payload.title,
content: payload.content,
link: payload.actionUrl ?? null,
})
return {
channel: "in_app" as const,
success: true,
messageId: id,
sentAt: new Date(),
}
})
mocks.smsSend.mockResolvedValue({
channel: "sms",
success: true,
sentAt: new Date(),
})
mocks.wechatSend.mockResolvedValue({
channel: "wechat",
success: true,
sentAt: new Date(),
})
mocks.emailSend.mockResolvedValue({
channel: "email",
success: true,
sentAt: new Date(),
})
})
it("should select in_app channel when pushEnabled is true and no contact info", async () => {
mocks.getNotificationPreferences.mockResolvedValue({
smsEnabled: false,
emailEnabled: false,
pushEnabled: true,
})
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1" })
mocks.createNotification.mockResolvedValue("notif-1")
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
const payload: NotificationPayload = {
userId: "user-1",
title: "Test notification",
content: "Test content",
type: "info",
}
const results = await sendNotification(payload)
expect(results).toHaveLength(1)
expect(results[0].channel).toBe("in_app")
expect(results[0].success).toBe(true)
expect(mocks.createNotification).toHaveBeenCalledWith(
expect.objectContaining({
userId: "user-1",
title: "Test notification",
})
)
})
it("should select sms channel when smsEnabled and phone provided", async () => {
mocks.getNotificationPreferences.mockResolvedValue({
smsEnabled: true,
emailEnabled: false,
pushEnabled: true,
})
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1", phone: "13800138000" })
mocks.createNotification.mockResolvedValue("notif-1")
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
}
const results = await sendNotification(payload)
expect(results).toHaveLength(2)
const channels = results.map((r) => r.channel)
expect(channels).toContain("in_app")
expect(channels).toContain("sms")
})
it("should select email channel when emailEnabled and email provided", async () => {
mocks.getNotificationPreferences.mockResolvedValue({
smsEnabled: false,
emailEnabled: true,
pushEnabled: true,
})
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1", email: "test@example.com" })
mocks.createNotification.mockResolvedValue("notif-1")
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
}
const results = await sendNotification(payload)
expect(results).toHaveLength(2)
const channels = results.map((r) => r.channel)
expect(channels).toContain("in_app")
expect(channels).toContain("email")
})
it("should fallback to in_app when all channels disabled", async () => {
mocks.getNotificationPreferences.mockResolvedValue({
smsEnabled: false,
emailEnabled: false,
pushEnabled: false,
})
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1" })
mocks.createNotification.mockResolvedValue("notif-1")
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
}
const results = await sendNotification(payload)
// pushEnabled false 时,兜底逻辑应至少发 in_app
expect(results).toHaveLength(1)
expect(results[0].channel).toBe("in_app")
})
it("should select wechat channel when pushEnabled and wechatOpenId provided", async () => {
mocks.getNotificationPreferences.mockResolvedValue({
smsEnabled: false,
emailEnabled: false,
pushEnabled: true,
})
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1", wechatOpenId: "wx-open-id" })
mocks.createNotification.mockResolvedValue("notif-1")
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
}
const results = await sendNotification(payload)
expect(results).toHaveLength(2)
const channels = results.map((r) => r.channel)
expect(channels).toContain("in_app")
expect(channels).toContain("wechat")
})
it("should call logNotificationSendBatch with results", async () => {
mocks.getNotificationPreferences.mockResolvedValue({
smsEnabled: false,
emailEnabled: false,
pushEnabled: true,
})
mocks.getUserContactInfo.mockResolvedValue({ userId: "user-1" })
mocks.createNotification.mockResolvedValue("notif-1")
mocks.logNotificationSendBatch.mockResolvedValue(undefined)
const payload: NotificationPayload = {
userId: "user-1",
title: "Test",
content: "Content",
type: "info",
}
await sendNotification(payload)
expect(mocks.logNotificationSendBatch).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ channel: "in_app", success: true }),
]),
{ userId: "user-1", title: "Test" }
)
})
})