/** * 四模块链路 E2E 集成测试(P1-3) * 场景:上传报告 → 解析入库 → 按规则生成方案 → 规划师审核 → 激活拆任务 → 孩子任务出现 → 重复激活幂等 → 全局统计 * * 运行前提: * - 后端已启动(本地 localhost:9082 或线上 https://ai.etotem.com.cn 同域代理) * - 环境变量: * API_BASE_URL 后端地址(默认 http://localhost:9082) * ADMIN_PHONE 管理员/规划师手机号 * ADMIN_PASSWORD 登录密码 * PDF_PATH 测试报告 PDF 绝对路径(默认跳过上传步骤,仅跑方案→任务段) * FAMILY_ID 演示家庭ID(激活后查孩子任务用) * CHILD_ID 孩子ID(激活后查孩子任务用) * PLAN_ID 已有 generated 方案ID(跳过生成/审核直接激活用) * * 运行:npx jest tests/integration/plan-chain.spec.js --forceExit * 输出:每一步 接口 / 请求 / 响应 code / 耗时(ms) */ describe('【主链路】报告解析→方案生成→任务分解→跟踪执行 E2E', () => { const BASE = process.env.API_BASE_URL || 'http://localhost:9082'; const PHONE = process.env.ADMIN_PHONE || ''; const PASSWORD = process.env.ADMIN_PASSWORD || ''; const PDF_PATH = process.env.PDF_PATH || ''; const FAMILY_ID = process.env.FAMILY_ID || ''; const CHILD_ID = process.env.CHILD_ID || ''; const PLAN_ID = process.env.PLAN_ID || ''; const state = { token: '', reportId: null, planId: null, taskCountBefore: 0, taskCountAfter: 0 }; const steps = []; let order = 0; async function post(path, body, { auth = true, multipart = false } = {}) { const headers = {}; if (!multipart) headers['Content-Type'] = 'application/json'; if (auth && state.token) headers['Authorization'] = 'Bearer ' + state.token; const t0 = Date.now(); let res; try { res = await fetch(BASE + path, { method: 'POST', headers, body: multipart ? body : (body === undefined ? undefined : JSON.stringify(body)), signal: AbortSignal.timeout(30000) }); } catch (e) { const ms = Date.now() - t0; steps.push({ no: ++order, api: path, body: safeBody(body), code: 'NET', ms, note: e.message }); console.error(`[${ms}ms] ${path} 网络错误: ${e.message}`); return null; } const ms = Date.now() - t0; let json = null; try { json = await res.json(); } catch (e) { /* 非JSON响应 */ } const code = json ? json.code : ('HTTP' + res.status); steps.push({ no: ++order, api: path, body: safeBody(body), code, ms, note: json && json.message ? String(json.message).substring(0, 60) : '' }); console.log(`[${ms}ms] POST ${path} -> code=${code}${json && json.message ? ' msg=' + json.message : ''}`); return json; } function safeBody(body) { if (!body) return {}; if (body instanceof FormData) return '{file}'; try { return JSON.stringify(body).substring(0, 200); } catch (e) { return String(body).substring(0, 200); } } test('[0] 前置环境检查', () => { expect(PHONE && PASSWORD).toBeTruthy(); console.log('BASE_URL =', BASE); console.log('PDF_PATH =', PDF_PATH || '(未提供,跳过上传段)'); console.log('FAMILY_ID =', FAMILY_ID || '(未提供,跳过孩子任务断言)'); console.log('CHILD_ID =', CHILD_ID || '(未提供,跳过孩子任务断言)'); }); test('[1] 登录管理后台获取 token', async () => { const json = await post('/api/admin-auth/login-by-password', { phone: PHONE, password: PASSWORD }, { auth: false }); expect(json).not.toBeNull(); expect(json.code).toBe(200); state.token = json.data && (json.data.token || json.data.accessToken); expect(state.token).toBeTruthy(); console.log('已获取 token:', String(state.token).substring(0, 20) + '...'); }); test('[2] 上传报告 PDF → 解析入库(P0-4/P1-1)', async () => { if (!PDF_PATH) { console.log('跳过(未提供 PDF_PATH)'); return; } const fs = require('fs'); const buf = fs.readFileSync(PDF_PATH); const fd = new FormData(); fd.append('file', new Blob([buf], { type: 'application/pdf' }), PDF_PATH.split(/[\\/]/).pop()); const json = await post('/api/health/report/upload', fd, { multipart: true }); expect(json).not.toBeNull(); if (json.code !== 200) { console.warn('上传未返回 200(可能 PDF 非募极生物肠道菌群报告或后端未装解析依赖):', json.message); return; } state.reportId = json.data && (json.data.id || json.data.reportId); console.log('报告已入库 reportId =', state.reportId); }); test('[3] 报告列表可见(入库落库验证)', async () => { if (!state.reportId) { console.log('跳过(无报告ID)'); return; } const json = await post('/api/health/report/list', {}); expect(json).not.toBeNull(); console.log('报告列表 total =', json.code === 200 && json.data ? (Array.isArray(json.data) ? json.data.length : json.data.total || '-') : '-'); }); test('[4] 按规则生成方案(DAN 报告确认触发 AssessmentPlanGenerator,P0-3)', async () => { const resultId = process.env.DAN_RESULT_ID; if (!resultId) { console.log('跳过(未提供 DAN_RESULT_ID;规则引擎在 /api/assessment/report/{resultId}/confirm-upload 触发)'); return; } const json = await post('/api/assessment/report/' + resultId + '/confirm-upload', {}); expect(json).not.toBeNull(); console.log('DAN 结果确认返回 code =', json.code); }); test('[5] 规划师审核方案生成的方案(P0-2 联调)', async () => { let planId = PLAN_ID; if (!planId) { // 取当前家庭最新 generated 方案(须有 familyId;此处由后端 JWT familyId 决定) const list = await post('/api/guide/plans/family-list', { status: 'generated' }); if (list && list.code === 200 && Array.isArray(list.data) && list.data.length > 0) { planId = list.data[0].id; } } if (!planId) { console.log('跳过(无可用 generated 方案;需先完成 DAN 报告确认或提供 PLAN_ID)'); return; } state.planId = planId; const json = await post('/api/guide/plans/review', { planId: Number(planId), approved: true }); expect(json).not.toBeNull(); if (json.code === 200) console.log('方案已批准 planId =', planId); else console.warn('方案审核结果:', json.message); }); test('[6] 激活方案 → 任务分解(P0-5)', async () => { if (!state.planId) { console.log('跳过(无方案ID)'); return; } if (FAMILY_ID && CHILD_ID) { const before = await post('/api/guide/families/' + FAMILY_ID + '/children/' + CHILD_ID + '/tasks', {}); state.taskCountBefore = before && before.code === 200 && Array.isArray(before.data) ? before.data.length : 0; console.log('激活前孩子任务数 =', state.taskCountBefore); } const json = await post('/api/guide/plans/activate', { planId: Number(state.planId) }); expect(json).not.toBeNull(); if (json.code === 200) console.log('方案已激活(引导任务 + 模板包任务已生成)'); else console.warn('激活失败:', json.message); }); test('[7] 孩子任务列表出现方案任务(跟踪执行)', async () => { if (!state.planId || !FAMILY_ID || !CHILD_ID) { console.log('跳过(缺 FAMILY_ID/CHILD_ID)'); return; } const json = await post('/api/guide/families/' + FAMILY_ID + '/children/' + CHILD_ID + '/tasks', {}); expect(json && json.code).toBe(200); const tasks = json && Array.isArray(json.data) ? json.data : []; const planTasks = tasks.filter(t => t.sourceType === 'plan' || t.taskType === 'plan'); console.log('激活后孩子任务数 =', tasks.length, ',其中方案任务 =', planTasks.length); expect(planTasks.length).toBeGreaterThan(0); }); test('[8] 重复激活幂等(不产生重复任务)', async () => { if (!state.planId) { console.log('跳过(无方案ID)'); return; } const json = await post('/api/guide/plans/activate', { planId: Number(state.planId) }); expect(json).not.toBeNull(); // 幂等表现:返回业务错误(400 状态提示),且不重复生成任务 console.log('重复激活响应 code =', json.code, 'message =', json.message); if (FAMILY_ID && CHILD_ID) { const after = await post('/api/guide/families/' + FAMILY_ID + '/children/' + CHILD_ID + '/tasks', {}); state.taskCountAfter = after && after.code === 200 && Array.isArray(after.data) ? after.data.length : -1; console.log('重复激活后孩子任务数 =', state.taskCountAfter, '(应等于激活后数量,不新增)'); } }); test('[9] 全局任务统计可查', async () => { const json = await post('/api/admin/tasks/stats', {}); expect(json).not.toBeNull(); console.log('tasks/stats code =', json.code); }); afterAll(() => { console.log('\n===== E2E 步序记录(告警: FAIL 表示 code!=200)====='); console.table(steps); const fails = steps.filter(s => String(s.code) !== '200'); if (fails.length) console.warn('存在非 200 步骤:', fails.map(f => '#' + f.no + ' ' + f.api).join(', ')); }); });