| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183 |
- // [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 档),共享远端不可执行。
- });
|