feat(iit): Complete CRA Agent V3.0 P0 milestone - autonomous QC pipeline

P0-1: Variable list sync from REDCap metadata
P0-2: QC rule configuration with JSON Logic + AI suggestion
P0-3: Scheduled QC + report generation + eQuery closed loop
P0-4: Unified dashboard + AI stream timeline + critical events

Backend:
- Add IitEquery, IitCriticalEvent Prisma models + migration
- Add cronEnabled/cronExpression to IitProject
- Implement eQuery service/controller/routes (CRUD + respond/review/close)
- Implement DailyQcOrchestrator (report -> eQuery -> critical events -> notify)
- Add AI rule suggestion service
- Register daily QC cron worker and eQuery auto-review worker
- Extend QC cockpit with timeline, trend, critical events APIs
- Fix timeline issues field compat (object vs array format)

Frontend:
- Create IIT business module with 6 pages (Dashboard, AI Stream, eQuery,
  Reports, Variable List + project config pages)
- Migrate IIT config from admin panel to business module
- Implement health score, risk heatmap, trend chart, critical event alerts
- Register IIT module in App router and top navigation

Testing:
- Add E2E API test script covering 7 modules (46 assertions, all passing)

Tested: E2E API tests 46/46 passed, backend and frontend verified
Made-with: Cursor
This commit is contained in:
2026-02-26 13:28:08 +08:00
parent 31b0433195
commit 203846968c
35 changed files with 7353 additions and 22 deletions

View File

@@ -0,0 +1,249 @@
/**
* 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);
});