|
@@ -6,7 +6,7 @@ import {resolve,join} from 'node:path';
|
|
|
const co=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx',base=(process.env.XIAOSHU_SERVER_URL||'https://server.xiaoshu.pro').replace(/\/$/,''),appId=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',suffix=Date.now(),state={users:[],co,admin:null,helper:null};
|
|
const co=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx',base=(process.env.XIAOSHU_SERVER_URL||'https://server.xiaoshu.pro').replace(/\/$/,''),appId=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',suffix=Date.now(),state={users:[],co,admin:null,helper:null};
|
|
|
if(!process.env.XIAOSHU_MASTER_KEY)throw Error('缺少 XIAOSHU_MASTER_KEY');
|
|
if(!process.env.XIAOSHU_MASTER_KEY)throw Error('缺少 XIAOSHU_MASTER_KEY');
|
|
|
const fixtureDirectory=resolve('tmp/mobile-teaching-flow-'+suffix);mkdirSync(fixtureDirectory,{recursive:true,mode:0o700});const statePath=join(fixtureDirectory,'fixture.json');
|
|
const fixtureDirectory=resolve('tmp/mobile-teaching-flow-'+suffix);mkdirSync(fixtureDirectory,{recursive:true,mode:0o700});const statePath=join(fixtureDirectory,'fixture.json');
|
|
|
-const password=randomBytes(24).toString('base64url'),company={__type:'Pointer',className:'Company',objectId:co};
|
|
|
|
|
|
|
+const password=randomBytes(54).toString('base64url'),company={__type:'Pointer',className:'Company',objectId:co};
|
|
|
function persist(){writeFileSync(statePath,JSON.stringify(state),{mode:0o600});}
|
|
function persist(){writeFileSync(statePath,JSON.stringify(state),{mode:0o600});}
|
|
|
async function parse(path,method='GET',data){const r=await fetch(base+'/parse'+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Parse-Master-Key':process.env.XIAOSHU_MASTER_KEY},...(data?{body:JSON.stringify(data)}:{})});const d=await r.json();if(!r.ok)throw Error('Parse '+path.split('?')[0]+': '+(d.error||r.status));return d;}
|
|
async function parse(path,method='GET',data){const r=await fetch(base+'/parse'+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Parse-Master-Key':process.env.XIAOSHU_MASTER_KEY},...(data?{body:JSON.stringify(data)}:{})});const d=await r.json();if(!r.ok)throw Error('Parse '+path.split('?')[0]+': '+(d.error||r.status));return d;}
|
|
|
async function call(path,token,params,allowFailure=false){const r=await fetch(base+'/api/functions/'+path,{method:'POST',headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId},body:JSON.stringify({token,params}),signal:AbortSignal.timeout(60000)});const d=await r.json();if(!allowFailure&&(!r.ok||d.success===false||d.retcode<0))throw Error((params.operation||params.action||path)+': '+(d.message||d.retmsg||d.error||r.status));return {status:r.status,data:d.data??d.result,raw:d};}
|
|
async function call(path,token,params,allowFailure=false){const r=await fetch(base+'/api/functions/'+path,{method:'POST',headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId},body:JSON.stringify({token,params}),signal:AbortSignal.timeout(60000)});const d=await r.json();if(!allowFailure&&(!r.ok||d.success===false||d.retcode<0))throw Error((params.operation||params.action||path)+': '+(d.message||d.retmsg||d.error||r.status));return {status:r.status,data:d.data??d.result,raw:d};}
|
|
@@ -35,7 +35,12 @@ try{
|
|
|
const self=(await mutate('ops/course-bindings/save','',{...payload,categoryKey:'word_self_study'})).data;expect(self.bindingIds[0]!==word.bindingIds[0],'同词表跨分类分别开课');
|
|
const self=(await mutate('ops/course-bindings/save','',{...payload,categoryKey:'word_self_study'})).data;expect(self.bindingIds[0]!==word.bindingIds[0],'同词表跨分类分别开课');
|
|
|
const zeroSave=await mutate('ops/course-bindings/save','',{...payload,studentId:empty.legacyUserId},randomUUID(),true);expect(zeroSave.status===409,'零余额实际开课请求被拒绝');
|
|
const zeroSave=await mutate('ops/course-bindings/save','',{...payload,studentId:empty.legacyUserId},randomUUID(),true);expect(zeroSave.status===409,'零余额实际开课请求被拒绝');
|
|
|
const duplicate=await mutate('ops/course-bindings/save','',payload,randomUUID(),true);expect(duplicate.status===409,'同分类重复开课被拒绝');
|
|
const duplicate=await mutate('ops/course-bindings/save','',payload,randomUUID(),true);expect(duplicate.status===409,'同分类重复开课被拒绝');
|
|
|
- const studentToken=(await parse('/login','POST',{username:'release_member_'+suffix,password})).sessionToken;
|
|
|
|
|
|
|
+ const temporaryToken=(await parse('/login','POST',{username:'release_member_'+suffix,password})).sessionToken;
|
|
|
|
|
+ const forced=await call('xiaoshu/app/gateway',temporaryToken,{action:'app_learning_overview'},true);expect(forced.status===403&&forced.raw.code==='PASSWORD_RESET_REQUIRED','临时密码禁止教学接口');
|
|
|
|
|
+ const finalPassword=randomBytes(54).toString('base64url');await call('xiaoshu/app/gateway',temporaryToken,{action:'app_password_change',currentPassword:password,newPassword:finalPassword});
|
|
|
|
|
+ const oldSession=await fetch(base+'/parse/users/me',{headers:{'X-Parse-Application-Id':appId,'X-Parse-Session-Token':temporaryToken}});expect(!oldSession.ok,'改密撤销原登录会话');
|
|
|
|
|
+ const oldLogin=await call('xiaoshu/app/gateway','',{action:'user_login_passwd',name:'release_member_'+suffix,passwd:password},true);expect(oldLogin.status===401,'旧密码不能通过迁移覆盖新密码');
|
|
|
|
|
+ const relogin=(await call('xiaoshu/app/gateway','',{action:'user_login_passwd',name:'release_member_'+suffix,passwd:finalPassword})).data;const studentToken=relogin.sessionToken;expect(studentToken?.startsWith('r:')&&!relogin.passwordResetRequired,'72位新密码登录成功并解除临时密码状态');
|
|
|
const learning=(await call('xiaoshu/app/gateway',studentToken,{action:'app_learning_overview'})).data;expect(learning.courses.length===2&&new Set(learning.courses.map(c=>c.categoryKey)).size===2,'用户端分别展示两个开课分类');
|
|
const learning=(await call('xiaoshu/app/gateway',studentToken,{action:'app_learning_overview'})).data;expect(learning.courses.length===2&&new Set(learning.courses.map(c=>c.categoryKey)).size===2,'用户端分别展示两个开课分类');
|
|
|
const units=await Promise.all([word,self].map(b=>call('xiaoshu/app/gateway',studentToken,{action:'node_list',ifunit:1,pid:course.courseId,bindingId:b.bindingIds[0]})));expect(JSON.stringify(units[0].data)===JSON.stringify(units[1].data),'同词表跨分类沿用共享词汇进度');
|
|
const units=await Promise.all([word,self].map(b=>call('xiaoshu/app/gateway',studentToken,{action:'node_list',ifunit:1,pid:course.courseId,bindingId:b.bindingIds[0]})));expect(JSON.stringify(units[0].data)===JSON.stringify(units[1].data),'同词表跨分类沿用共享词汇进度');
|
|
|
const date=new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}),draft={studentId:student.legacyUserId,coachId:coach.legacyUserId,courseId:course.courseId,durationMinutes:60,deliveryMode:'online',date,startTime:'10:00',recurrence:'once',occurrences:1};
|
|
const date=new Date().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}),draft={studentId:student.legacyUserId,coachId:coach.legacyUserId,courseId:course.courseId,durationMinutes:60,deliveryMode:'online',date,startTime:'10:00',recurrence:'once',occurrences:1};
|
|
@@ -95,6 +100,10 @@ try{
|
|
|
const race2=await Promise.all([mutate('ops/appointments/create','',drafts[0],randomUUID(),true),mutate('ops/users/adjust-balance',student.objectId,{account:'Purse',amount:1,payMethod:'运营记账',direction:'deduct',sourceTransactionId:transfer.objectId},randomUUID(),true)]);expect(race2.filter(r=>r.status===200).length===1,'退款和预留并发互斥');
|
|
const race2=await Promise.all([mutate('ops/appointments/create','',drafts[0],randomUUID(),true),mutate('ops/users/adjust-balance',student.objectId,{account:'Purse',amount:1,payMethod:'运营记账',direction:'deduct',sourceTransactionId:transfer.objectId},randomUUID(),true)]);expect(race2.filter(r=>r.status===200).length===1,'退款和预留并发互斥');
|
|
|
const reconciliation=(await call(state.helper.path,state.admin.sessionToken,{mode:'reconcile'})).data;expect(reconciliation.every(r=>Number(r.balance)===Number(r.ledger)),'门店学员四类余额与流水逐项对账');
|
|
const reconciliation=(await call(state.helper.path,state.admin.sessionToken,{mode:'reconcile'})).data;expect(reconciliation.every(r=>Number(r.balance)===Number(r.ledger)),'门店学员四类余额与流水逐项对账');
|
|
|
const audit=(await call(state.helper.path,state.admin.sessionToken,{mode:'audit'})).data;expect(audit[0].missing===0,'全部课时业务单均有事务审计');
|
|
const audit=(await call(state.helper.path,state.admin.sessionToken,{mode:'audit'})).data;expect(audit[0].missing===0,'全部课时业务单均有事务审计');
|
|
|
|
|
+ const deletion=await call('xiaoshu/app/gateway',empty.sessionToken,{action:'app_deletion_request',reason:'隔离发布验证注销申请'}),deletionRetry=await call('xiaoshu/app/gateway',empty.sessionToken,{action:'app_deletion_request',reason:'隔离发布验证重复申请'});expect(deletion.data.id===deletionRetry.data.id,'真实注销重复请求幂等');
|
|
|
|
|
+ await mutate('ops/privacy/process',deletion.data.id,{version:deletion.data.version,status:'processing'});
|
|
|
|
|
+ const deletionStatus=(await call('xiaoshu/app/gateway',empty.sessionToken,{action:'app_deletion_status'})).data;expect(deletionStatus.items.some(r=>r.id===deletion.data.id&&r.status==='processing'),'后台处理状态在学生端回读');
|
|
|
|
|
+ await call('xiaoshu/app/gateway',empty.sessionToken,{action:'app_logout'});const revoked=await fetch(base+'/parse/users/me',{headers:{'X-Parse-Application-Id':appId,'X-Parse-Session-Token':empty.sessionToken}});expect(!revoked.ok,'退出后原token服务端已撤销');
|
|
|
const snapshot=(await call(state.helper.path,state.admin.sessionToken,{mode:'counts'})).data;console.log('ENROLLMENT_COUNTS',snapshot);console.log('ONLINE_SMOKE_PASSED');
|
|
const snapshot=(await call(state.helper.path,state.admin.sessionToken,{mode:'counts'})).data;console.log('ENROLLMENT_COUNTS',snapshot);console.log('ONLINE_SMOKE_PASSED');
|
|
|
}catch(e){console.error('ONLINE_SMOKE_FAILED',e.message);process.exitCode=1;}
|
|
}catch(e){console.error('ONLINE_SMOKE_FAILED',e.message);process.exitCode=1;}
|
|
|
finally{let clean=true;if(state.helper)try{await call(state.helper.path,state.admin.sessionToken,{mode:'cleanup'});}catch(e){console.error('CLEANUP_FAILED',e.message);clean=false;}if(clean){if(state.helper)await parse('/classes/Function/'+state.helper.objectId,'DELETE');for(const u of [...state.users].reverse())await parse('/users/'+u.objectId,'DELETE');rmSync(statePath,{force:true});console.log('FIXTURES_CLEANED');}else{console.error('隔离资源待清理,受限状态文件:'+statePath);process.exitCode=1;}}
|
|
finally{let clean=true;if(state.helper)try{await call(state.helper.path,state.admin.sessionToken,{mode:'cleanup'});}catch(e){console.error('CLEANUP_FAILED',e.message);clean=false;}if(clean){if(state.helper)await parse('/classes/Function/'+state.helper.objectId,'DELETE');for(const u of [...state.users].reverse())await parse('/users/'+u.objectId,'DELETE');rmSync(statePath,{force:true});console.log('FIXTURES_CLEANED');}else{console.error('隔离资源待清理,受限状态文件:'+statePath);process.exitCode=1;}}
|
|
@@ -102,6 +111,7 @@ finally{let clean=true;if(state.helper)try{await call(state.helper.path,state.ad
|
|
|
function helperCode(state){
|
|
function helperCode(state){
|
|
|
const ids=state.users.map(u=>u.legacyUserId),objects=state.users.map(u=>u.objectId),texts=ids.map(String),co=state.co,admin=state.admin.objectId;
|
|
const ids=state.users.map(u=>u.legacyUserId),objects=state.users.map(u=>u.objectId),texts=ids.map(String),co=state.co,admin=state.admin.objectId;
|
|
|
const statements=[
|
|
const statements=[
|
|
|
|
|
+['DELETE FROM \"AccountDeletionRequest\" WHERE company=$1 AND \"userId\"=ANY($2::text[])',[co,objects]],
|
|
|
['DELETE FROM "SurveyLog" WHERE "learnerLegacyId"=ANY($1::numeric[])',[ids]],
|
|
['DELETE FROM "SurveyLog" WHERE "learnerLegacyId"=ANY($1::numeric[])',[ids]],
|
|
|
['DELETE FROM "SurveyItem" WHERE "learnerLegacyId"=ANY($1::numeric[])',[ids]],
|
|
['DELETE FROM "SurveyItem" WHERE "learnerLegacyId"=ANY($1::numeric[])',[ids]],
|
|
|
['DELETE FROM "MobileTeachingCommand" WHERE "company"=$1 AND "actorId"=ANY($2::text[])',[co,texts]],
|
|
['DELETE FROM "MobileTeachingCommand" WHERE "company"=$1 AND "actorId"=ANY($2::text[])',[co,texts]],
|