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