Ver código fonte

test: 新增 TC-B-06 AI 教练 Fallback 可执行规格(门禁两态契约)

liaoxg 1 semana atrás
pai
commit
853745a7a3
1 arquivos alterados com 183 adições e 0 exclusões
  1. 183 0
      tests/e2e/api/butler-fallback.spec.js

+ 183 - 0
tests/e2e/api/butler-fallback.spec.js

@@ -0,0 +1,183 @@
+// [TC-B-06] AI 成长教练 · LangGraph 不可用 Fallback —— 接口契约规格
+// ================================================================
+// 上游文档:tests/test-cases/butler.md → TC-B-06(P0);场景 tests/user-scenarios/08-butler.md SC-B-06
+// 运行:npx playwright test api/butler-fallback.spec.js -c playwright.config.api.js --project=desktop
+//
+// 契约来源(已核对后端实现):
+//   cfc-backend/.../controller/ai/AIChatController.java#sendHealthCoachMessage
+//     Result gateBlocked = checkGate(userId);            // 会员门禁:FREE 且开关开启 → 46003
+//     Map resp = aiGateway.chat(...);
+//     String answer = resp != null ? resp.get("answer","") : "";   // 熔断/禁用 → resp=null → answer=""
+//     return Result.success({answer, conversationId, recommendations, tasks});  // 仍 200
+//
+// 本规格按「门禁两态」设计,保证在 FREE 环境与会员环境都产出有效断言:
+//   A. 门禁拦截态(FREE):拦截必须是明确业务码(46003/46004)、非 5xx、不泄露内部错误、不影响其他接口;
+//   B. 门禁放行态(BASIC+):返回结构完整;降级(answer 空)时必须带 degraded/fallback 标记。
+//
+// ⚠ 已识别实现缺口(TC-B-06/2 为探针,勿删):
+//   SC-B-06 要求「降级时明确提示 AI 服务降级中」,但当前实现降级态只把 answer 置空,
+//   响应体无 degraded / fallback 标记 → 前端无法区分「AI 说空话」与「AI 已降级」。
+//   TC-B-06/2 在降级发生时判红,补标记后自动转绿。
+//
+// ⚠ 相关缺陷(复现于 2026-11-05 联调,已另行报告,非本规格断言项):
+//   POST /api/membership/trial(激活试用会员)返回 500,导致测试账号无法自助升级为会员,
+//   故会员态用例需外部注入 AI_TEST_TOKEN。
+//
+// 环境说明:远程仅部署后端 API(CFC-ENV-001),故为纯接口层规格,不依赖 SPA。
+// 安全约定:不打印 token / 手机号。
+
+const { test, expect } = require('@playwright/test');
+const { apiPost } = require('../helpers/api');
+
+/** 注册账号:direct-register 冷启动实测可达 16s+,故显式 60s 超时 + 2 次重试 */
+async function register(request, role, tag) {
+  const phone = '13' + String(Math.floor(Math.random() * 900000000) + 100000000);
+  let lastErr;
+  for (let i = 0; i < 3; i++) {
+    try {
+      const r = await request.post('/api/auth/direct-register', {
+        data: { phone, role, nickname: `QA-${tag}` },
+        timeout: 60000,
+      });
+      const body = await r.json().catch(() => ({}));
+      if (body.code === 200 && body.data && body.data.token) {
+        return { token: body.data.token, userId: body.data.userId };
+      }
+      lastErr = body;
+    } catch (e) {
+      lastErr = e.message;
+    }
+  }
+  throw new Error(`register(${role}) 失败: ${JSON.stringify(lastErr)}`);
+}
+
+/** 优先用注入的会员账号(AI_TEST_TOKEN),否则注册 FREE 账号 */
+async function getAccount(request, tag) {
+  if (process.env.AI_TEST_TOKEN) {
+    return { token: process.env.AI_TEST_TOKEN, userId: null, injected: true };
+  }
+  const u = await register(request, 'parent', tag);
+  return { ...u, injected: false };
+}
+
+/** AI 门禁状态 */
+async function aiGate(request, token) {
+  const g = await apiPost(request, '/api/ai/gate', {}, token);
+  const d = g.data || {};
+  const canUse = d.canUse === true || d.canUse === 'true';
+  return { canUse, status: d.status, memberLevel: d.memberLevel, code: g.code };
+}
+
+/** AI 教练对话 */
+function sendCoach(request, token, query) {
+  return apiPost(request, '/api/ai/health-coach/send', { query }, token);
+}
+
+/** 门禁拦截的合法业务码(预期内的拒绝,不是故障) */
+const GATE_CODES = [46003, 46004];
+
+test.describe('[TC-B-06] AI 教练 Fallback 契约', () => {
+  test('[TC-B-06/1] 门禁两态契约:放行则结构完整,拦截则明确业务码且不 5xx', async ({ request }) => {
+    const u = await getAccount(request, 'TCB06-1');
+    const gate = await aiGate(request, u.token);
+    const r = await sendCoach(request, u.token, '孩子最近便秘,饮食上要注意什么?');
+
+    expect(r.status, `HTTP 不应为服务端错误,实际 ${r.status}`).toBeLessThan(500);
+
+    if (!gate.canUse) {
+      // A. 拦截态:必须是明确业务码 + 可读提示,不能 500/空白
+      test.info().annotations.push({ type: 'info', description: `门禁拦截态(${gate.status}/${gate.memberLevel}),校验拦截契约` });
+      expect(GATE_CODES, `门禁拦截应返回 46003/46004,实际 code=${r.code} msg=${r.message}`).toContain(r.code);
+      expect(String(r.message || '').length, '拦截应给出可读提示,不能为空').toBeGreaterThan(0);
+      expect(
+        /会员|续费|开通/.test(String(r.message || '')),
+        `提示文案应引导开通/续费,实际「${r.message}」,用户无法自助解决`,
+      ).toBeTruthy();
+      console.log(`[TC-B-06/1] 拦截态 code=${r.code} msg=${r.message}`);
+      return;
+    }
+
+    // B. 放行态:结构完整
+    expect(r.code, `放行态业务码应为 200,实际 code=${r.code} msg=${r.message}`).toBe(200);
+    expect(r.data, '放行态应含 data').toBeTruthy();
+    expect(typeof r.data.answer, 'data.answer 必须是字符串(降级时为空串,不能 null/undefined)').toBe('string');
+    expect(r.data, '返回结构应含 conversationId').toHaveProperty('conversationId');
+    expect(Array.isArray(r.data.recommendations), 'recommendations 应为数组').toBeTruthy();
+    expect(Array.isArray(r.data.tasks), 'tasks 应为数组').toBeTruthy();
+    console.log(`[TC-B-06/1] 放行态 answer 长度=${(r.data.answer || '').length}`);
+  });
+
+  test('[TC-B-06/2] 降级态契约:answer 为空时必须携带降级标记(已知缺口探针)', async ({ request }) => {
+    const u = await getAccount(request, 'TCB06-2');
+    const gate = await aiGate(request, u.token);
+    test.skip(!gate.canUse, `门禁未放行(${gate.status}/${gate.memberLevel}),降级契约需会员账号;可用 AI_TEST_TOKEN 注入`);
+
+    const r = await sendCoach(request, u.token, '帮我看看孩子最近的成长情况');
+    expect(r.code, `降级也不得报错,实际 code=${r.code} msg=${r.message}`).toBe(200);
+
+    const answer = r.data && typeof r.data.answer === 'string' ? r.data.answer : '';
+    if (answer.trim().length > 0) {
+      test.info().annotations.push({ type: 'info', description: '本次 AI 正常返回,未触发降级,跳过降级标记校验' });
+      console.log('[TC-B-06/2] AI 正常态,未触发降级');
+      return;
+    }
+
+    const d = r.data || {};
+    const degradedFlag =
+      d.degraded === true || d.fallback === true || d.fallbackUsed === true || d.fallback_used === true;
+    expect(
+      degradedFlag,
+      '[SC-B-06 缺口] AI 降级(answer 为空)但响应体未携带 degraded / fallback 标记,' +
+        '前端无法提示「AI 服务降级中」,用户只会看到空白回答。' +
+        '修复位置:AIChatController#sendHealthCoachMessage 的 resp==null 分支。',
+    ).toBeTruthy();
+  });
+
+  test('[TC-B-06/3] 稳定性:连续 10 次请求无 5xx、无非预期业务码(含降级不雪崩)', async ({ request }) => {
+    const u = await getAccount(request, 'TCB06-3');
+    const gate = await aiGate(request, u.token);
+
+    const N = 10;
+    const statuses = [];
+    const codes = [];
+    for (let i = 0; i < N; i++) {
+      // 串行而非并发:不在共享远端施压,只验证单链路稳定性
+      const r = await sendCoach(request, u.token, `第${i + 1}次:儿童作息建议`);
+      statuses.push(r.status);
+      codes.push(r.code);
+    }
+
+    const serverErr = statuses.filter((s) => s >= 500);
+    expect(serverErr.length, `出现 ${serverErr.length}/${N} 个 5xx:${JSON.stringify(statuses)}`).toBe(0);
+
+    // 业务码只允许:200(成功/降级空答)或门禁码;出现其它(如 500/502/504 包装成业务码)判红
+    const allowed = [200, ...GATE_CODES];
+    const bad = codes.filter((c) => !allowed.includes(c));
+    expect(bad, `出现非预期业务码 ${JSON.stringify(bad)}(全量 ${JSON.stringify(codes)}),疑似 AI 服务异常被吞成业务错误`).toHaveLength(0);
+
+    console.log(`[TC-B-06/3] gate=${gate.canUse ? '放行' : '拦截'} status=${JSON.stringify(statuses)} code=${JSON.stringify(codes)}`);
+  });
+
+  test('[TC-B-06/4] AI 不可用不阻塞其他业务接口:AI 请求后家庭列表仍可用', async ({ request }) => {
+    const u = await getAccount(request, 'TCB06-4');
+    const gate = await aiGate(request, u.token);
+
+    const ai = await sendCoach(request, u.token, '测试 AI 与业务接口隔离');
+    expect(ai.status, 'AI 接口不应 5xx').toBeLessThan(500);
+
+    const list = await apiPost(request, '/api/family/member/list', {}, u.token);
+    expect(list.status, `AI 调用后家庭列表不可用,HTTP=${list.status}(疑似 AI 故障拖垮主流程)`).toBe(200);
+    expect(list.data !== undefined, '家庭列表应返回 data').toBeTruthy();
+    console.log(`[TC-B-06/4] gate=${gate.canUse ? '放行' : '拦截'} ai_code=${ai.code} family_code=${list.code}`);
+  });
+});
+
+// —— 需可控故障注入,远程共享环境不具备条件,保留为设计 ——
+test.fixme('[TC-B-06/5] 熔断半开恢复:熔断打开→快速失败→恢复后自动切回(需注入 LangGraph 不可达)', async () => {
+  // 设计:
+  // 1. 注入 LangGraph 不可达(测试环境置 python.enabled=false 或断网),连续 3 次触发 failure-threshold;
+  // 2. 断言后续请求快速失败(P95 < connectTimeout)而非拖到 readTimeout(120s),且立即返回降级结果;
+  // 3. 恢复后等待 reset-timeout-ms(默认 30s,半开)再调用,断言回答质量恢复、无需重启;
+  // 4. 半开期并发 10 次,断言仅少量探测放行、无大量失败(不抖动)。
+  // 前置:需 tests 专用配置档 + 环境重启权限(execute 档),共享远端不可执行。
+});