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
268 lines
9.7 KiB
TypeScript
268 lines
9.7 KiB
TypeScript
/**
|
||
* 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();
|