Files
delivery-desk/scripts/test-postgres-runtime.ts
yuzhe 6091d61612 feat(review): 支持多候选稿验收轮次
- 支持每轮提交 1–5 个候选稿并按指定稿验收
- 保留历史轮次只读并兼容单候选稿版本接口
- 同步 SQLite/PostgreSQL schema、迁移验证、测试与项目文档
2026-07-21 20:25:52 +08:00

231 lines
28 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 { 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 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 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 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 {versions:Array<{version_number:number;round_status:string;candidate_status:string}>}).versions.find((item)=>item.version_number===1);
if(firstRound?.round_status!=='completed'||firstRound.candidate_status!=='not_selected')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 {collection:{status:string}}).collection.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({version_number:3,decision:'changes_requested',reason:'请调整第三版'})},reviewCookie);
expectStatus(requestChanges.response.status,200,'客户要求修改',requestChanges.body);
const approveV3=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:3,decision:'approved'})},reviewCookie);
expectStatus(approveV3.response.status,200,'客户再次通过',approveV3.body);
const candidateRound=await request(`/api/notes/${workId}/review-rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({candidates:[
{candidate_name:'暖色方案',title:'候选稿 A',description:'暖色方向',tags:['A'],images:['https://cdn.example.com/runtime-candidate-a.jpg']},
{candidate_name:'冷色方案',title:'候选稿 B',description:'冷色方向',tags:['B'],images:['https://cdn.example.com/runtime-candidate-b.jpg']}
]})});
expectStatus(candidateRound.response.status,201,'创建多候选稿验收轮次',candidateRound.body);
const candidateDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
expectStatus(candidateDetail.response.status,200,'读取多候选稿',candidateDetail.body);
const latestCandidates=(candidateDetail.body as {versions:Array<{version_number:number;round_number:number;candidate_name:string;candidate_status:string}>}).versions.filter((item)=>item.round_number===4);
if(latestCandidates.length!==2||latestCandidates.map((item)=>item.version_number).join(',')!=='4,5')throw new Error('同一验收轮次未生成两个独立候选稿');
const historicalDecision=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:3,decision:'approved'})},reviewCookie);
expectStatus(historicalDecision.response.status,409,'历史验收轮次不可重复决策',historicalDecision.body);
const historicalClientAnnotation=await request(`/api/review/postgres-runtime-test/works/${workId}/text-annotations`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:3,target:'title',content:'历史轮次不应写入'})},reviewCookie);
expectStatus(historicalClientAnnotation.response.status,409,'客户不可批注历史轮次',historicalClientAnnotation.body);
const historicalOperatorAnnotation=await request(`/api/notes/${workId}/text-annotations`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({version_number:3,target:'title',content:'历史轮次不应写入'})});
expectStatus(historicalOperatorAnnotation.response.status,409,'工作台不可批注历史轮次',historicalOperatorAnnotation.body);
const oversizedRound=await request(`/api/notes/${workId}/review-rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({candidates:[{candidate_name:'超量方案',title:'超量方案',images:Array.from({length:31},(_,index)=>`https://cdn.example.com/oversized-${index}.jpg`)}]})});
expectStatus(oversizedRound.response.status,400,'验收轮次总图片不可超过 30 张',oversizedRound.body);
const changesA=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:4,decision:'changes_requested',reason:'A 方案需调整'})},reviewCookie);
expectStatus(changesA.response.status,200,'单个候选稿要求修改',changesA.body);
const afterChanges=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
if((afterChanges.body as {review_status:string}).review_status!=='pending')throw new Error('仍有待验收候选稿时作品不应整体进入需修改');
const chooseB=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:5,decision:'approved'})},reviewCookie);
expectStatus(chooseB.response.status,200,'选择并通过候选稿 B',chooseB.body);
const selectedDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
const selectedBody=selectedDetail.body as {version_number:number;approved_version_number:number;review_status:string;versions:Array<{version_number:number;candidate_status:string}>};
if(selectedBody.version_number!==5||selectedBody.approved_version_number!==5||selectedBody.review_status!=='approved')throw new Error('作品未指向客户选中的候选稿');
if(selectedBody.versions.find((item)=>item.version_number===4)?.candidate_status!=='not_selected'||selectedBody.versions.find((item)=>item.version_number===5)?.candidate_status!=='selected')throw new Error('选中候选稿后同轮状态未正确收口');
const secondWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({collectionId,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 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();
}