| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136 |
- // 非功能·性能/兼容 冒烟规格
- // 纯接口调用,不依赖前端 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 档(联网压测),须在用户显式授权且使用专用压测工具/隔离环境后执行,不在共享远端擅自施压。
- });
|