316 lines
38 KiB
TypeScript
316 lines
38 KiB
TypeScript
process.env.NODE_ENV = 'test';
|
||
process.env.DATABASE_URL = 'pg-mem://';
|
||
process.env.INITIAL_ADMIN_PASSWORD = 'AdminTest123!';
|
||
process.env.COS_CONFIG_ENCRYPTION_KEY = 'test-only-encryption-key-at-least-32-characters';
|
||
|
||
const { default: app } = await import('../api/app.js');
|
||
const { database, closeDatabase } = await import('../api/database.js');
|
||
const { encryptSecret } = await import('../api/configCrypto.js');
|
||
const server = app.listen(0, '127.0.0.1');
|
||
await new Promise<void>((resolve) => server.once('listening', resolve));
|
||
const address = server.address();
|
||
if (!address || typeof address === 'string') throw new Error('测试服务启动失败');
|
||
const base = `http://127.0.0.1:${address.port}`;
|
||
|
||
async function request(path: string, init: RequestInit = {}, cookie?: string) {
|
||
const response = await fetch(`${base}${path}`, { ...init, headers: { ...(init.headers || {}), ...(cookie ? { Cookie: cookie } : {}) } });
|
||
const body = response.status === 204 ? null : await response.json();
|
||
return { response, body };
|
||
}
|
||
function expectStatus(actual: number, expected: number, label: string, body?: unknown) {
|
||
if (actual !== expected) throw new Error(`${label}: 期望 ${expected},实际 ${actual},响应 ${JSON.stringify(body)}`);
|
||
}
|
||
|
||
try {
|
||
const health = await request('/api/health'); expectStatus(health.response.status, 200, '健康检查');
|
||
if ((health.body as { database?: string }).database !== 'postgres') throw new Error('测试未运行在 PostgreSQL 查询层');
|
||
|
||
const login = await request('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'AdminTest123!' }) });
|
||
expectStatus(login.response.status, 200, '平台管理员登录');
|
||
const adminCookie = login.response.headers.get('set-cookie')?.split(';')[0];
|
||
if (!adminCookie) throw new Error('登录未返回会话 Cookie');
|
||
const group = await request('/api/management/groups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '测试运营组', username: 'test_manager', display_name: '测试管理员', password: 'Manager123!' }) }, adminCookie);
|
||
expectStatus(group.response.status, 201, '创建运营组');
|
||
const groupId = Number((group.body as { id: number }).id);
|
||
|
||
const duplicateGroupAdmin = await request('/api/management/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group_id: groupId, username: 'second_manager', display_name: '林溪', password: 'Manager123!', role: 'group_admin' }) }, adminCookie);
|
||
expectStatus(duplicateGroupAdmin.response.status, 409, '拒绝第二位组管理员', duplicateGroupAdmin.body);
|
||
const firstOperator = await request('/api/management/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group_id: groupId, username: 'first_operator', display_name: '林溪', password: 'Operator123!', role: 'operator' }) }, adminCookie);
|
||
expectStatus(firstOperator.response.status, 201, '创建第一位光影叙事', firstOperator.body);
|
||
const secondOperator = await request('/api/management/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ group_id: groupId, username: 'second_operator', display_name: '周宁', password: 'Operator123!', role: 'operator' }) }, adminCookie);
|
||
expectStatus(secondOperator.response.status, 201, '创建第二位光影叙事', secondOperator.body);
|
||
const secondPlatformAdmin = await request('/api/management/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'second_admin', display_name: '平台管理员乙', password: 'AdminTest123!', role: 'platform_admin' }) }, adminCookie);
|
||
expectStatus(secondPlatformAdmin.response.status, 201, '创建第二位平台管理员', secondPlatformAdmin.body);
|
||
const renameOperator = await request(`/api/management/users/${Number((firstOperator.body as { id: number }).id)}/name`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: '林清溪' }) }, adminCookie);
|
||
expectStatus(renameOperator.response.status, 200, '修改光影叙事用户名', renameOperator.body);
|
||
const groupAccounts = await request(`/api/management/users?groupId=${groupId}`, {}, adminCookie);
|
||
expectStatus(groupAccounts.response.status, 200, '读取组内账号', groupAccounts.body);
|
||
const groupUsers = groupAccounts.body as Array<{ display_name: string; role: string }>;
|
||
if (groupUsers.filter((item) => item.role === 'group_admin').length !== 1 || groupUsers.filter((item) => item.role === 'operator').length !== 2) throw new Error('运营组账号数量规则未正确执行');
|
||
const allAccounts = await request('/api/management/users', {}, adminCookie);
|
||
expectStatus(allAccounts.response.status, 200, '读取平台账号', allAccounts.body);
|
||
if ((allAccounts.body as Array<{ role: string }>).filter((item) => item.role === 'platform_admin').length !== 2) throw new Error('多个平台管理员未正确创建');
|
||
const replaceAdmin = await request(`/api/management/groups/${groupId}/replace-admin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: Number((firstOperator.body as { id: number }).id), previous_action: 'demote' }) }, adminCookie);
|
||
expectStatus(replaceAdmin.response.status, 200, '更换组管理员', replaceAdmin.body);
|
||
const replacedAccounts = await request(`/api/management/users?groupId=${groupId}`, {}, adminCookie);
|
||
const replacedUsers = replacedAccounts.body as Array<{ id:number;role:string;last_login_at:string|null }>;
|
||
if (replacedUsers.filter((item)=>item.role==='group_admin').length!==1 || replacedUsers.find((item)=>item.role==='group_admin')?.id!==Number((firstOperator.body as {id:number}).id)) throw new Error('组管理员更换未保持唯一身份');
|
||
const groupsAfterReplace = await request('/api/management/groups', {}, adminCookie);
|
||
expectStatus(groupsAfterReplace.response.status, 200, '读取运营组统计', groupsAfterReplace.body);
|
||
const groupStats=(groupsAfterReplace.body as Array<{id:number;user_count:number;operator_count:number;group_admin_name:string|null}>).find((item)=>item.id===groupId);
|
||
if(!groupStats||groupStats.user_count!==3||groupStats.operator_count!==2||!groupStats.group_admin_name)throw new Error('运营组统计不正确');
|
||
const renameGroup=await request(`/api/management/groups/${groupId}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'已更名运营组'})},adminCookie);
|
||
expectStatus(renameGroup.response.status,200,'修改运营组名称',renameGroup.body);
|
||
if((renameGroup.body as {name:string}).name!=='已更名运营组')throw new Error('运营组名称未正确更新');
|
||
const newAdminLogin=await request('/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:'first_operator',password:'Operator123!'})});
|
||
expectStatus(newAdminLogin.response.status,200,'新组管理员登录',newAdminLogin.body);
|
||
const newAdminCookie=newAdminLogin.response.headers.get('set-cookie')?.split(';')[0];
|
||
if(!newAdminCookie)throw new Error('新组管理员登录未返回会话 Cookie');
|
||
const accountsAfterLogin=await request(`/api/management/users?groupId=${groupId}`,{},adminCookie);
|
||
if(!(accountsAfterLogin.body as Array<{username:string;last_login_at:string|null}>).find((item)=>item.username==='first_operator')?.last_login_at)throw new Error('最近登录时间未记录');
|
||
const adminLogs=await request('/api/management/audit-logs?userId=1',{},adminCookie);
|
||
expectStatus(adminLogs.response.status,200,'按账号筛选审计日志',adminLogs.body);
|
||
if(!(adminLogs.body as Array<{action:string}>).some((item)=>item.action==='group.admin_replace'))throw new Error('管理员更换审计日志缺失');
|
||
|
||
const project = await request('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'PostgreSQL 联调项目', slug: 'postgres-runtime-test', client_description: '运行时验证', groupId }) }, adminCookie);
|
||
expectStatus(project.response.status, 201, '创建项目', project.body);
|
||
if((project.body as {group_name?:string}).group_name!=='已更名运营组')throw new Error('项目接口未返回所属运营组名称');
|
||
const projectId = Number((project.body as { id: number }).id);
|
||
const missingStorage=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'缺少 COS 配置',images:['https://cdn.example.com/missing.jpg']})},adminCookie);
|
||
expectStatus(missingStorage.response.status,409,'没有活动 COS 时拒绝 URL 导入',missingStorage.body);
|
||
const worksWithoutStorage=await request(`/api/projects/${projectId}/works`,{},adminCookie);
|
||
if((worksWithoutStorage.body as unknown[]).length!==0)throw new Error('URL 导入失败后仍创建了作品记录');
|
||
const blockedPrivateStorage=await request('/api/management/storage-configs',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({region:'ap-guangzhou',bucket:'blocked-private-1234567890',public_base_url:'http://127.0.0.1:8080',cdn_domain:'',path_prefix:'delivery-desk',secret_id:'test-secret-id',secret_key:'test-secret-key'})},adminCookie);
|
||
expectStatus(blockedPrivateStorage.response.status,400,'拒绝私有地址作为对象存储访问域名',blockedPrivateStorage.body);
|
||
await database.insertId("INSERT INTO storage_configs (region,bucket,public_base_url,cdn_domain,path_prefix,secret_id_encrypted,secret_key_encrypted,status,test_status,created_by) VALUES (?,?,?,?,?,?,?,?,?,?)", ['ap-guangzhou','runtime-test-1234567890','https://runtime-test-1234567890.cos.ap-guangzhou.myqcloud.com','https://cdn.example.com','delivery-desk',encryptSecret('test-secret-id'),encryptSecret('test-secret-key'),'active','passed',1]);
|
||
const invalidMultipart=new FormData();
|
||
invalidMultipart.append('title','伪造图片内容');
|
||
invalidMultipart.append('images',new Blob(['not-a-real-image'],{type:'image/png'}),'spoofed.png');
|
||
const invalidUpload=await request(`/api/projects/${projectId}/works`,{method:'POST',body:invalidMultipart},adminCookie);
|
||
expectStatus(invalidUpload.response.status,422,'拒绝仅伪造 MIME 的上传文件',invalidUpload.body);
|
||
const privateImageUrls=['http://localhost/private.jpg','http://127.0.0.1/private.jpg','http://10.0.0.1/private.jpg','http://100.64.0.1/private.jpg','http://169.254.0.1/private.jpg','http://172.16.0.1/private.jpg','http://192.168.1.1/private.jpg','http://[::1]/private.jpg','http://[fd00::1]/private.jpg'];
|
||
for(const [index,imageUrl] of privateImageUrls.entries()){
|
||
const blockedPrivateImage=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:`禁止私有图片 ${index+1}`,images:[imageUrl]})},adminCookie);
|
||
expectStatus(blockedPrivateImage.response.status,400,`拒绝私有图片地址 ${imageUrl}`,blockedPrivateImage.body);
|
||
}
|
||
const worksAfterPrivateImages=await request(`/api/projects/${projectId}/works`,{},adminCookie);
|
||
if((worksAfterPrivateImages.body as unknown[]).length!==0)throw new Error('私有图片地址被拒绝后仍创建了作品记录');
|
||
const otherProject=await request('/api/projects',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'同组隔离项目',slug:'isolated-project',client_description:'不应被项目 Key 看见',groupId})},adminCookie);
|
||
expectStatus(otherProject.response.status,201,'创建同组隔离项目',otherProject.body);
|
||
const otherProjectId=Number((otherProject.body as {id:number}).id);
|
||
const otherWork=await request(`/api/projects/${otherProjectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'其他项目作品',images:['https://cdn.example.com/isolated.jpg']})},adminCookie);
|
||
expectStatus(otherWork.response.status,201,'创建其他项目作品',otherWork.body);
|
||
const otherWorkId=Number((otherWork.body as {id:number}).id);
|
||
const nativeFetch=globalThis.fetch;
|
||
const {default:COS}=await import('cos-nodejs-sdk-v5');
|
||
const cosPrototype=COS.prototype as unknown as {putObject:(...args:unknown[])=>unknown};
|
||
const nativePutObject=cosPrototype.putObject;
|
||
let importedStorageKey='';
|
||
try{
|
||
globalThis.fetch=(async(input:RequestInfo|URL,init?:RequestInit)=>{
|
||
const target=input instanceof Request?input.url:String(input);
|
||
if(target==='https://93.184.216.34/source.png')return new Response(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64'),{status:200,headers:{'Content-Type':'image/png'}});
|
||
return nativeFetch(input,init);
|
||
}) as typeof fetch;
|
||
cosPrototype.putObject=(async(options:unknown)=>{
|
||
const item=options as {Key:string;Body?:NodeJS.ReadableStream};
|
||
importedStorageKey=String(item.Key);
|
||
if(item.Body&&typeof item.Body.on==='function'){
|
||
await new Promise<void>((resolve,reject)=>{item.Body!.on('data',()=>undefined);item.Body!.once('end',resolve);item.Body!.once('error',reject)});
|
||
}
|
||
return{statusCode:200};
|
||
}) as typeof cosPrototype.putObject;
|
||
const localUpload=new FormData();
|
||
localUpload.append('externalId','runtime-local-file');
|
||
localUpload.append('title','本地文件上传作品');
|
||
localUpload.append('tags',JSON.stringify(['#multipart']));
|
||
const validPng=Uint8Array.from(atob('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='),(character)=>character.charCodeAt(0));
|
||
localUpload.append('images',new Blob([validPng],{type:'image/png'}),'generated.png');
|
||
const localWork=await request(`/api/projects/${otherProjectId}/works`,{method:'POST',body:localUpload},adminCookie);
|
||
expectStatus(localWork.response.status,201,'multipart 本地图片上传 COS',localWork.body);
|
||
const localDetail=await request(`/api/works/${Number((localWork.body as {id:number}).id)}`,{},adminCookie);
|
||
const localImage=(localDetail.body as {images:Array<{url:string;width:number;height:number;storage_provider:string;storage_key:string}>}).images[0];
|
||
if(!importedStorageKey.startsWith('delivery-desk/originals/')||localImage.url!==`https://cdn.example.com/${importedStorageKey}`||localImage.storage_provider!=='tencent_cos'||localImage.width!==1||localImage.height!==1)throw new Error('multipart 本地图片没有校验并写入当前 COS');
|
||
const importedWork=await request(`/api/projects/${otherProjectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'异源转存作品',images:['https://93.184.216.34/source.png']})},adminCookie);
|
||
expectStatus(importedWork.response.status,201,'异源图片转存 COS',importedWork.body);
|
||
const importedDetail=await request(`/api/works/${Number((importedWork.body as {id:number}).id)}`,{},adminCookie);
|
||
const importedImage=(importedDetail.body as {images:Array<{url:string;storage_provider:string;storage_key:string}>}).images[0];
|
||
if(!importedStorageKey.startsWith('delivery-desk/imports/')||importedImage.url!==`https://cdn.example.com/${importedStorageKey}`||importedImage.storage_provider!=='tencent_cos')throw new Error('异源图片没有归一到当前 COS');
|
||
}finally{
|
||
globalThis.fetch=nativeFetch;
|
||
cosPrototype.putObject=nativePutObject;
|
||
}
|
||
|
||
const access = await request(`/api/projects/${projectId}/customer-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, password: 'Review123!' }) }, adminCookie);
|
||
expectStatus(access.response.status, 200, '配置客户访问');
|
||
|
||
const collection = await request(`/api/projects/${projectId}/collections`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '第一阶段', client_description: '验收阶段' }) }, adminCookie);
|
||
expectStatus(collection.response.status, 201, '创建作品交付集');
|
||
const collectionId=Number((collection.body as {id:number}).id);
|
||
if((collection.body as {status:string}).status!=='draft')throw new Error('空作品交付集未初始化为待提交');
|
||
|
||
const projectKey=await request('/api/management/api-keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'项目接入测试 Key',project_id:projectId})},newAdminCookie);
|
||
expectStatus(projectKey.response.status,201,'创建项目级 API Key',projectKey.body);
|
||
const projectKeyId=Number((projectKey.body as {item:{id:number}}).item.id);
|
||
const projectToken=(projectKey.body as {token:string}).token;
|
||
const bearerHeaders={Authorization:`Bearer ${projectToken}`};
|
||
const visibleProjects=await request('/api/projects',{headers:bearerHeaders});
|
||
expectStatus(visibleProjects.response.status,200,'项目级 Key 查询项目',visibleProjects.body);
|
||
if((visibleProjects.body as Array<{id:number}>).length!==1||Number((visibleProjects.body as Array<{id:number}>)[0].id)!==projectId)throw new Error('项目级 Key 未严格隔离到绑定项目');
|
||
const forbiddenProject=await request(`/api/projects/${otherProjectId}`,{headers:bearerHeaders});
|
||
expectStatus(forbiddenProject.response.status,403,'项目级 Key 拒绝访问其他项目',forbiddenProject.body);
|
||
const forbiddenFeedback=await request(`/api/works/${otherWorkId}/annotations`,{headers:bearerHeaders});
|
||
expectStatus(forbiddenFeedback.response.status,403,'项目级 Key 拒绝读取其他项目作品反馈',forbiddenFeedback.body);
|
||
const visibleCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||
expectStatus(visibleCollections.response.status,200,'项目级 Key 查询作品交付集',visibleCollections.body);
|
||
if(!(visibleCollections.body as Array<{id:number}>).some((item)=>Number(item.id)===collectionId))throw new Error('项目级 Key 未返回目标作品交付集');
|
||
const createWorkBody={collectionId,externalId:'runtime-client-work-001',title:'接口作品 V1',description:'公开 URL 图片',tags:['API 测试'],images:['https://cdn.example.com/runtime-v1.jpg']};
|
||
const createdWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify(createWorkBody)});
|
||
expectStatus(createdWork.response.status,201,'JSON URL 创建作品',createdWork.body);
|
||
const workId=Number((createdWork.body as {id:number}).id);
|
||
const reviewingCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||
const reviewingCollection=(reviewingCollections.body as Array<{id:number;status:string;work_count:number}>).find((item)=>Number(item.id)===collectionId);
|
||
if(reviewingCollection?.status!=='reviewing'||Number(reviewingCollection.work_count)!==1)throw new Error('新增待验收作品后,作品交付集未进入验收中');
|
||
const reviewingProject=await request(`/api/projects/${projectId}`,{headers:bearerHeaders});
|
||
if((reviewingProject.body as {review_status:string}).review_status!=='reviewing')throw new Error('新增待验收作品后,项目未进入验收中');
|
||
const repeatedWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify(createWorkBody)});
|
||
expectStatus(repeatedWork.response.status,200,'externalId 幂等创建',repeatedWork.body);
|
||
if(Number((repeatedWork.body as {id:number}).id)!==workId||!(repeatedWork.body as {idempotent?:boolean}).idempotent)throw new Error('externalId 重复请求创建了不同作品');
|
||
const foundWork=await request(`/api/notes?collectionId=${collectionId}&externalId=runtime-client-work-001`,{headers:bearerHeaders});
|
||
expectStatus(foundWork.response.status,200,'externalId 查询作品',foundWork.body);
|
||
if((foundWork.body as Array<{id:number}>).length!==1||Number((foundWork.body as Array<{id:number}>)[0].id)!==workId)throw new Error('未能通过 externalId 找回作品');
|
||
const newVersion=await request(`/api/notes/${workId}/versions`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({title:'接口作品 V2',description:'第二版',tags:['API 测试'],images:['https://cdn.example.com/runtime-v2.jpg']})});
|
||
expectStatus(newVersion.response.status,201,'JSON URL 创建新版本',newVersion.body);
|
||
if(Number((newVersion.body as {version_number:number}).version_number)!==2)throw new Error('作品版本号未递增');
|
||
const workDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
|
||
expectStatus(workDetail.response.status,200,'读取新版本作品',workDetail.body);
|
||
const firstRound=(workDetail.body as {rounds:Array<{version_number:number;round_status:string;completion_reason:string}>}).rounds.find((item)=>item.version_number===1);
|
||
if(firstRound?.round_status!=='completed'||firstRound.completion_reason!=='superseded')throw new Error('新验收轮次未自动收口旧轮次');
|
||
const currentImages=(workDetail.body as {images:Array<{url:string;storage_provider:string}>}).images;
|
||
if(currentImages.length!==1||currentImages[0].url!=='https://cdn.example.com/runtime-v2.jpg'||currentImages[0].storage_provider!=='tencent_cos')throw new Error('同源 COS 图片没有直接复用');
|
||
const reviewLogin = await request('/api/review/postgres-runtime-test/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer_name: '客户测试', password: 'Review123!' }) });
|
||
expectStatus(reviewLogin.response.status, 200, '客户登录');
|
||
const reviewCookie = reviewLogin.response.headers.get('set-cookie')?.split(';')[0];
|
||
const reviewProject = await request('/api/review/postgres-runtime-test/project', {}, reviewCookie);
|
||
expectStatus(reviewProject.response.status, 200, '客户项目读取');
|
||
|
||
const approveV2=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:2,decision:'approved'})},reviewCookie);
|
||
expectStatus(approveV2.response.status,200,'客户通过作品',approveV2.body);
|
||
const completedCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||
const completedCollection=(completedCollections.body as Array<{id:number;status:string;completed_at:string|null;approved_count:number}>).find((item)=>Number(item.id)===collectionId);
|
||
if(completedCollection?.status!=='completed'||!completedCollection.completed_at||Number(completedCollection.approved_count)!==1)throw new Error('全部作品通过后,作品交付集未自动完成');
|
||
const readonlyComment=await request(`/api/review/postgres-runtime-test/works/${workId}/comments`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:'完成后不应写入'})},reviewCookie);
|
||
expectStatus(readonlyComment.response.status,409,'验收完毕后客户只读',readonlyComment.body);
|
||
const completedWorkDetail=await request(`/api/review/postgres-runtime-test/works/${workId}`,{},reviewCookie);
|
||
expectStatus(completedWorkDetail.response.status,200,'完成后读取作品',completedWorkDetail.body);
|
||
if((completedWorkDetail.body as {project:{review_status:string}}).project.review_status!=='completed')throw new Error('作品详情未返回项目验收完成状态');
|
||
const completedProject=await request(`/api/projects/${projectId}`,{headers:bearerHeaders});
|
||
if((completedProject.body as {review_status:string}).review_status!=='completed')throw new Error('全部作品通过后,项目接口未返回验收完成');
|
||
|
||
const reopenApproved=await request(`/api/notes/${workId}/reopen`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason:'补充复核'})},newAdminCookie);
|
||
expectStatus(reopenApproved.response.status,200,'组管理员重新打开已通过作品',reopenApproved.body);
|
||
const reopenedByAdmin=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||
if((reopenedByAdmin.body as Array<{id:number;status:string}>).find((item)=>Number(item.id)===collectionId)?.status!=='reviewing')throw new Error('管理员重新打开作品后,作品交付集未回到验收中');
|
||
const approveReopened=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:2,decision:'approved'})},reviewCookie);
|
||
expectStatus(approveReopened.response.status,200,'客户通过重新打开的作品',approveReopened.body);
|
||
|
||
const versionThree=await request(`/api/notes/${workId}/versions`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({title:'接口作品 V3',description:'完成后追加版本',tags:['API 测试'],images:['https://cdn.example.com/runtime-v3.jpg']})});
|
||
expectStatus(versionThree.response.status,201,'完成后创建新版本',versionThree.body);
|
||
const reopenedCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||
const reopenedCollection=(reopenedCollections.body as Array<{id:number;status:string;completed_at:string|null}>).find((item)=>Number(item.id)===collectionId);
|
||
if(reopenedCollection?.status!=='reviewing'||reopenedCollection.completed_at!==null)throw new Error('新版本未将作品交付集重新打开为验收中');
|
||
const requestChanges=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:3,decision:'changes_requested',reason:'请调整第三轮'})},reviewCookie);
|
||
expectStatus(requestChanges.response.status,200,'客户要求修改',requestChanges.body);
|
||
const closedRoundComment=await request(`/api/works/${workId}/comments`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({content:'退修轮次不应继续写入'})});
|
||
expectStatus(closedRoundComment.response.status,409,'退修轮次禁止新增总体反馈',closedRoundComment.body);
|
||
const closedRoundDecision=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:3,decision:'approved'})},reviewCookie);
|
||
expectStatus(closedRoundDecision.response.status,409,'退修轮次不可再次通过',closedRoundDecision.body);
|
||
const roundFour=await request(`/api/works/${workId}/rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({title:'接口作品 V4',description:'第四轮',tags:['API 测试'],images:['https://cdn.example.com/runtime-v4.jpg']})});
|
||
expectStatus(roundFour.response.status,201,'创建单方案第 4 轮',roundFour.body);
|
||
const multiRound=await request(`/api/notes/${workId}/review-rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({candidates:[{title:'方案 A',images:['https://cdn.example.com/a.jpg']},{title:'方案 B',images:['https://cdn.example.com/b.jpg']}]})});
|
||
expectStatus(multiRound.response.status,400,'拒绝一轮多个方案',multiRound.body);
|
||
const historicalClientAnnotation=await request(`/api/review/postgres-runtime-test/works/${workId}/text-annotations`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:3,target:'title',start_offset:0,end_offset:4,selected_text:'接口作品',content:'历史轮次不应写入'})},reviewCookie);
|
||
expectStatus(historicalClientAnnotation.response.status,409,'客户不可批注历史轮次',historicalClientAnnotation.body);
|
||
const mismatchedSelection=await request(`/api/works/${workId}/text-annotations`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({round_number:4,target:'title',start_offset:0,end_offset:4,selected_text:'错误文字',content:'不应写入'})});
|
||
expectStatus(mismatchedSelection.response.status,400,'拒绝与内容快照不匹配的文字选区',mismatchedSelection.body);
|
||
const textAnnotation=await request(`/api/works/${workId}/text-annotations`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({round_number:4,target:'title',start_offset:0,end_offset:4,selected_text:'接口作品',content:'标题选区批注'})});
|
||
expectStatus(textAnnotation.response.status,201,'创建标题选区批注',textAnnotation.body);
|
||
const textAnnotationId=Number((textAnnotation.body as {id:number}).id);
|
||
const tagAnnotation=await request(`/api/review/postgres-runtime-test/works/${workId}/text-annotations`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:4,target:'tags',start_offset:0,end_offset:6,selected_text:'API 测试',content:'Tag 选区批注'})},reviewCookie);
|
||
expectStatus(tagAnnotation.response.status,201,'客户创建 Tag 选区批注',tagAnnotation.body);
|
||
const feedbackReply=await request(`/api/works/${workId}/feedback/text_annotation/${textAnnotationId}/replies`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({content:'已记录这条意见'})});
|
||
expectStatus(feedbackReply.response.status,201,'回复文字批注',feedbackReply.body);
|
||
const feedback=await request(`/api/works/${workId}/annotations`,{headers:bearerHeaders});
|
||
expectStatus(feedback.response.status,200,'按作品 ID 获取全部反馈',feedback.body);
|
||
const roundFeedback=(feedback.body as {rounds:Array<{round_number:number;text_annotations:Array<unknown>;feedback_replies:Array<unknown>}>}).rounds.find((item)=>item.round_number===4);
|
||
if(roundFeedback?.text_annotations.length!==2||roundFeedback.feedback_replies.length!==1)throw new Error('作品反馈聚合未返回标题、Tag 批注及其回复');
|
||
const optimizationContext=await request(`/api/works/${workId}/optimization-context?round=4`,{headers:bearerHeaders});
|
||
expectStatus(optimizationContext.response.status,200,'获取内容优化上下文',optimizationContext.body);
|
||
const optimizationBody=optimizationContext.body as {content:{title:string;tags:string[];images:Array<{url:string}>};feedback:{text_annotations:Array<{target:string;replies:Array<unknown>}>}};
|
||
if(optimizationBody.content.title!=='接口作品 V4'||optimizationBody.content.tags[0]!=='API 测试'||optimizationBody.content.images[0]?.url!=='https://cdn.example.com/runtime-v4.jpg')throw new Error('内容优化上下文缺少当前轮次图文快照');
|
||
if(optimizationBody.feedback.text_annotations.length!==2||optimizationBody.feedback.text_annotations.find((item)=>item.target==='title')?.replies.length!==1)throw new Error('内容优化上下文未组合有效批注与回复');
|
||
const withdrawText=await request(`/api/works/${workId}/feedback/text_annotation/${textAnnotationId}/withdraw`,{method:'POST',headers:bearerHeaders});
|
||
expectStatus(withdrawText.response.status,200,'本人留痕撤回文字批注',withdrawText.body);
|
||
const afterWithdraw=await request(`/api/works/${workId}/annotations`,{headers:bearerHeaders});
|
||
const withdrawnItem=(afterWithdraw.body as {rounds:Array<{round_number:number;text_annotations:Array<{id:number;withdrawn_at:string|null}>}>}).rounds.find((item)=>item.round_number===4)?.text_annotations.find((item)=>item.id===textAnnotationId);
|
||
if(!withdrawnItem?.withdrawn_at)throw new Error('撤回批注未在作品聚合接口中保留记录');
|
||
const actionableContext=await request(`/api/works/${workId}/optimization-context?round=4`,{headers:bearerHeaders});
|
||
if((actionableContext.body as {feedback:{text_annotations:Array<{id:number}>}}).feedback.text_annotations.some((item)=>item.id===textAnnotationId))throw new Error('内容优化上下文默认返回了已撤回批注');
|
||
const historyContext=await request(`/api/works/${workId}/optimization-context?round=4&include_history=true`,{headers:bearerHeaders});
|
||
if(!(historyContext.body as {feedback:{text_annotations:Array<{id:number}>}}).feedback.text_annotations.some((item)=>item.id===textAnnotationId))throw new Error('内容优化上下文无法按需返回历史批注');
|
||
await database.execute("UPDATE projects SET status='closed' WHERE id=?",[projectId]);
|
||
const closedProjectRound=await request(`/api/works/${workId}/rounds`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'不应创建',images:['https://cdn.example.com/closed.jpg']})},adminCookie);
|
||
expectStatus(closedProjectRound.response.status,409,'已关闭项目禁止创建新轮次',closedProjectRound.body);
|
||
const closedProjectDecision=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:4,decision:'approved'})},reviewCookie);
|
||
expectStatus(closedProjectDecision.response.status,409,'已关闭项目禁止客户验收写入',closedProjectDecision.body);
|
||
await database.execute("UPDATE projects SET status='active' WHERE id=?",[projectId]);
|
||
const approveRoundFour=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:4,decision:'approved'})},reviewCookie);
|
||
expectStatus(approveRoundFour.response.status,200,'客户通过第 4 轮',approveRoundFour.body);
|
||
const secondWork=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({externalId:'runtime-client-work-002',title:'完成后新增作品',description:'验证部分通过',tags:['API 测试'],images:['https://cdn.example.com/runtime-second.jpg']})});
|
||
expectStatus(secondWork.response.status,201,'完成后新增作品',secondWork.body);
|
||
const secondWorkId=Number((secondWork.body as {id:number}).id);
|
||
const partialCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||
const partialCollection=(partialCollections.body as Array<{id:number;status:string;work_count:number;approved_count:number}>).find((item)=>Number(item.id)===collectionId);
|
||
if(partialCollection?.status!=='reviewing'||Number(partialCollection.work_count)!==2||Number(partialCollection.approved_count)!==1)throw new Error('完成后新增作品未恢复验收中或进度统计错误');
|
||
const reopenedProject=await request(`/api/projects/${projectId}`,{headers:bearerHeaders});
|
||
if((reopenedProject.body as {review_status:string}).review_status!=='reviewing')throw new Error('完成后新增作品未将项目恢复为验收中');
|
||
const approveSecond=await request(`/api/review/postgres-runtime-test/works/${secondWorkId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:1,decision:'approved'})},reviewCookie);
|
||
expectStatus(approveSecond.response.status,200,'客户通过新增作品',approveSecond.body);
|
||
const forbiddenDraft=await request(`/api/notes/${workId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'draft'})});
|
||
expectStatus(forbiddenDraft.response.status,409,'普通写入不能绕过重新打开规则',forbiddenDraft.body);
|
||
const reopenForDraft=await request(`/api/notes/${workId}/reopen`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason:'验证退回草稿后的集合状态'})},newAdminCookie);
|
||
expectStatus(reopenForDraft.response.status,200,'组管理员重新打开第一件作品',reopenForDraft.body);
|
||
const reopenSecondForDraft=await request(`/api/notes/${secondWorkId}/reopen`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason:'验证空集合状态'})},newAdminCookie);
|
||
expectStatus(reopenSecondForDraft.response.status,200,'组管理员重新打开第二件作品',reopenSecondForDraft.body);
|
||
const draftWork=await request(`/api/notes/${workId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'draft'})});
|
||
expectStatus(draftWork.response.status,200,'作品退回草稿',draftWork.body);
|
||
const draftSecondWork=await request(`/api/notes/${secondWorkId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'draft'})});
|
||
expectStatus(draftSecondWork.response.status,200,'新增作品退回草稿',draftSecondWork.body);
|
||
const draftCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||
const draftCollection=(draftCollections.body as Array<{id:number;status:string;work_count:number;completed_at:string|null}>).find((item)=>Number(item.id)===collectionId);
|
||
if(draftCollection?.status!=='draft'||Number(draftCollection.work_count)!==0||draftCollection.completed_at!==null)throw new Error('无已提交作品时未回到待提交');
|
||
const resubmitWork=await request(`/api/notes/${workId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'pending'})});
|
||
expectStatus(resubmitWork.response.status,200,'重新提交作品',resubmitWork.body);
|
||
const deleteWork=await request(`/api/notes/${workId}`,{method:'DELETE',headers:bearerHeaders});
|
||
expectStatus(deleteWork.response.status,204,'删除最后一件作品',deleteWork.body);
|
||
const deleteSecondWork=await request(`/api/notes/${secondWorkId}`,{method:'DELETE',headers:bearerHeaders});
|
||
expectStatus(deleteSecondWork.response.status,204,'删除第二件作品',deleteSecondWork.body);
|
||
const emptyCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||
const emptyCollection=(emptyCollections.body as Array<{id:number;status:string;work_count:number}>).find((item)=>Number(item.id)===collectionId);
|
||
if(emptyCollection?.status!=='draft'||Number(emptyCollection.work_count)!==0)throw new Error('删除最后一件作品后未回到待提交');
|
||
|
||
const revokeProjectKey=await request(`/api/management/api-keys/${projectKeyId}`,{method:'DELETE'},newAdminCookie);
|
||
expectStatus(revokeProjectKey.response.status,204,'吊销项目级 API Key');
|
||
|
||
const apiKey = await request('/api/management/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '运行时测试 Key' }) }, adminCookie);
|
||
expectStatus(apiKey.response.status, 201, '创建平台 API Key');
|
||
const keyId = Number((apiKey.body as { item: { id: number } }).item.id);
|
||
const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie);
|
||
expectStatus(revoke.response.status, 204, '吊销平台 API Key');
|
||
|
||
process.stdout.write('PostgreSQL 运行时验证通过:账号角色、项目隔离、客户门禁、项目自动状态、完成只读、API Key 发现、externalId 幂等、multipart 本地上传、COS 归一化、SSRF 拦截与单方案轮次\n');
|
||
} finally {
|
||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||
await closeDatabase();
|
||
}
|