Files
AIclinicalresearch/backend/tests/e2e-phase2-phase3-test.ts
HaHafeng 0b29fe88b5 feat(iit): QC deep fix + V3.1 architecture plan + project member management
QC System Deep Fix:
- HardRuleEngine: add null tolerance + field availability pre-check (skipped status)
- SkillRunner: baseline data merge for follow-up events + field availability check
- QcReportService: record-level pass rate calculation + accurate LLM XML report
- iitBatchController: legacy log cleanup (eventId=null) + upsert RecordSummary
- seed-iit-qc-rules: null/empty string tolerance + applicableEvents config

V3.1 Architecture Design (docs only, no code changes):
- QC engine V3.1 plan: 5-level data structure (CDISC ODM) + D1-D7 dimensions
- Three-batch implementation strategy (A: foundation, B: bubbling, C: new engines)
- Architecture team review: 4 whitepapers reviewed + feedback doc + 4 critical suggestions
- CRA Agent strategy roadmap + CRA 4-tool explanation doc for clinical experts

Project Member Management:
- Cross-tenant member search and assignment (remove tenant restriction)
- IIT project detail page enhancement with tabbed layout (KB + members)
- IitProjectContext for business-side project selection
- System-KB route access control adjustment for project operators

Frontend:
- AdminLayout sidebar menu restructure
- IitLayout with project context provider
- IitMemberManagePage new component
- Business-side pages adapt to project context

Prisma:
- 2 new migrations (user-project RBAC + is_demo flag)
- Schema updates for project member management

Made-with: Cursor
2026-03-01 15:27:05 +08:00

