nonfunc-perf-compat.spec.js 7.9 KB

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