butler-fallback.spec.js 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183
  1. // [TC-B-06] AI 成长教练 · LangGraph 不可用 Fallback —— 接口契约规格
  2. // ================================================================
  3. // 上游文档:tests/test-cases/butler.md → TC-B-06(P0);场景 tests/user-scenarios/08-butler.md SC-B-06
  4. // 运行:npx playwright test api/butler-fallback.spec.js -c playwright.config.api.js --project=desktop
  5. //
  6. // 契约来源(已核对后端实现):
  7. // cfc-backend/.../controller/ai/AIChatController.java#sendHealthCoachMessage
  8. // Result gateBlocked = checkGate(userId); // 会员门禁:FREE 且开关开启 → 46003
  9. // Map resp = aiGateway.chat(...);
  10. // String answer = resp != null ? resp.get("answer","") : ""; // 熔断/禁用 → resp=null → answer=""
  11. // return Result.success({answer, conversationId, recommendations, tasks}); // 仍 200
  12. //
  13. // 本规格按「门禁两态」设计,保证在 FREE 环境与会员环境都产出有效断言:
  14. // A. 门禁拦截态(FREE):拦截必须是明确业务码(46003/46004)、非 5xx、不泄露内部错误、不影响其他接口;
  15. // B. 门禁放行态(BASIC+):返回结构完整;降级(answer 空)时必须带 degraded/fallback 标记。
  16. //
  17. // ⚠ 已识别实现缺口(TC-B-06/2 为探针,勿删):
  18. // SC-B-06 要求「降级时明确提示 AI 服务降级中」,但当前实现降级态只把 answer 置空,
  19. // 响应体无 degraded / fallback 标记 → 前端无法区分「AI 说空话」与「AI 已降级」。
  20. // TC-B-06/2 在降级发生时判红,补标记后自动转绿。
  21. //
  22. // ⚠ 相关缺陷(复现于 2026-11-05 联调,已另行报告,非本规格断言项):
  23. // POST /api/membership/trial(激活试用会员)返回 500,导致测试账号无法自助升级为会员,
  24. // 故会员态用例需外部注入 AI_TEST_TOKEN。
  25. //
  26. // 环境说明:远程仅部署后端 API(CFC-ENV-001),故为纯接口层规格,不依赖 SPA。
  27. // 安全约定:不打印 token / 手机号。
  28. const { test, expect } = require('@playwright/test');
  29. const { apiPost } = require('../helpers/api');
  30. /** 注册账号:direct-register 冷启动实测可达 16s+,故显式 60s 超时 + 2 次重试 */
  31. async function register(request, role, tag) {
  32. const phone = '13' + String(Math.floor(Math.random() * 900000000) + 100000000);
  33. let lastErr;
  34. for (let i = 0; i < 3; i++) {
  35. try {
  36. const r = await request.post('/api/auth/direct-register', {
  37. data: { phone, role, nickname: `QA-${tag}` },
  38. timeout: 60000,
  39. });
  40. const body = await r.json().catch(() => ({}));
  41. if (body.code === 200 && body.data && body.data.token) {
  42. return { token: body.data.token, userId: body.data.userId };
  43. }
  44. lastErr = body;
  45. } catch (e) {
  46. lastErr = e.message;
  47. }
  48. }
  49. throw new Error(`register(${role}) 失败: ${JSON.stringify(lastErr)}`);
  50. }
  51. /** 优先用注入的会员账号(AI_TEST_TOKEN),否则注册 FREE 账号 */
  52. async function getAccount(request, tag) {
  53. if (process.env.AI_TEST_TOKEN) {
  54. return { token: process.env.AI_TEST_TOKEN, userId: null, injected: true };
  55. }
  56. const u = await register(request, 'parent', tag);
  57. return { ...u, injected: false };
  58. }
  59. /** AI 门禁状态 */
  60. async function aiGate(request, token) {
  61. const g = await apiPost(request, '/api/ai/gate', {}, token);
  62. const d = g.data || {};
  63. const canUse = d.canUse === true || d.canUse === 'true';
  64. return { canUse, status: d.status, memberLevel: d.memberLevel, code: g.code };
  65. }
  66. /** AI 教练对话 */
  67. function sendCoach(request, token, query) {
  68. return apiPost(request, '/api/ai/health-coach/send', { query }, token);
  69. }
  70. /** 门禁拦截的合法业务码(预期内的拒绝,不是故障) */
  71. const GATE_CODES = [46003, 46004];
  72. test.describe('[TC-B-06] AI 教练 Fallback 契约', () => {
  73. test('[TC-B-06/1] 门禁两态契约:放行则结构完整,拦截则明确业务码且不 5xx', async ({ request }) => {
  74. const u = await getAccount(request, 'TCB06-1');
  75. const gate = await aiGate(request, u.token);
  76. const r = await sendCoach(request, u.token, '孩子最近便秘,饮食上要注意什么?');
  77. expect(r.status, `HTTP 不应为服务端错误,实际 ${r.status}`).toBeLessThan(500);
  78. if (!gate.canUse) {
  79. // A. 拦截态:必须是明确业务码 + 可读提示,不能 500/空白
  80. test.info().annotations.push({ type: 'info', description: `门禁拦截态(${gate.status}/${gate.memberLevel}),校验拦截契约` });
  81. expect(GATE_CODES, `门禁拦截应返回 46003/46004,实际 code=${r.code} msg=${r.message}`).toContain(r.code);
  82. expect(String(r.message || '').length, '拦截应给出可读提示,不能为空').toBeGreaterThan(0);
  83. expect(
  84. /会员|续费|开通/.test(String(r.message || '')),
  85. `提示文案应引导开通/续费,实际「${r.message}」,用户无法自助解决`,
  86. ).toBeTruthy();
  87. console.log(`[TC-B-06/1] 拦截态 code=${r.code} msg=${r.message}`);
  88. return;
  89. }
  90. // B. 放行态:结构完整
  91. expect(r.code, `放行态业务码应为 200,实际 code=${r.code} msg=${r.message}`).toBe(200);
  92. expect(r.data, '放行态应含 data').toBeTruthy();
  93. expect(typeof r.data.answer, 'data.answer 必须是字符串(降级时为空串,不能 null/undefined)').toBe('string');
  94. expect(r.data, '返回结构应含 conversationId').toHaveProperty('conversationId');
  95. expect(Array.isArray(r.data.recommendations), 'recommendations 应为数组').toBeTruthy();
  96. expect(Array.isArray(r.data.tasks), 'tasks 应为数组').toBeTruthy();
  97. console.log(`[TC-B-06/1] 放行态 answer 长度=${(r.data.answer || '').length}`);
  98. });
  99. test('[TC-B-06/2] 降级态契约:answer 为空时必须携带降级标记(已知缺口探针)', async ({ request }) => {
  100. const u = await getAccount(request, 'TCB06-2');
  101. const gate = await aiGate(request, u.token);
  102. test.skip(!gate.canUse, `门禁未放行(${gate.status}/${gate.memberLevel}),降级契约需会员账号;可用 AI_TEST_TOKEN 注入`);
  103. const r = await sendCoach(request, u.token, '帮我看看孩子最近的成长情况');
  104. expect(r.code, `降级也不得报错,实际 code=${r.code} msg=${r.message}`).toBe(200);
  105. const answer = r.data && typeof r.data.answer === 'string' ? r.data.answer : '';
  106. if (answer.trim().length > 0) {
  107. test.info().annotations.push({ type: 'info', description: '本次 AI 正常返回,未触发降级,跳过降级标记校验' });
  108. console.log('[TC-B-06/2] AI 正常态,未触发降级');
  109. return;
  110. }
  111. const d = r.data || {};
  112. const degradedFlag =
  113. d.degraded === true || d.fallback === true || d.fallbackUsed === true || d.fallback_used === true;
  114. expect(
  115. degradedFlag,
  116. '[SC-B-06 缺口] AI 降级(answer 为空)但响应体未携带 degraded / fallback 标记,' +
  117. '前端无法提示「AI 服务降级中」,用户只会看到空白回答。' +
  118. '修复位置:AIChatController#sendHealthCoachMessage 的 resp==null 分支。',
  119. ).toBeTruthy();
  120. });
  121. test('[TC-B-06/3] 稳定性:连续 10 次请求无 5xx、无非预期业务码(含降级不雪崩)', async ({ request }) => {
  122. const u = await getAccount(request, 'TCB06-3');
  123. const gate = await aiGate(request, u.token);
  124. const N = 10;
  125. const statuses = [];
  126. const codes = [];
  127. for (let i = 0; i < N; i++) {
  128. // 串行而非并发:不在共享远端施压,只验证单链路稳定性
  129. const r = await sendCoach(request, u.token, `第${i + 1}次:儿童作息建议`);
  130. statuses.push(r.status);
  131. codes.push(r.code);
  132. }
  133. const serverErr = statuses.filter((s) => s >= 500);
  134. expect(serverErr.length, `出现 ${serverErr.length}/${N} 个 5xx:${JSON.stringify(statuses)}`).toBe(0);
  135. // 业务码只允许:200(成功/降级空答)或门禁码;出现其它(如 500/502/504 包装成业务码)判红
  136. const allowed = [200, ...GATE_CODES];
  137. const bad = codes.filter((c) => !allowed.includes(c));
  138. expect(bad, `出现非预期业务码 ${JSON.stringify(bad)}(全量 ${JSON.stringify(codes)}),疑似 AI 服务异常被吞成业务错误`).toHaveLength(0);
  139. console.log(`[TC-B-06/3] gate=${gate.canUse ? '放行' : '拦截'} status=${JSON.stringify(statuses)} code=${JSON.stringify(codes)}`);
  140. });
  141. test('[TC-B-06/4] AI 不可用不阻塞其他业务接口:AI 请求后家庭列表仍可用', async ({ request }) => {
  142. const u = await getAccount(request, 'TCB06-4');
  143. const gate = await aiGate(request, u.token);
  144. const ai = await sendCoach(request, u.token, '测试 AI 与业务接口隔离');
  145. expect(ai.status, 'AI 接口不应 5xx').toBeLessThan(500);
  146. const list = await apiPost(request, '/api/family/member/list', {}, u.token);
  147. expect(list.status, `AI 调用后家庭列表不可用,HTTP=${list.status}(疑似 AI 故障拖垮主流程)`).toBe(200);
  148. expect(list.data !== undefined, '家庭列表应返回 data').toBeTruthy();
  149. console.log(`[TC-B-06/4] gate=${gate.canUse ? '放行' : '拦截'} ai_code=${ai.code} family_code=${list.code}`);
  150. });
  151. });
  152. // —— 需可控故障注入,远程共享环境不具备条件,保留为设计 ——
  153. test.fixme('[TC-B-06/5] 熔断半开恢复:熔断打开→快速失败→恢复后自动切回(需注入 LangGraph 不可达)', async () => {
  154. // 设计:
  155. // 1. 注入 LangGraph 不可达(测试环境置 python.enabled=false 或断网),连续 3 次触发 failure-threshold;
  156. // 2. 断言后续请求快速失败(P95 < connectTimeout)而非拖到 readTimeout(120s),且立即返回降级结果;
  157. // 3. 恢复后等待 reset-timeout-ms(默认 30s,半开)再调用,断言回答质量恢复、无需重启;
  158. // 4. 半开期并发 10 次,断言仅少量探测放行、无大量失败(不抖动)。
  159. // 前置:需 tests 专用配置档 + 环境重启权限(execute 档),共享远端不可执行。
  160. });