Files
delivery-desk/scripts/test-postgres-runtime.ts

258 lines
32 KiB
TypeScript
Raw 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.
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 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 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 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!=='external')throw new Error('新版本图片没有按外部 URL 保存');
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 幂等、JSON URL 作品与单方案轮次\n');
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
await closeDatabase();
}