/** * P0 端到端 API 测试脚本 * * 测试完整流水线: * 变量清单 → 规则配置 → 质控报告 → eQuery 闭环 → 驾驶舱 → AI 时间线 → 重大事件 * * 运行方式: npx tsx tests/e2e-p0-test.ts */ const BASE = 'http://localhost:3001/api/v1/admin/iit-projects'; const PROJECT_ID = 'test0102-pd-study'; let passCount = 0; let failCount = 0; const results: { name: string; ok: boolean; detail?: string }[] = []; async function api(method: string, path: string, body?: any) { const url = `${BASE}${path}`; const opts: RequestInit = { method }; if (body) { opts.headers = { 'Content-Type': 'application/json' }; opts.body = JSON.stringify(body); } const res = await fetch(url, opts); const json = await res.json().catch(() => null); return { status: res.status, data: json }; } function assert(name: string, condition: boolean, detail?: string) { if (condition) { passCount++; results.push({ name, ok: true }); console.log(` ✅ ${name}`); } else { failCount++; results.push({ name, ok: false, detail }); console.log(` ❌ ${name}${detail ? ` — ${detail}` : ''}`); } } // ========== Test Suites ========== async function testFieldMetadata() { console.log('\n📋 [1/7] 变量清单 API'); const { status, data } = await api('GET', `/${PROJECT_ID}/field-metadata`); assert('GET field-metadata 返回 200', status === 200, `status=${status}`); assert('返回 fields 数组', Array.isArray(data?.data?.fields), JSON.stringify(data?.data)?.substring(0, 100)); assert('fields 数量 > 0', (data?.data?.fields?.length || 0) > 0, `count=${data?.data?.fields?.length}`); assert('返回 forms 数组', Array.isArray(data?.data?.forms), `forms=${JSON.stringify(data?.data?.forms)?.substring(0, 80)}`); // Search const { data: searchData } = await api('GET', `/${PROJECT_ID}/field-metadata?search=age`); assert('search=age 返回结果', searchData?.data?.fields !== undefined); } async function testQcRules() { console.log('\n📐 [2/7] 规则配置 API'); const { status, data } = await api('GET', `/${PROJECT_ID}/rules`); assert('GET rules 返回 200', status === 200, `status=${status}`); assert('返回 rules 数组', Array.isArray(data?.data), `type=${typeof data?.data}`); // Create a test rule const newRule = { name: 'E2E_test_rule_auto', category: 'logic_check', field: 'age', logic: { '>': [{ var: 'age' }, 120] }, message: 'E2E 测试:年龄超过 120', severity: 'warning', }; const { status: createStatus, data: createData } = await api('POST', `/${PROJECT_ID}/rules`, newRule); assert('POST rules 创建规则 201/200', [200, 201].includes(createStatus), `status=${createStatus}`); const ruleId = createData?.data?.id; if (ruleId) { // Delete the test rule const { status: delStatus } = await api('DELETE', `/${PROJECT_ID}/rules/${ruleId}`); assert('DELETE rule 删除成功', [200, 204].includes(delStatus), `status=${delStatus}`); } // AI suggest (LLM 依赖,200 或 400/500 均可接受) const { status: suggestStatus } = await api('POST', `/${PROJECT_ID}/rules/suggest`, {}); assert('POST rules/suggest AI 建议可达 (非网络错误)', [200, 400, 500].includes(suggestStatus), `status=${suggestStatus}`); } async function testQcCockpit() { console.log('\n📊 [3/7] 质控驾驶舱 API'); const { status, data } = await api('GET', `/${PROJECT_ID}/qc-cockpit`); assert('GET qc-cockpit 返回 200', status === 200, `status=${status}`); assert('返回 stats 对象', data?.data?.stats !== undefined, `keys=${Object.keys(data?.data || {})}`); assert('stats 包含 totalRecords', typeof data?.data?.stats?.totalRecords === 'number'); assert('stats 包含 passRate', typeof data?.data?.stats?.passRate === 'number'); assert('返回 heatmap 对象', data?.data?.heatmap !== undefined); } async function testQcReport() { console.log('\n📄 [4/7] 质控报告 API'); // Get report (may be cached or generated) const { status, data } = await api('GET', `/${PROJECT_ID}/qc-cockpit/report`); assert('GET report 返回 200', status === 200, `status=${status}`); if (data?.data) { assert('报告包含 summary', data.data.summary !== undefined); assert('报告包含 criticalIssues', Array.isArray(data.data.criticalIssues)); assert('报告包含 warningIssues', Array.isArray(data.data.warningIssues)); assert('报告包含 formStats', Array.isArray(data.data.formStats)); assert('报告包含 llmFriendlyXml', typeof data.data.llmFriendlyXml === 'string'); } // Refresh report const { status: refreshStatus } = await api('POST', `/${PROJECT_ID}/qc-cockpit/report/refresh`); assert('POST report/refresh 返回 200', refreshStatus === 200, `status=${refreshStatus}`); } async function testEquery() { console.log('\n📨 [5/7] eQuery 闭环 API'); // Stats const { status: statsStatus, data: statsData } = await api('GET', `/${PROJECT_ID}/equeries/stats`); assert('GET equeries/stats 返回 200', statsStatus === 200, `status=${statsStatus}`); assert('stats 包含 total', typeof statsData?.data?.total === 'number'); assert('stats 包含 pending', typeof statsData?.data?.pending === 'number'); // List const { status: listStatus, data: listData } = await api('GET', `/${PROJECT_ID}/equeries`); assert('GET equeries 返回 200', listStatus === 200, `status=${listStatus}`); assert('返回 items 数组', Array.isArray(listData?.data?.items)); // Create a test eQuery via direct DB insert (simulate AI dispatch) // Instead, we test the respond/review flow if there are existing equeries // If none exist, we test with filter params const { status: filteredStatus } = await api('GET', `/${PROJECT_ID}/equeries?status=pending&severity=error`); assert('GET equeries 带过滤参数返回 200', filteredStatus === 200); // Test respond endpoint (will fail gracefully if no eQuery exists) const { status: respondStatus } = await api('POST', `/${PROJECT_ID}/equeries/nonexistent-id/respond`, { responseText: 'E2E test response', }); assert('POST respond 对不存在的 eQuery 返回 400/404/500', [400, 404, 500].includes(respondStatus), `status=${respondStatus}`); // Test close endpoint const { status: closeStatus } = await api('POST', `/${PROJECT_ID}/equeries/nonexistent-id/close`, { closedBy: 'e2e-test', }); assert('POST close 对不存在的 eQuery 返回 400/404/500', [400, 404, 500].includes(closeStatus), `status=${closeStatus}`); // Test review endpoint const { status: reviewStatus } = await api('POST', `/${PROJECT_ID}/equeries/nonexistent-id/review`, { passed: true, reviewNote: 'E2E test review', }); assert('POST review 对不存在的 eQuery 返回 400/404/500', [400, 404, 500].includes(reviewStatus), `status=${reviewStatus}`); } async function testTimeline() { console.log('\n⏱️ [6/7] AI 工作时间线 API'); const { status, data } = await api('GET', `/${PROJECT_ID}/qc-cockpit/timeline`); assert('GET timeline 返回 200', status === 200, `status=${status}`); assert('返回 items 数组', Array.isArray(data?.data?.items)); assert('返回 total 数字', typeof data?.data?.total === 'number'); if (data?.data?.items?.length > 0) { const item = data.data.items[0]; assert('timeline item 包含 description', typeof item.description === 'string'); assert('timeline item 包含 recordId', typeof item.recordId === 'string'); assert('timeline item 包含 status', typeof item.status === 'string'); assert('timeline item 包含 details.rulesEvaluated', typeof item.details?.rulesEvaluated === 'number'); } // Date filter const today = new Date().toISOString().split('T')[0]; const { status: dateStatus } = await api('GET', `/${PROJECT_ID}/qc-cockpit/timeline?date=${today}`); assert('GET timeline 带日期过滤返回 200', dateStatus === 200); } async function testTrendAndCriticalEvents() { console.log('\n📈 [7/7] 趋势 + 重大事件 API'); // Trend const { status: trendStatus, data: trendData } = await api('GET', `/${PROJECT_ID}/qc-cockpit/trend?days=30`); assert('GET trend 返回 200', trendStatus === 200, `status=${trendStatus}`); assert('trend 返回数组', Array.isArray(trendData?.data)); if (trendData?.data?.length > 0) { assert('trend item 包含 date + passRate', trendData.data[0].date && typeof trendData.data[0].passRate === 'number'); } // Critical events const { status: ceStatus, data: ceData } = await api('GET', `/${PROJECT_ID}/qc-cockpit/critical-events`); assert('GET critical-events 返回 200', ceStatus === 200, `status=${ceStatus}`); assert('返回 items 数组', Array.isArray(ceData?.data?.items)); assert('返回 total 数字', typeof ceData?.data?.total === 'number'); // With status filter const { status: ceFilterStatus } = await api('GET', `/${PROJECT_ID}/qc-cockpit/critical-events?status=open`); assert('GET critical-events 带状态过滤返回 200', ceFilterStatus === 200); } // ========== Main ========== async function main() { console.log('='.repeat(60)); console.log(' P0 端到端 API 测试'); console.log(` 项目: ${PROJECT_ID}`); console.log(` 后端: ${BASE}`); console.log('='.repeat(60)); // Health check try { const healthRes = await fetch('http://localhost:3001/health'); if (!healthRes.ok) throw new Error(`status ${healthRes.status}`); console.log('\n🟢 后端服务已启动'); } catch { console.error('\n🔴 后端服务未启动!请先运行 npm run dev'); process.exit(1); } await testFieldMetadata(); await testQcRules(); await testQcCockpit(); await testQcReport(); await testEquery(); await testTimeline(); await testTrendAndCriticalEvents(); // Summary console.log('\n' + '='.repeat(60)); console.log(` 测试结果: ${passCount} 通过 / ${failCount} 失败 / ${passCount + failCount} 总计`); if (failCount === 0) { console.log(' 🎉 全部通过!'); } else { console.log(' ⚠️ 有失败项,请检查:'); results.filter((r) => !r.ok).forEach((r) => { console.log(` ❌ ${r.name}${r.detail ? ` — ${r.detail}` : ''}`); }); } console.log('='.repeat(60)); process.exit(failCount > 0 ? 1 : 0); } main().catch((err) => { console.error('测试脚本异常:', err); process.exit(2); });