|
|
@@ -0,0 +1,66 @@
|
|
|
+#!/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}));
|