Parcourir la source

test(backend): 主链路E2E脚本与黑盒自测脚本

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
E2E Test Bot il y a 3 semaines
Parent
commit
67e1d5a5c7
2 fichiers modifiés avec 273 ajouts et 0 suppressions
  1. 181 0
      tests/integration/plan-chain.spec.js
  2. 92 0
      tests/selfcheck-plan-chain.ps1

+ 181 - 0
tests/integration/plan-chain.spec.js

@@ -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(', '));
+  });
+});

+ 92 - 0
tests/selfcheck-plan-chain.ps1

@@ -0,0 +1,92 @@
+# 黑盒自测脚本(接口级) — 对应 tests/BLACKBOX-TEST-PLAN.md 4.x 用例
+# 目标: http://cfc.iwintrue.com
+$ErrorActionPreference = "Continue"
+$BASE = "http://cfc.iwintrue.com"
+$results = @()
+
+function Invoke-Case([string]$id, [string]$role, [string]$desc, [string]$path, $body, [bool]$auth = $true, [int]$timeoutSec = 20) {
+    $headers = @{ "Content-Type" = "application/json" }
+    if ($auth -and $script:token) { $headers["Authorization"] = "Bearer $script:token" }
+    $jsonBody = if ($null -eq $body) { "{}" } else { $body | ConvertTo-Json -Depth 6 -Compress }
+    $t0 = Get-Date
+    try {
+        $resp = Invoke-RestMethod -Uri ($BASE + $path) -Method Post -Headers $headers -Body $jsonBody -TimeoutSec $timeoutSec
+        $ms = [int]((Get-Date) - $t0).TotalMilliseconds
+        $code = $resp.code
+        $note = $resp.message
+        $dataInfo = ""
+        if ($null -ne $resp.data) {
+            $dataInfo = (($resp.data | ConvertTo-Json -Depth 2 -Compress)).Substring(0, [Math]::Min(120, (($resp.data | ConvertTo-Json -Depth 2 -Compress)).Length))
+        }
+        $script:results += [pscustomobject]@{ id = $id; role = $role; api = $path; desc = $desc; code = $code; ms = $ms; note = $note; data = $dataInfo }
+        "PASS/INFO #$id [$role] $desc -> code=$code ($ms ms)${note} | $dataInfo "
+    } catch {
+        $ms = [int]((Get-Date) - $t0).TotalMilliseconds
+        $err = $_.Exception.Message
+        $code = "EX"
+        if ($_.Exception.Response) { try { $code = [int]$_.Exception.Response.StatusCode } catch {} }
+        $script:results += [pscustomobject]@{ id = $id; role = $role; api = $path; desc = $desc; code = $code; ms = $ms; note = $err; data = "" }
+        "FAIL #$id [$role] $desc -> $code ($ms ms) $err"
+    }
+}
+
+function Set-Token([string]$id) {
+    try {
+        $resp = Invoke-RestMethod -Uri ($BASE + "/api/admin-auth/login-by-password") -Method Post -Headers @{ "Content-Type" = "application/json" } -Body (@{ phone = "13800138000"; password = "admin123" } | ConvertTo-Json -Compress) -TimeoutSec 20
+        if ($resp.code -eq 200 -and $resp.data) {
+            $script:token = $resp.data.token
+            $script:results += [pscustomobject]@{ id = $id; role = "运营"; api = "/api/admin-auth/login-by-password"; desc = "管理端密码登录(种子账号)"; code = $resp.code; ms = 0; note = $resp.message; data = ("token=" + $script:token.Substring(0, 12) + "...") }
+            "TOKEN_OK phone=13800138000"
+        } else {
+            $script:results += [pscustomobject]@{ id = $id; role = "运营"; api = "/api/admin-auth/login-by-password"; desc = "管理端密码登录(种子账号)"; code = $resp.code; ms = 0; note = $resp.message; data = "" }
+            "TOKEN_FAIL: $($resp.message)"
+        }
+    } catch {
+        "TOKEN_EX: $($_.Exception.Message)"
+    }
+}
+
+# ===== 1. 登录 =====
+Set-Token "E2E-00"
+
+# ===== 2. 主链路 / 近期修复回归 =====
+Invoke-Case "P0-3"   "运营"   "方案生成规则列表(种子≥2条, BUG-C)"   "/api/admin/plan-rules/list"   $null
+Invoke-Case "P0-4"   "运营"   "报告解析中台类型列表(含gut_flora, BUG-D)" "/api/admin/report-parser/types" @{ page = 1; size = 100 }
+Invoke-Case "A-03"   "运营"   "方案管理列表 admin全量(不筛家庭, 无白屏根因)" "/api/guide/plans/family-list" @{ status = "" }
+Invoke-Case "TCH-11" "规划师" "成长计划列表(修复后不400, BUG-E)" "/api/growth/plan/list" @{ }
+Invoke-Case "A-02"   "运营"   "全局任务统计" "/api/admin/tasks/stats" $null
+Invoke-Case "A-05"   "运营"   "注册重复报告类型应400(幂等)" "/api/admin/report-parser/types/save" @{ typeId = "gut_flora"; displayName = "菌群检测" }
+Invoke-Case "A-16"   "运营"   "参数校验:空body删除知识库应400非500(BUG-K)" "/api/admin/knowledge-base/delete" @{ }
+
+# ===== 3. 顺带修复BUG回归 =====
+Invoke-Case "A-11"   "运营"   "创建勋章(自动badgeId, BUG-F)" "/api/badges/create" @{
+    name = "BLACKBOX自测勋章"; description = "黑盒自测"; icon = "🏅"; category = "打卡"; level = "铜牌";
+    rarity = "普通"; triggerType = "task_count"; threshold = 5; sortOrder = 1; expireDays = -1; isActive = 1
+}
+Invoke-Case "A-12"   "运营"   "新增供应商(字段契约映射, BUG-G)" "/api/admin/supplier/save" @{
+    nickname = "BLACKBOX供应商"; phone = "13800009999"; realName = "黑盒测试"; vendorType = "product"; vendorStatus = "active"
+}
+Invoke-Case "BUG-H"  "运营"   "已发布活动提交审核应400提示非500" "/api/admin/activity/submit-review" @{ id = 38 }
+Invoke-Case "BUG-I"  "运营"   "查询知识库(契约对齐, id=1)" "/api/admin/knowledge-base/get" @{ id = 1 }
+
+# 知识库删除:先建再删(自建数据)
+$kbSave = Invoke-Case "A-14a" "运营" "新建知识条目(供删除用)" "/api/admin/knowledge-base/save" @{ title = "BLACKBOX自测知识"; content = "自测内容"; status = 1 }
+$kbId = $null
+try {
+    $r2 = Invoke-RestMethod -Uri ($BASE + "/api/admin/knowledge-base/save") -Method Post -Headers @{ "Content-Type" = "application/json"; "Authorization" = "Bearer $script:token" } -Body (@{ title = "BLACKBOX自测知识" + [DateTime]::Now.Ticks; content = "自测内容"; status = 1 } | ConvertTo-Json) -TimeoutSec 15
+    if ($r2.code -eq 200) { $kbId = $r2.data.id; "KB_CREATED id=$kbId" }
+} catch { "KB_CREATE_EX: $($_.Exception.Message)" }
+if ($kbId) { Invoke-Case "A-14b" "运营" "删除知识条目(自建, BUG-I)" "/api/admin/knowledge-base/delete" @{ id = $kbId } }
+
+# 文章创建(计时, BUG-J)
+$artTitle = "BLACKBOX自测文章" + ([DateTime]::Now.Ticks % 100000)
+Invoke-Case "A-08" "运营" "创建文章(1s内返回预期, BUG-J)" "/api/admin/articles/create" @{
+    title = $artTitle; content = "这是一篇黑盒自测短文,验证创建接口响应耗时不超过10秒。"; summary = "自测";
+    author = "admin"; status = "draft"; articleType = "normal"
+}
+
+# ===== 4. 汇总 =====
+"`n========== 自测汇总 =========="
+$script:results | Format-Table -AutoSize id, role, desc, code, ms, note | Out-String -Width 200
+$fail = $script:results | Where-Object { $_.code -ne 200 }
+if ($fail) { "FAILED ITEMS: $($fail.id -join ', ')" } else { "ALL 200 (无失败项)" }