// 非功能·性能/兼容 冒烟规格 // 纯接口调用,不依赖前端 SPA(契合 CFC-ENV-001)。 // 对应文档化用例:tests/test-cases/*.md 中「## 非功能测试用例(性能/兼容/安全)」段的 性能/兼容 行。 // // 覆盖映射: // TC-G-N03 非微信H5兜底登录 -> test1 // TC-G-N02/P-N06/C-N03/T-N04/S-N03/X-N07 多端UA兼容 -> test2 // TC-A-N04/X-N02 限流熔断稳定性 -> test3 // TC-P-N02/X-N01 并发不串数据 -> test4 // 响应时间基线(多列表/搜索用例) -> test5 // TC-C-N01/X-N04 打卡幂等 -> test.skip (需任务流夹具, 见底部) // TC-X-N03 活动名额并发不超卖 -> test.skip (需活动夹具 + 压测授权, 见底部) // // 注:本文件只落地 API 层可断言项;前端渲染类与真实压测类保留为文档化设计,不在远程共享环境擅自施压。 const { test, expect } = require('@playwright/test'); const BASE = process.env.BASE_URL || 'http://cfc.iwintrue.com'; async function apiPost(request, path, body, token, extraHeaders) { const headers = { 'Content-Type': 'application/json' }; if (token) headers['Authorization'] = `Bearer ${token}`; if (extraHeaders) Object.assign(headers, extraHeaders); const r = await request.post(BASE + path, { headers, data: body || {} }); let json; try { json = await r.json(); } catch { json = {}; } return { status: r.status(), json, headers: r.headers() }; } // 惰性注册(带重试,规避远端部署窗口偶发非 JSON 响应) async function register(request, role, phone) { let lastErr; for (let i = 0; i < 4; i++) { try { const r = await request.post(BASE + '/api/auth/direct-register', { headers: { 'Content-Type': 'application/json' }, data: { phone: phone || ('13' + String(Math.floor(Math.random() * 1e9)).padStart(9, '0')), role, nickname: `QA-${role}-${i}` }, }); const j = await r.json().catch(() => ({})); if (j && j.code === 200 && j.data && j.data.token) return j.data.token; lastErr = j; } catch (e) { lastErr = e.message; } await new Promise((r) => setTimeout(r, 500)); } throw new Error(`register(${role}) 失败: ${JSON.stringify(lastErr)}`); } // 4 类客户端 UA const UAS = { iOS: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 MicroMessenger/8.0', Android: 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 Mobile Chrome/120.0 MicroMessenger/8.0', MiniProgram: 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0) AppleWebKit/605.1.15 MiniProgramEnv/WeChat', Web: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36', }; test('[TC-G-N03] 非微信H5兜底登录:手机号注册登录路径 UA 无关', async ({ request }) => { const token = await register(request, 'parent'); const r = await apiPost(request, '/api/family/member/list', {}, token); expect(r.status, `H5兜底登录后家庭列表应可访问,实际 HTTP=${r.status}`).toBe(200); expect(r.json.data !== undefined, '返回应含 data 字段').toBeTruthy(); }); test('[TC-G-N02/P-N06/C-N03/T-N04/S-N03/X-N07] 多端 UA 兼容:同请求不同客户端返回一致', async ({ request }) => { const token = await register(request, 'parent'); const results = {}; for (const [k, ua] of Object.entries(UAS)) { const r = await apiPost(request, '/api/family/member/list', {}, token, { 'User-Agent': ua }); results[k] = { status: r.status, dataLen: Array.isArray(r.json.data) ? r.json.data.length : -1 }; } // 兼容性核心:不同客户端不应导致接口行为分裂(不应出现部分 401/403/500) for (const [k, v] of Object.entries(results)) { expect([200, 401, 403].includes(v.status), `UA=${k} 状态异常 ${v.status}`).toBeTruthy(); } // 一致性:已登录态下各端 data 长度应相同 const lens = Object.values(results).map((v) => v.dataLen); expect(new Set(lens).size === 1, `各端返回数据长度不一致: ${JSON.stringify(results)}`).toBeTruthy(); }); test('[TC-A-N04/X-N02] 限流熔断稳定性:爆发请求下系统不死(不出现 5xx 雪崩)', async ({ request }) => { const token = await register(request, 'parent'); const N = 20; const calls = Array.from({ length: N }, () => apiPost(request, '/api/family/member/list', {}, token)); const res = await Promise.all(calls); const statuses = res.map((r) => r.status); const serverErr = statuses.filter((s) => s >= 500).length; expect(serverErr, `爆发请求下出现 ${serverErr}/${N} 个 5xx,疑似雪崩/无熔断`).toBe(0); const rateLimited = statuses.filter((s) => s === 429).length; // 仅信息性:若后端未部署限流器,rateLimited=0 属设计缺口(非用例失败) console.log(`[限流] 状态分布=${JSON.stringify(statuses)} 其中429=${rateLimited}`); }); test('[TC-P-N02/X-N01] 并发不串数据:并行 AI 请求各自独立响应', async ({ request }) => { const token = await register(request, 'parent'); // R170 需求变更对齐(S-cfc-163「聊天仅会员可见」,dc5522d09): // 该变更把 AI 教练能力收敛为会员专享,非会员直连返回 code=46003 // 「该功能为会员专享,请先开通会员」。因此**非会员夹具无法构造并发会话**, // 原断言 `code===200` 会恒假红(产品行为正确、用例未随需求更新)。 // 处置:显式识别门控并 skip(不静默放行、不改断言假绿), // 同时登记夹具缺口「缺会员账号夹具」→ G-170-1。 const probe = await apiPost(request, '/api/ai/health-coach/send', { query: '并发探针' }, token); test.skip(probe.json && probe.json.code === 46003, 'AI 教练受会员门控(S-cfc-163 / code=46003):缺会员夹具,并发隔离待补测'); const queries = ['帮我制定运动计划', '推荐儿童食谱', '如何改善睡眠', '英语启蒙建议', '亲子沟通技巧']; const calls = queries.map((q) => apiPost(request, '/api/ai/health-coach/send', { query: q }, token)); const res = await Promise.all(calls); res.forEach((r, i) => { expect(r.status, `第${i}个并行请求失败 HTTP=${r.status}`).toBe(200); expect(r.json && r.json.code === 200, `第${i}个请求返回异常: ${JSON.stringify(r.json).slice(0, 80)}`).toBeTruthy(); }); // 隔离信号:各并行请求应拿到各自独立的 conversationId(不串数据的关键判据) const convIds = res.map((r) => r.json.data && r.json.data.conversationId).filter(Boolean); expect(new Set(convIds).size, `并行请求 conversationId 重复,疑似串数据: ${convIds}`).toBe(convIds.length); }); test('[响应时间基线] 已登录家庭列表单请求耗时冒烟 ≤ 8s', async ({ request }) => { const token = await register(request, 'parent'); const t0 = Date.now(); const r = await apiPost(request, '/api/family/member/list', {}, token); const dt = Date.now() - t0; expect(r.status).toBe(200); console.log(`[耗时] 家庭列表 ${dt}ms`); expect(dt, `家庭列表响应 ${dt}ms 超过冒烟基线 8s`).toBeLessThan(8000); }); // —— 以下为需业务夹具 / 压测授权,暂不自动执行(避免消耗真实打卡/活动名额、不打压共享远端)—— test.skip('[TC-C-N01/X-N04] 任务打卡幂等:重复提交不重复计分(需任务流夹具)', async () => { // 前置:创建方案→生成任务→取得打卡入口;用同一 idempotencyKey 连发两次,断言第二次被幂等忽略。 // 因依赖完整打卡业务流与可重复测试夹具,留作设计,待补打卡 e2e 后启用。 }); test.skip('[TC-X-N03] 活动名额并发不超卖:100 名额 / 瞬时 500 请求(需活动夹具 + 压测授权)', async () => { // 真实压测:需构造测试活动 + 500 并发报名,断言接受数 ≤ 名额且不超卖。 // 属 execute 档(联网压测),须在用户显式授权且使用专用压测工具/隔离环境后执行,不在共享远端擅自施压。 });