plan-chain.spec.js 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. /**
  2. * 四模块链路 E2E 集成测试(P1-3)
  3. * 场景:上传报告 → 解析入库 → 按规则生成方案 → 规划师审核 → 激活拆任务 → 孩子任务出现 → 重复激活幂等 → 全局统计
  4. *
  5. * 运行前提:
  6. * - 后端已启动(本地 localhost:9082 或线上 https://ai.etotem.com.cn 同域代理)
  7. * - 环境变量:
  8. * API_BASE_URL 后端地址(默认 http://localhost:9082)
  9. * ADMIN_PHONE 管理员/规划师手机号
  10. * ADMIN_PASSWORD 登录密码
  11. * PDF_PATH 测试报告 PDF 绝对路径(默认跳过上传步骤,仅跑方案→任务段)
  12. * FAMILY_ID 演示家庭ID(激活后查孩子任务用)
  13. * CHILD_ID 孩子ID(激活后查孩子任务用)
  14. * PLAN_ID 已有 generated 方案ID(跳过生成/审核直接激活用)
  15. *
  16. * 运行:npx jest tests/integration/plan-chain.spec.js --forceExit
  17. * 输出:每一步 接口 / 请求 / 响应 code / 耗时(ms)
  18. */
  19. describe('【主链路】报告解析→方案生成→任务分解→跟踪执行 E2E', () => {
  20. const BASE = process.env.API_BASE_URL || 'http://localhost:9082';
  21. const PHONE = process.env.ADMIN_PHONE || '';
  22. const PASSWORD = process.env.ADMIN_PASSWORD || '';
  23. const PDF_PATH = process.env.PDF_PATH || '';
  24. const FAMILY_ID = process.env.FAMILY_ID || '';
  25. const CHILD_ID = process.env.CHILD_ID || '';
  26. const PLAN_ID = process.env.PLAN_ID || '';
  27. const state = { token: '', reportId: null, planId: null, taskCountBefore: 0, taskCountAfter: 0 };
  28. const steps = [];
  29. let order = 0;
  30. async function post(path, body, { auth = true, multipart = false } = {}) {
  31. const headers = {};
  32. if (!multipart) headers['Content-Type'] = 'application/json';
  33. if (auth && state.token) headers['Authorization'] = 'Bearer ' + state.token;
  34. const t0 = Date.now();
  35. let res;
  36. try {
  37. res = await fetch(BASE + path, {
  38. method: 'POST',
  39. headers,
  40. body: multipart ? body : (body === undefined ? undefined : JSON.stringify(body)),
  41. signal: AbortSignal.timeout(30000)
  42. });
  43. } catch (e) {
  44. const ms = Date.now() - t0;
  45. steps.push({ no: ++order, api: path, body: safeBody(body), code: 'NET', ms, note: e.message });
  46. console.error(`[${ms}ms] ${path} 网络错误: ${e.message}`);
  47. return null;
  48. }
  49. const ms = Date.now() - t0;
  50. let json = null;
  51. try { json = await res.json(); } catch (e) { /* 非JSON响应 */ }
  52. const code = json ? json.code : ('HTTP' + res.status);
  53. steps.push({ no: ++order, api: path, body: safeBody(body), code, ms,
  54. note: json && json.message ? String(json.message).substring(0, 60) : '' });
  55. console.log(`[${ms}ms] POST ${path} -> code=${code}${json && json.message ? ' msg=' + json.message : ''}`);
  56. return json;
  57. }
  58. function safeBody(body) {
  59. if (!body) return {};
  60. if (body instanceof FormData) return '{file}';
  61. try { return JSON.stringify(body).substring(0, 200); } catch (e) { return String(body).substring(0, 200); }
  62. }
  63. test('[0] 前置环境检查', () => {
  64. expect(PHONE && PASSWORD).toBeTruthy();
  65. console.log('BASE_URL =', BASE);
  66. console.log('PDF_PATH =', PDF_PATH || '(未提供,跳过上传段)');
  67. console.log('FAMILY_ID =', FAMILY_ID || '(未提供,跳过孩子任务断言)');
  68. console.log('CHILD_ID =', CHILD_ID || '(未提供,跳过孩子任务断言)');
  69. });
  70. test('[1] 登录管理后台获取 token', async () => {
  71. const json = await post('/api/admin-auth/login-by-password', { phone: PHONE, password: PASSWORD }, { auth: false });
  72. expect(json).not.toBeNull();
  73. expect(json.code).toBe(200);
  74. state.token = json.data && (json.data.token || json.data.accessToken);
  75. expect(state.token).toBeTruthy();
  76. console.log('已获取 token:', String(state.token).substring(0, 20) + '...');
  77. });
  78. test('[2] 上传报告 PDF → 解析入库(P0-4/P1-1)', async () => {
  79. if (!PDF_PATH) { console.log('跳过(未提供 PDF_PATH)'); return; }
  80. const fs = require('fs');
  81. const buf = fs.readFileSync(PDF_PATH);
  82. const fd = new FormData();
  83. fd.append('file', new Blob([buf], { type: 'application/pdf' }), PDF_PATH.split(/[\\/]/).pop());
  84. const json = await post('/api/health/report/upload', fd, { multipart: true });
  85. expect(json).not.toBeNull();
  86. if (json.code !== 200) {
  87. console.warn('上传未返回 200(可能 PDF 非募极生物肠道菌群报告或后端未装解析依赖):', json.message);
  88. return;
  89. }
  90. state.reportId = json.data && (json.data.id || json.data.reportId);
  91. console.log('报告已入库 reportId =', state.reportId);
  92. });
  93. test('[3] 报告列表可见(入库落库验证)', async () => {
  94. if (!state.reportId) { console.log('跳过(无报告ID)'); return; }
  95. const json = await post('/api/health/report/list', {});
  96. expect(json).not.toBeNull();
  97. console.log('报告列表 total =', json.code === 200 && json.data ? (Array.isArray(json.data) ? json.data.length : json.data.total || '-') : '-');
  98. });
  99. test('[4] 按规则生成方案(DAN 报告确认触发 AssessmentPlanGenerator,P0-3)', async () => {
  100. const resultId = process.env.DAN_RESULT_ID;
  101. if (!resultId) { console.log('跳过(未提供 DAN_RESULT_ID;规则引擎在 /api/assessment/report/{resultId}/confirm-upload 触发)'); return; }
  102. const json = await post('/api/assessment/report/' + resultId + '/confirm-upload', {});
  103. expect(json).not.toBeNull();
  104. console.log('DAN 结果确认返回 code =', json.code);
  105. });
  106. test('[5] 规划师审核方案生成的方案(P0-2 联调)', async () => {
  107. let planId = PLAN_ID;
  108. if (!planId) {
  109. // 取当前家庭最新 generated 方案(须有 familyId;此处由后端 JWT familyId 决定)
  110. const list = await post('/api/guide/plans/family-list', { status: 'generated' });
  111. if (list && list.code === 200 && Array.isArray(list.data) && list.data.length > 0) {
  112. planId = list.data[0].id;
  113. }
  114. }
  115. if (!planId) { console.log('跳过(无可用 generated 方案;需先完成 DAN 报告确认或提供 PLAN_ID)'); return; }
  116. state.planId = planId;
  117. const json = await post('/api/guide/plans/review', { planId: Number(planId), approved: true });
  118. expect(json).not.toBeNull();
  119. if (json.code === 200) console.log('方案已批准 planId =', planId);
  120. else console.warn('方案审核结果:', json.message);
  121. });
  122. test('[6] 激活方案 → 任务分解(P0-5)', async () => {
  123. if (!state.planId) { console.log('跳过(无方案ID)'); return; }
  124. if (FAMILY_ID && CHILD_ID) {
  125. const before = await post('/api/guide/families/' + FAMILY_ID + '/children/' + CHILD_ID + '/tasks', {});
  126. state.taskCountBefore = before && before.code === 200 && Array.isArray(before.data)
  127. ? before.data.length : 0;
  128. console.log('激活前孩子任务数 =', state.taskCountBefore);
  129. }
  130. const json = await post('/api/guide/plans/activate', { planId: Number(state.planId) });
  131. expect(json).not.toBeNull();
  132. if (json.code === 200) console.log('方案已激活(引导任务 + 模板包任务已生成)');
  133. else console.warn('激活失败:', json.message);
  134. });
  135. test('[7] 孩子任务列表出现方案任务(跟踪执行)', async () => {
  136. if (!state.planId || !FAMILY_ID || !CHILD_ID) { console.log('跳过(缺 FAMILY_ID/CHILD_ID)'); return; }
  137. const json = await post('/api/guide/families/' + FAMILY_ID + '/children/' + CHILD_ID + '/tasks', {});
  138. expect(json && json.code).toBe(200);
  139. const tasks = json && Array.isArray(json.data) ? json.data : [];
  140. const planTasks = tasks.filter(t => t.sourceType === 'plan' || t.taskType === 'plan');
  141. console.log('激活后孩子任务数 =', tasks.length, ',其中方案任务 =', planTasks.length);
  142. expect(planTasks.length).toBeGreaterThan(0);
  143. });
  144. test('[8] 重复激活幂等(不产生重复任务)', async () => {
  145. if (!state.planId) { console.log('跳过(无方案ID)'); return; }
  146. const json = await post('/api/guide/plans/activate', { planId: Number(state.planId) });
  147. expect(json).not.toBeNull();
  148. // 幂等表现:返回业务错误(400 状态提示),且不重复生成任务
  149. console.log('重复激活响应 code =', json.code, 'message =', json.message);
  150. if (FAMILY_ID && CHILD_ID) {
  151. const after = await post('/api/guide/families/' + FAMILY_ID + '/children/' + CHILD_ID + '/tasks', {});
  152. state.taskCountAfter = after && after.code === 200 && Array.isArray(after.data) ? after.data.length : -1;
  153. console.log('重复激活后孩子任务数 =', state.taskCountAfter, '(应等于激活后数量,不新增)');
  154. }
  155. });
  156. test('[9] 全局任务统计可查', async () => {
  157. const json = await post('/api/admin/tasks/stats', {});
  158. expect(json).not.toBeNull();
  159. console.log('tasks/stats code =', json.code);
  160. });
  161. afterAll(() => {
  162. console.log('\n===== E2E 步序记录(告警: FAIL 表示 code!=200)=====');
  163. console.table(steps);
  164. const fails = steps.filter(s => String(s.code) !== '200');
  165. if (fails.length) console.warn('存在非 200 步骤:', fails.map(f => '#' + f.no + ' ' + f.api).join(', '));
  166. });
  167. });