/** * 通知 / 公告整合 smoke test — * * 覆盖: * 1. admin POST /admin/announcement/publish (空内容 → 400) * 2. admin POST /admin/announcement/publish (HTML 内容 → 200 sent_to=N) * 3. 后端 DB:fan-out 数等于 User 总数(active+inactive 都收到) * 4. tudou GET /announcement → 拿到那条 HTML * 5. tudou 浏览器进 /app 应自动强弹 modal(GlobalAnnouncementGate) * 6. 关闭 modal → POST /announcement/read → 再开页面不弹 * 7. tudou 进消息中心 → 看到公告条目(带 [公告] chip + HTML 渲染) * * 前提:backend 8000 + frontend 5173 跑着,admin/admin123 + tudou/tudoupass123 可登录。 * 清场:测试前清掉所有 announcement 未读;测试后也清掉以免污染其他 smoke。 */ import { chromium } from '@playwright/test'; const BASE = 'http://localhost:5173'; const API = 'http://localhost:8000'; const results = []; function pass(name) { results.push({ name, ok: true }); console.log(` ✓ ${name}`); } function fail(name, err) { results.push({ name, ok: false, err: err?.message || err }); console.log(` ✗ ${name}: ${err?.message || err}`); } async function login(page, username, password) { const res = await page.request.post(`${API}/api/v1/auth/login`, { data: { username, password }, }); if (!res.ok()) throw new Error(`登录失败 ${username}: ${res.status()}`); const body = await res.json(); return { token: body?.tokens?.access, user: body?.user }; } async function setStorage(page, { token, refresh, user }) { await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' }); await page.evaluate(({ access, r, u }) => { localStorage.setItem('access_token', access); if (r) localStorage.setItem('refresh_token', r); if (u) localStorage.setItem('user', JSON.stringify(u)); }, { access: token, r: refresh, u: user }); } async function clearAllUnreadAnnouncements(adminToken) { // 没有专用清理 endpoint,用 admin 自己的 read-all 跑一遍(只清自己的); // 其他用户的未读靠 tudou 的 read-all 单独清。这里只清 admin 自己。 await fetch(`${API}/api/v1/announcement/read`, { method: 'POST', headers: { 'Authorization': `Bearer ${adminToken}` }, }); } async function main() { const browser = await chromium.launch({ headless: true }); const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); const page = await ctx.newPage(); const consoleErrors = []; page.on('console', (m) => { if (m.type() === 'error' && !/401|404|Failed to load|DevTools/.test(m.text())) { consoleErrors.push(m.text()); } }); // 接受 confirm 弹窗(发送公告二次确认) page.on('dialog', (d) => d.accept()); console.log('\n════ 公告整合 smoke ════'); // 前置:登录拿 token let adminTok, tudouTok; try { const a = await login(page, 'admin', 'admin123'); const t = await login(page, 'tudou', 'tudoupass123'); adminTok = a.token; tudouTok = t.token; if (!adminTok || !tudouTok) throw new Error('token 空'); } catch (e) { fail('前置:admin/tudou 登录拿 token', e); await browser.close(); return; } // 先清掉 tudou 可能的旧未读公告 await fetch(`${API}/api/v1/announcement/read`, { method: 'POST', headers: { 'Authorization': `Bearer ${tudouTok}` }, }); await fetch(`${API}/api/v1/announcement/read`, { method: 'POST', headers: { 'Authorization': `Bearer ${adminTok}` }, }); // ── 1. 空内容 400 ── try { const r = await fetch(`${API}/api/v1/admin/announcement/publish`, { method: 'POST', headers: { 'Authorization': `Bearer ${adminTok}`, 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ content: '' }), }); if (r.status === 400) pass('1. 空内容发送返回 400'); else fail('1. 空内容', new Error(`期望 400 实际 ${r.status}`)); } catch (e) { fail('1. 空内容', e); } // ── 2. HTML 发送返回 200 + sent_to ── // 内容做长一点 — 短公告 preview 不会截断,折叠态/展开态文字完全一样, // 测试 7.3 收起没法用 text 区分;长内容让 preview '…' 截断, // 用末尾独有 marker 字串作为"只在展开态才看得到"的探针。 const uniqueMarker = `EXPANDED-ONLY-MARKER-${Date.now()}`; const testContent = `
smoke 测试公告 1234567890abcdefghijklmnop 用一些很多文字填充让 preview 截断,确保折叠态看不到完整 HTML。占位文字继续:用户/团队/超管角色矩阵 → 团队管理/消费记录/系统设置。结尾标记 ${uniqueMarker}
`; let sentTo = 0; try { const r = await fetch(`${API}/api/v1/admin/announcement/publish`, { method: 'POST', headers: { 'Authorization': `Bearer ${adminTok}`, 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify({ content: testContent }), }); const data = await r.json(); sentTo = data.sent_to; if (r.status === 200 && sentTo > 0) pass(`2. HTML 发送成功 sent_to=${sentTo}`); else fail('2. HTML 发送', new Error(`期望 200+sent_to>0 实际 ${r.status} ${JSON.stringify(data)}`)); } catch (e) { fail('2. HTML 发送', e); } // ── 3. fan-out 数 = User 总数(用 admin 计:GET /admin/users total 应 ≈ sent_to) ── try { const r = await fetch(`${API}/api/v1/admin/users?page_size=1`, { headers: { 'Authorization': `Bearer ${adminTok}` }, }); const data = await r.json(); if (data.total === sentTo) pass(`3. fan-out 数 (${sentTo}) = User 总数 (${data.total})`); else fail('3. fan-out 数', new Error(`sent_to=${sentTo} vs admin/users.total=${data.total}`)); } catch (e) { fail('3. fan-out 数', e); } // ── 4. tudou GET /announcement 拿到那条 ── try { const r = await fetch(`${API}/api/v1/announcement`, { headers: { 'Authorization': `Bearer ${tudouTok}` }, }); const data = await r.json(); if (data.enabled && data.announcement.includes('smoke 测试公告')) { pass('4. tudou 拿到未读公告'); } else { fail('4. tudou 未读公告', new Error(`enabled=${data.enabled} content=${data.announcement.slice(0, 40)}`)); } } catch (e) { fail('4. tudou GET /announcement', e); } // ── 5. tudou 浏览器进任意路由(/app)应自动弹 modal ── const tudouLogin = await login(page, 'tudou', 'tudoupass123'); await setStorage(page, { token: tudouLogin.token, refresh: undefined, user: tudouLogin.user }); await page.goto(`${BASE}/app`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(2000); // 等 GlobalAnnouncementGate fetch + 渲染 try { const modalVisible = await page.locator('text=我知道了').isVisible({ timeout: 3000 }); if (modalVisible) pass('5. tudou 进 /app 自动强弹公告 modal'); else fail('5. 自动强弹', new Error('未找到"我知道了"按钮 — modal 没弹')); } catch (e) { fail('5. 自动强弹', e); } // ── 6. 关闭 modal → 标已读 → 再开页面不弹 ── try { await page.locator('button:has-text("我知道了")').click({ timeout: 3000 }); await page.waitForTimeout(1500); // 等 POST /announcement/read 完成 // 验证后端已标读 const r = await fetch(`${API}/api/v1/announcement`, { headers: { 'Authorization': `Bearer ${tudouTok}` }, }); const data = await r.json(); if (!data.enabled) pass('6. 关闭 modal 后 GET /announcement 返回 enabled=false (已读)'); else fail('6. 关闭后未读取', new Error(`期望 enabled=false 实际 ${data.enabled}`)); // 再开 /app 不该弹 await page.goto(`${BASE}/admin/dashboard`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(2000); const stillVisible = await page.locator('button:has-text("我知道了")').isVisible({ timeout: 1500 }).catch(() => false); if (!stillVisible) pass('6.1 再开页面不再弹 modal'); else fail('6.1 再次弹出', new Error('已读状态下还在弹')); } catch (e) { fail('6. 关闭流程', e); } // ── 7. 消息中心:accordion 列表 ── try { await page.goto(`${BASE}/notifications`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(2000); // 找带 [公告] chip 的行 const chip = await page.locator('text=/^公告$/').first().isVisible({ timeout: 3000 }); if (chip) pass('7. 消息中心显示 [公告] chip'); else fail('7. chip 缺失', new Error('未找到公告 chip')); // 折叠态:公告文字应该在 title 行(标题"系统公告")就能看到 const titleVisible = await page.locator('text=系统公告').first().isVisible({ timeout: 2000 }); if (titleVisible) pass('7.1 折叠态显示公告标题'); else fail('7.1 折叠态', new Error('看不到公告标题')); // ── 7.2 点击行展开 → 看到末尾独有 marker(只在展开态才能看到,折叠 preview 被 60 字 '…' 截断) ── // 用 chip [公告] 作为稳定点击锚点(chip 只在头部出现,不会跳到展开内容里) const chipLocator = page.locator('text=/^公告$/').first(); // 先验证折叠态看不到 marker const markerInCollapsed = await page.locator(`text=${uniqueMarker}`).first().isVisible({ timeout: 1000 }).catch(() => false); if (!markerInCollapsed) pass('7.2.0 折叠态 preview 截断,看不到末尾 marker'); else fail('7.2.0 折叠态泄漏', new Error('折叠状态下却看得到 marker,preview 没截断')); await chipLocator.click({ force: true, timeout: 3000 }); await page.waitForTimeout(800); const expandedMarker = await page.locator(`text=${uniqueMarker}`).first().isVisible({ timeout: 2000 }); if (expandedMarker) pass('7.2 点击行展开后显示完整 HTML 内容(看到末尾 marker)'); else fail('7.2 展开内容', new Error('展开后看不到 marker')); // ── 7.3 再点 chip → 收起(marker 不再可见) ── await chipLocator.click({ force: true, timeout: 3000 }); await page.waitForTimeout(800); const stillExpanded = await page.locator(`text=${uniqueMarker}`).first().isVisible({ timeout: 1000 }).catch(() => false); if (!stillExpanded) pass('7.3 再点同一行收起 (accordion,marker 不再可见)'); else fail('7.3 收起', new Error('再点没收起,marker 还可见')); } catch (e) { fail('7. 消息中心 accordion', e); } // 清场:把测试造的公告标已读,避免污染下一次 smoke await fetch(`${API}/api/v1/announcement/read`, { method: 'POST', headers: { 'Authorization': `Bearer ${adminTok}` }, }); await fetch(`${API}/api/v1/announcement/read`, { method: 'POST', headers: { 'Authorization': `Bearer ${tudouTok}` }, }); if (consoleErrors.length > 0) { fail('console errors', new Error(consoleErrors.slice(0, 3).join(' | '))); } else { pass('全程无 console.error'); } console.log('\n────────────── 汇总 ──────────────'); const passed = results.filter(r => r.ok).length; const failed = results.filter(r => !r.ok); console.log(`通过: ${passed} / ${results.length}`); if (failed.length > 0) { console.log(`失败 ${failed.length} 项:`); failed.forEach(r => console.log(` - ${r.name}: ${r.err}`)); } await browser.close(); process.exit(failed.length === 0 ? 0 : 1); } main().catch((e) => { console.error(e); process.exit(1); });