268 lines
9.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Phase 2 + Phase 3 联合端到端测试
*
* 测试内容:
* 1. 认证中间件IIT admin 路由需要 Token
* 2. RBAC 角色权限(不同角色看到不同内容)
* 3. /my-projects API用户-项目关联)
* 4. 租户过滤(项目按 tenantId 隔离)
* 5. 租户选项 API
* 6. 项目创建带 tenantId
* 7. IIT_OPERATOR 角色枚举
* 8. UserRole / TenantOption 类型完整性
*
* 运行方式: npx tsx tests/e2e-phase2-phase3-test.ts
*/
const AUTH_BASE = 'http://localhost:3001/api/v1/auth';
const ADMIN_IIT_BASE = 'http://localhost:3001/api/v1/admin/iit-projects';
const IIT_BASE = 'http://localhost:3001/api/v1/iit';
let passCount = 0;
let failCount = 0;
const results: { name: string; ok: boolean; detail?: string }[] = [];
// ========== Helpers ==========
async function rawFetch(url: string, opts?: RequestInit) {
const res = await fetch(url, opts);
const json = await res.json().catch(() => null);
return { status: res.status, data: json };
}
async function authApi(method: string, url: string, token: string, body?: any) {
const opts: RequestInit = {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
};
if (body) opts.body = JSON.stringify(body);
return rawFetch(url, opts);
}
async function login(phone: string, password: string = '123456'): Promise<string | null> {
const { status, data } = await rawFetch(`${AUTH_BASE}/login/password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, password }),
});
if (status === 200 && data?.data?.tokens?.accessToken) {
return data.data.tokens.accessToken;
}
return null;
}
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 testAuthMiddleware() {
console.log('\n🔒 [1/8] IIT Admin 路由认证中间件');
// 无 Token 访问应返回 401
const { status: noTokenStatus } = await rawFetch(`${ADMIN_IIT_BASE}`, { method: 'GET' });
assert('无 Token 访问 GET /iit-projects → 401', noTokenStatus === 401, `status=${noTokenStatus}`);
const { status: noTokenStatus2 } = await rawFetch(`${ADMIN_IIT_BASE}/tenant-options`, { method: 'GET' });
assert('无 Token 访问 GET /tenant-options → 401', noTokenStatus2 === 401, `status=${noTokenStatus2}`);
}
async function testRoleForbidden() {
console.log('\n🚫 [2/8] RBAC: 普通 USER 不能访问 IIT Admin');
const userToken = await login('13800138003'); // 王医生, role=USER
assert('普通用户登录成功', userToken !== null);
if (userToken) {
const { status } = await authApi('GET', ADMIN_IIT_BASE, userToken);
assert('USER 角色访问 IIT Admin → 403', status === 403, `status=${status}`);
}
}
async function testSuperAdminAccess() {
console.log('\n👑 [3/8] SUPER_ADMIN 全权限访问');
const adminToken = await login('13800000001'); // 超级管理员
assert('SUPER_ADMIN 登录成功', adminToken !== null);
if (adminToken) {
const { status, data } = await authApi('GET', ADMIN_IIT_BASE, adminToken);
assert('SUPER_ADMIN 访问项目列表 → 200', status === 200, `status=${status}`);
assert('返回项目数组', Array.isArray(data?.data), `type=${typeof data?.data}`);
// 项目带 tenantName
const projects = data?.data || [];
if (projects.length > 0) {
assert('项目含 tenantName 字段', projects[0].tenantName !== undefined,
`keys=${Object.keys(projects[0]).join(',')}`);
assert('项目含 tenantId 字段', projects[0].tenantId !== undefined);
}
}
return adminToken;
}
async function testTenantOptions(adminToken: string) {
console.log('\n🏢 [4/8] 租户选项 API');
const { status, data } = await authApi('GET', `${ADMIN_IIT_BASE}/tenant-options`, adminToken);
assert('GET /tenant-options → 200', status === 200, `status=${status}`);
assert('返回租户数组', Array.isArray(data?.data), `type=${typeof data?.data}`);
const tenants = data?.data || [];
assert('租户数量 > 0', tenants.length > 0, `count=${tenants.length}`);
if (tenants.length > 0) {
assert('租户含 id/name/code/type',
tenants[0].id && tenants[0].name && tenants[0].code && tenants[0].type,
`keys=${Object.keys(tenants[0]).join(',')}`);
}
return tenants;
}
async function testTenantFiltering(adminToken: string) {
console.log('\n🔍 [5/8] 租户过滤');
// 壹证循科技 的 tenantId
const yizhengxunId = 'eb4e93b7-0210-4bf5-b853-cbea49cdadf8';
// 不带租户过滤 → 返回所有项目
const { data: allData } = await authApi('GET', ADMIN_IIT_BASE, adminToken);
const allCount = allData?.data?.length || 0;
assert('无过滤返回所有项目', allCount > 0, `count=${allCount}`);
// 带租户过滤 → 只返回该租户项目
const { data: filteredData } = await authApi('GET',
`${ADMIN_IIT_BASE}?tenantId=${yizhengxunId}`, adminToken);
const filteredProjects = filteredData?.data || [];
assert('按租户过滤返回结果', filteredProjects.length > 0, `count=${filteredProjects.length}`);
assert('过滤结果中所有项目 tenantId 一致',
filteredProjects.every((p: any) => p.tenantId === yizhengxunId),
`tenantIds=${filteredProjects.map((p: any) => p.tenantId).join(',')}`);
// 不存在的租户 → 返回空数组
const { data: emptyData } = await authApi('GET',
`${ADMIN_IIT_BASE}?tenantId=non-existent-tenant`, adminToken);
assert('不存在的租户返回空数组', (emptyData?.data?.length || 0) === 0);
}
async function testPharmaAdminIsolation() {
console.log('\n🏭 [6/8] PHARMA_ADMIN 租户隔离');
const pharmaToken = await login('13800000006'); // 药企管理员, tenant=takeda
assert('PHARMA_ADMIN 登录成功', pharmaToken !== null);
if (pharmaToken) {
const { status, data } = await authApi('GET', ADMIN_IIT_BASE, pharmaToken);
assert('PHARMA_ADMIN 可以访问项目列表', status === 200, `status=${status}`);
const projects = data?.data || [];
// PHARMA_ADMIN 的 tenantId 是 tenant-takeda而测试项目属于 壹证循科技
// 所以不应该看到测试项目
const takedaTenantId = 'tenant-takeda';
const allMatchTenant = projects.every((p: any) => p.tenantId === takedaTenantId);
assert('PHARMA_ADMIN 只能看到自己租户的项目(或空)',
projects.length === 0 || allMatchTenant,
`count=${projects.length}, tenantIds=${projects.map((p: any) => p.tenantId).join(',')}`);
}
}
async function testMyProjectsApi() {
console.log('\n👤 [7/8] /my-projects API用户-项目关联)');
// 无 Token → 401
const { status: noTokenStatus } = await rawFetch(`${IIT_BASE}/my-projects`, { method: 'GET' });
assert('无 Token 访问 /my-projects → 401', noTokenStatus === 401, `status=${noTokenStatus}`);
// 登录用户(有 IitUserMapping 关联)
const adminToken = await login('13800000001');
if (adminToken) {
const { status, data } = await authApi('GET', `${IIT_BASE}/my-projects`, adminToken);
assert('/my-projects 返回 200', status === 200, `status=${status}`);
assert('返回 data 数组', Array.isArray(data?.data), `type=${typeof data?.data}`);
// 当前超级管理员可能没有 IitUserMapping所以可能是空数组
// 但 API 不应报错
const projects = data?.data || [];
assert('/my-projects 不报错(即使无关联项目)', true, `count=${projects.length}`);
if (projects.length > 0) {
assert('项目含 myRole 字段', projects[0].myRole !== undefined,
`keys=${Object.keys(projects[0]).join(',')}`);
}
}
}
async function testProjectDetailWithTenant(adminToken: string) {
console.log('\n📋 [8/8] 项目详情含租户信息');
const projectId = 'test0102-pd-study';
const { status, data } = await authApi('GET', `${ADMIN_IIT_BASE}/${projectId}`, adminToken);
assert('GET 项目详情 → 200', status === 200, `status=${status}`);
const project = data?.data;
assert('项目详情含 tenantId', project?.tenantId !== undefined && project?.tenantId !== null,
`tenantId=${project?.tenantId}`);
assert('项目详情含 tenant 对象', project?.tenant !== undefined,
`tenant=${JSON.stringify(project?.tenant)}`);
if (project?.tenant) {
assert('tenant 含 name', !!project.tenant.name, `name=${project.tenant.name}`);
}
}
// ========== Main ==========
async function main() {
console.log('🚀 Phase 2 + Phase 3 联合端到端测试');
console.log('='.repeat(60));
try {
// Phase 2 Tests
await testAuthMiddleware();
await testRoleForbidden();
const adminToken = await testSuperAdminAccess();
await testMyProjectsApi();
// Phase 3 Tests
if (adminToken) {
const tenants = await testTenantOptions(adminToken);
await testTenantFiltering(adminToken);
await testProjectDetailWithTenant(adminToken);
}
await testPharmaAdminIsolation();
} catch (err) {
console.error('\n💥 测试执行异常:', err);
failCount++;
}
// Summary
console.log('\n' + '='.repeat(60));
console.log(`📊 测试结果: ${passCount} 通过, ${failCount} 失败, 共 ${passCount + failCount}`);
if (failCount > 0) {
console.log('\n❌ 失败项:');
results.filter(r => !r.ok).forEach(r => {
console.log(`${r.name}${r.detail ? `${r.detail}` : ''}`);
});
}
console.log('\n' + (failCount === 0 ? '🎉 全部通过!' : '⚠️ 有失败项,请检查'));
process.exit(failCount > 0 ? 1 : 0);
}
main();