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:
SpecialX
2026-06-23 10:13:57 +08:00
parent 696346dc08
commit f75602d14e
39 changed files with 2557 additions and 110 deletions

View File

@@ -0,0 +1,64 @@
import { expect, test } from "@playwright/test"
/**
* 私信模块 E2E 测试。
* 未登录场景可独立运行;登录后场景需要 DATABASE_URL。
* 参考 announcements.spec.ts 的登录与跳转断言模式。
*/
test.describe("Messages module", () => {
test.describe("unauthenticated access", () => {
test("redirects to login when not authenticated", async ({ page }) => {
await page.goto("/messages")
await expect(page).toHaveURL(/\/login(?:$|[/?#])/)
})
test("redirects message detail to login when not authenticated", async ({ page }) => {
await page.goto("/messages/some-id")
await expect(page).toHaveURL(/\/login(?:$|[/?#])/)
})
test("redirects compose to login when not authenticated", async ({ page }) => {
await page.goto("/messages/compose")
await expect(page).toHaveURL(/\/login(?:$|[/?#])/)
})
})
test.describe("authenticated access", () => {
test.beforeEach(async ({ page }) => {
test.skip(!process.env.DATABASE_URL, "requires DATABASE_URL for authenticated flow")
const email = process.env.E2E_STUDENT_EMAIL ?? "student@e2e.local"
const password = process.env.E2E_STUDENT_PASSWORD ?? "e2e-pass-123456"
await page.goto("/login")
await page.getByLabel("Email").fill(email)
await page.getByLabel("Password").fill(password)
await page.getByRole("button", { name: "Sign In with Email" }).click()
})
test("messages page loads and stays authenticated", async ({ page }) => {
await page.goto("/messages")
await expect(page.locator("body")).toBeVisible()
await expect(page).not.toHaveURL(/\/login(?:$|[/?#])/)
})
test("messages page has tab navigation", async ({ page }) => {
await page.goto("/messages")
const tabs = page.locator('[role="tab"]')
await expect(tabs.first()).toBeVisible({ timeout: 10000 })
const tabCount = await tabs.count()
expect(tabCount).toBeGreaterThanOrEqual(2)
})
test("messages page renders without error", async ({ page }) => {
await page.goto("/messages")
await expect(page.locator("body")).toBeVisible()
})
test("message compose page loads", async ({ page }) => {
await page.goto("/messages/compose")
await expect(page.locator("body")).toBeVisible()
})
})
})

View File

@@ -0,0 +1,4 @@
// Stub for Next.js "server-only" package.
// The real package throws when imported on the client side; vitest (jsdom)
// cannot resolve it, so we alias it to this empty module in vitest.unit.config.ts.
export {}