#!/usr/bin/env node // Real isolated end-to-end fixtures. No real student's/teacher's decision is changed. import {randomBytes} from 'node:crypto'; import {mkdirSync,writeFileSync} from 'node:fs'; import assert from 'node:assert/strict'; const master=process.env.XIAOSHU_MASTER_KEY,operator=process.env.XIAOSHU_SYNC_OPERATOR_TOKEN; if(!master||!operator)throw Error('缺少部署验收配置'); const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',functions=(process.env.XIAOSHU_FUNCTION_URL||base.replace(/\/parse$/,'/api/functions')).replace(/\/$/,''); const users=[],suffix=Date.now()+'_'+randomBytes(4).toString('hex'),checks=[],requests=[];let helper; const parse=async(path,method='GET',body)=>{const r=await fetch(base+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(30000)});const d=await r.json();if(!r.ok||d.error)throw Error('Fixture request failed '+path.split('?')[0]+' HTTP '+r.status+' '+String(d.error||d.message||''));return d;}; const call=async(path,token,params,allow=false)=>{const r=await fetch(functions+'/'+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params}),signal:AbortSignal.timeout(30000)});const d=await r.json();if(!allow&&(!r.ok||d.success===false||d.retcode<0))throw Error('Live workflow '+(params.action||params.operation)+': '+(d.message||d.retmsg||r.status));return {status:r.status,data:d.data??d.result};}; const ops=(operation,params={},token=operator,allow=false)=>call('xiaoshu/ops/gateway-v3',token,{operation,...params},allow); const mobile=(user,action,params={},allow=false)=>call('xiaoshu/app/gateway',user.sessionToken,{action,...params},allow); const meR=await fetch(base+'/users/me',{headers:{'X-Parse-Application-Id':app,'X-Parse-Session-Token':operator}}),me=await meR.json();if(!meR.ok||!me.company?.objectId)throw Error('Unknown operator');const co=me.company.objectId; const baseline=(await ops('ops/support-requests/list',{pageSize:100})).data; try{ const idle=await call('xiaoshu/sync/direct-v1',operator,{action:'status'});assert.ok(Array.isArray(idle.data.runs)&&idle.data.runs.every(r=>r.status!=='running')); for(const [index,kind] of ['student','teacher'].entries()){ const uid=999000000+(Date.now()%100000)*10+index; const collision=await parse('/classes/_User?'+new URLSearchParams({where:JSON.stringify({company:me.company,legacyUserId:uid}),limit:'1',keys:'objectId'})); if(collision.results?.length)throw Error('隔离编号已存在,停止创建测试记录'); const user=await parse('/users','POST',{username:'support_'+String(Date.now()).slice(-8)+'_'+kind,password:randomBytes(24).toString('hex'),company:me.company}); users.push({...user,uid,kind}); const fields={legacyUserId:uid,legacyUserData:{UserID:uid,GroupID:kind==='teacher'?3:1,ParentUserID:0,Purse:0,SilverCoin:0,UserExp:0,UserPoint:0},legacyGroupId:kind==='teacher'?3:1,identityType:kind==='teacher'?'coach':'member',realName:'隔离验收'+kind,testCreatedBy:'support-workflow-smoke'}; for(const [key,value] of Object.entries(fields)){try{await parse('/users/'+user.objectId,'PUT',{[key]:value});}catch{throw Error('Fixture field update failed: '+kind+' '+key);}} } const [student,teacher]=users,ids=users.map(u=>u.uid),path='xiaoshu/system/support-cleanup-'+suffix; const deleteGuestbook="DELETE FROM \"Guestbook\" WHERE company=$1 AND userid=ANY($2::numeric[]) AND \"sourceKey\" LIKE 'cloud:guestbook_add:%'"; const code=`async function handler(request,response){try{if(request.user?.id!==${JSON.stringify(me.objectId)}||Date.now()>${Date.now()+900000})return response.status(403).json({success:false});await Psql.tx(async t=>{await t.none('DELETE FROM "SupportRequestResolution" WHERE company=$1 AND "ownerId"=ANY($2::numeric[])',[${JSON.stringify(co)},${JSON.stringify(ids)}]);await t.none(${JSON.stringify(deleteGuestbook)},[${JSON.stringify(co)},${JSON.stringify(ids)}]);});return response.json({success:true});}catch(e){return response.status(500).json({success:false,message:e.message});}}`; new Function(code); helper={...(await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'})),path}; for(const [user,kind,status,comment] of [[student,'feedback','processed','感谢反馈,已调整提醒设置。'],[teacher,'leave','approved','同意休假,请提前完成课程交接。'],[student,'leave','rejected','本次时间无法安排,请调整后重新提交。']]){ const title=`【${kind==='leave'?'休假申请':'意见反馈'}】隔离验收 ${suffix}`; const submitted=(await mobile(user,'guestbook_add',{model:JSON.stringify({UserID:user.uid,Title:title,TContent:'处理与回信隔离验收,仅用于功能检查',Cateid:22})})).data; assert.ok(submitted.objectId);requests.push({id:submitted.objectId,user,kind,status,comment}); } const board=(await ops('ops/support-requests/list',{search:suffix,pageSize:100})).data;assert.equal(board.total,3); assert.equal((await ops('ops/support-requests/list',{},student.sessionToken,true)).status,403); for(const req of requests){ const row=board.items.find(r=>r.id===req.id),input={targetId:req.id,status:req.status,comment:req.comment,version:row.version}; assert.equal(row.status,'pending');assert.equal((await ops('ops/support-requests/process',input,student.sessionToken,true)).status,403); assert.equal((await ops('ops/support-requests/process',{...input,version:'outdated'},operator,true)).status,409); const saved=(await ops('ops/support-requests/process',input)).data;assert.equal(saved.status,req.status); const retried=(await ops('ops/support-requests/process',input)).data;assert.equal(retried.handledAt,saved.handledAt); assert.equal((await ops('ops/support-requests/process',{...input,comment:'不得覆盖原决定'},operator,true)).status,409); } checks.push('真实学生反馈、教师休假同意、学生休假拒绝,意见与处理时间持久化;学生账号无后台处理权限;重复处理不可覆盖'); const studentInbox=(await mobile(student,'app_support_requests_list',{uid:teacher.uid})).data,teacherInbox=(await mobile(teacher,'app_support_requests_list',{})).data; assert.equal(studentInbox.total,2);assert.equal(studentInbox.unread,2);assert.equal(teacherInbox.total,1);assert.equal(teacherInbox.unread,1); assert.equal(teacherInbox.items[0].response,requests[1].comment);assert.ok(studentInbox.items.every(r=>r.id!==requests[1].id)); assert.equal((await mobile(student,'app_support_request_read',{id:requests[1].id},true)).status,404); for(const req of requests){await mobile(req.user,'app_support_request_read',{id:req.id});await mobile(req.user,'app_support_request_read',{id:req.id});} assert.equal((await mobile(student,'app_support_requests_list')).data.unread,0);assert.equal((await mobile(teacher,'app_support_requests_list')).data.unread,0); assert.ok((await ops('ops/support-requests/list',{search:suffix})).data.items.every(r=>r.readAt)); checks.push('学生与老师只收本人回复,伪造用户编号无效;已读确认可重试,后台同步显示已读'); }finally{ const failures=[]; if(helper){try{await call(helper.path,operator,{});}catch(e){failures.push('fixture records: '+e.message);}try{await parse('/classes/Function/'+helper.objectId,'DELETE');}catch{failures.push('function '+helper.objectId);}} for(const u of users){try{const sessions=(await parse('/classes/_Session?'+new URLSearchParams({where:JSON.stringify({user:{__type:'Pointer',className:'_User',objectId:u.objectId}})}))).results;for(const s of sessions)await parse('/classes/_Session/'+s.objectId,'DELETE');await parse('/users/'+u.objectId,'DELETE');}catch{failures.push('user '+u.objectId);}} if(failures.length)throw Error('验收清理未完成 '+failures.join('; ')); } const after=(await ops('ops/support-requests/list',{pageSize:100})).data; for(const row of baseline.items){const current=after.items.find(r=>r.id===row.id);assert.ok(current);assert.equal(current.status,row.status);assert.equal(current.response,row.response);} assert.equal((await ops('ops/support-requests/list',{search:suffix})).data.total,0); checks.push('测试账号、会话、记录、临时函数已清理;原有真实申请结果未改动'); mkdirSync('tmp/support-workflow',{recursive:true});writeFileSync('tmp/support-workflow/live-result.json',JSON.stringify({passed:true,checkedAt:new Date().toISOString(),checks},null,2));console.log(JSON.stringify({passed:true,checks}));