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、组件描述)
This commit is contained in:
251
src/modules/notifications/dispatcher.test.ts
Normal file
251
src/modules/notifications/dispatcher.test.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
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" }
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user