|
|
@@ -0,0 +1,181 @@
|
|
|
+/**
|
|
|
+ * 四模块链路 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(', '));
|
|
|
+ });
|
|
|
+});
|