smoke-lesson-records.mjs 8.4 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
  1. // Executed by the scoped release runner. Only disposable fixture IDs are mutated.
  2. import assert from 'node:assert/strict';
  3. import {randomUUID,randomBytes} from 'node:crypto';
  4. export async function smokeLessonRecords({parse,call,scoped,co,save}){
  5. const suffix=Date.now(),prefix='lrtest_'+suffix,uid=990000000+suffix%1000000,users=[],checks=[];
  6. const ok=(value,label)=>{assert.ok(value,label);checks.push(label);console.log('PASS '+label);};
  7. const sql=async(query,args=[])=>scoped(`return response.json({success:true,data:await Psql.query(${JSON.stringify(query)},${JSON.stringify(args)})});`);
  8. const ops=async(operation,params={})=>(await call('xiaoshu/ops/gateway-v3',{operation,...params})).data;
  9. const mobile=async(u,action,params={})=>(await call('xiaoshu/app/gateway',{action,...params},u.sessionToken)).result;
  10. const taskId=i=>prefix+'_task'+i,gid=i=>uid*100+i;
  11. try{
  12. for(const [i,kind] of ['member','coach','coach','member'].entries()){
  13. const value=await parse('/users','POST',{username:prefix+'_'+i,password:randomBytes(40).toString('base64url'),company:{__type:'Pointer',className:'Company',objectId:co},realName:'隔离上课记录验收'+i,identityType:kind,legacyGroupId:kind==='coach'?3:1,legacyUserId:uid+i,legacyUserData:{UserID:uid+i,GroupID:kind==='coach'?3:1,Purse:3,SilverCoin:3,UserPoint:3,UserExp:3}});users.push({...value,uid:uid+i});
  14. }
  15. save('isolated-fixtures.json',{users,prefix});
  16. await sql(`INSERT INTO "DailyStudyRecord"("objectId","sourceKey",company,id,"userId",dsid,pl,learned,xxqs,"createdAt","updatedAt") VALUES($1,$1,$2,$3,$4,$5,$6,1,$7,now(),now())`,[prefix+'_study',co,gid(1),uid,String(gid(2)),String(uid+1),JSON.stringify([{GeneralID:1,Title:'hello',check:1}])]);
  17. await sql(`INSERT INTO "CommonModel"("objectId","sourceKey",company,"generalId","itemId","modelId","nodeId",title,status,"createdAt","updatedAt") VALUES($1,$1,$2,$3,$3,56,291,'隔离学习记录',99,now(),now())`,[prefix+'_studyCommon',co,gid(1)]);
  18. // 25 classes prove cross-page history; only the first owns the review tasks.
  19. for(let i=0;i<25;i++)await sql(`INSERT INTO "LessonRecord"("objectId","sourceKey",company,id,xymz,jsmz,yyds,kclx,kcmc,pldp,pjnr,plpjsj,"createdAt","updatedAt") VALUES($1,$1,$2,$3,$4,$5,$6,'2','隔离课程','完整老师反馈','完整学生评价',$7,now(),now())`,[prefix+'_lesson'+i,co,gid(20+i),String(uid),String(uid+1),String(gid(2+i)),`2026-10-${String(i+1).padStart(2,'0')} 10:00:00`]);
  20. for(let i=0;i<4;i++){
  21. await sql(`INSERT INTO "MemoryPracticeRecord"("objectId","sourceKey",company,id,plid,yhid,xxjlid,"orderId",kywrq,kywsj,fxzt,wcsj,"createdAt","updatedAt") VALUES($1,$1,$2,$3,$4,$5,$6,$7,$8,$8,$9,'',now(),now())`,[taskId(i),co,gid(100+i),uid+1,uid,gid(1),gid(2),i===0?'2026-01-01':`2099-01-0${i+1}`,i===3?'1':'0']);
  22. await sql(`INSERT INTO "CommonModel"("objectId","sourceKey",company,"generalId","itemId","modelId","nodeId",title,status,"createdAt","updatedAt") VALUES($1,$1,$2,$3,$3,60,388,'隔离复习',99,now(),now())`,[prefix+'_taskCommon'+i,co,gid(100+i)]);
  23. }
  24. const p=await ops('ops/lessons/list',{studentId:uid});ok(p.total===25&&p.items.length===20&&p.items[0].lessonId===prefix+'_lesson24','真实接口分页、总课次与时间倒序');
  25. const second=await ops('ops/lessons/list',{studentId:uid,page:2});ok(second.items.length===5&&second.version===p.version,'分页快照一致且无漏行');
  26. const detail=await ops('ops/lessons/detail',{lessonId:prefix+'_lesson0'});ok(detail.coachComment==='完整老师反馈'&&detail.studentReview.comment==='完整学生评价'&&detail.tasks.length===4,'历史反馈与复习关联可完整查看');
  27. const params={studentId:uid,appointmentId:String(gid(2)),scope:'lesson',coachId:uid+2};
  28. const preview=await ops('ops/lessons/transfer-preview',params);ok(preview.count===3&&preview.dateFrom==='2026-01-01','预览包括逾期与未来,排除已完成');
  29. const before=await sql('SELECT to_jsonb(r) data FROM "ReviewReward" r WHERE company=$1 AND "taskId"=$2',[co,taskId(3)]);
  30. const request={...params,version:preview.version,reason:'隔离发布验收',idempotencyKey:randomUUID()};
  31. const responses=await Promise.all([ops('ops/lessons/transfer',request),ops('ops/lessons/transfer',request)]);ok(responses.every(r=>r.count===3&&r.commandId===responses[0].commandId),'真实并发重复转交只执行一次');
  32. const after=await sql('SELECT to_jsonb(r) data FROM "ReviewReward" r WHERE company=$1 AND "taskId"=$2',[co,taskId(3)]);assert.deepEqual(after,before);ok(true,'已完成奖励原值保持不变');
  33. const page=await mobile(users[2],'app_review_page',{uid:uid+2,studentId:uid,pageSize:100,status:'pending'});ok(page.items.length===3,'无授课关系的新陪练可查看接手任务');
  34. const words=await mobile(users[2],'e_record_detail',{id:gid(1),studentId:uid});ok(words.length===1&&words[0].Title==='hello','新陪练可读取对应来源词表');
  35. await assert.rejects(mobile(users[2],'app_learning_overview',{studentId:uid}));ok(true,'新陪练没有获得无关学习历史权限');
  36. const complete=i=>({content:JSON.stringify({GeneralID:gid(100+i)}),addon:JSON.stringify({fxzt:1})});
  37. await assert.rejects(mobile(users[1],'content_update',complete(0)));ok(true,'原陪练旧页面完成请求被拒绝');
  38. await Promise.all([mobile(users[2],'content_update',complete(0)),mobile(users[2],'content_update',complete(0))]);
  39. const reward=await sql('SELECT "coachId",amount FROM "ReviewReward" WHERE company=$1 AND "taskId"=$2',[co,taskId(0)]);ok(reward.length===1&&Number(reward[0].coachId)===uid+2&&Number(reward[0].amount)===1,'新陪练完成只计一次一元工资');
  40. await sql(`UPDATE "MemoryPracticeRecord" SET plid=$3,fxzt='1',wcsj='2026-01-01' WHERE company=$1 AND "objectId"=$2`,[co,taskId(1),uid+1]);
  41. const preserved=await sql('SELECT plid,fxzt FROM "MemoryPracticeRecord" WHERE company=$1 AND "objectId"=$2',[co,taskId(1)]);ok(Number(preserved[0].plid)===uid+2&&preserved[0].fxzt==='0','旧来源更新被保护并保留待复习状态');
  42. const reverse={...params,coachId:uid+1},racePreview=await ops('ops/lessons/transfer-preview',reverse);
  43. const race=await Promise.allSettled([ops('ops/lessons/transfer',{...reverse,version:racePreview.version,reason:'并发交接验证',idempotencyKey:randomUUID()}),mobile(users[2],'content_update',complete(1))]);
  44. const actual=await sql('SELECT m.plid,m.fxzt,r."coachId" FROM "MemoryPracticeRecord" m LEFT JOIN "ReviewReward" r ON r.company=m.company AND r."taskId"=m."objectId" WHERE m.company=$1 AND m."objectId"=$2',[co,taskId(1)]);
  45. ok(race.filter(r=>r.status==='fulfilled').length===1&&(actual[0].fxzt==='1'?Number(actual[0].coachId)===uid+2:Number(actual[0].plid)===uid+1),'真实多连接转交与完成互斥,工资归属一致');
  46. await assert.rejects(call('xiaoshu/ops/gateway-v3',{operation:'ops/lessons/transfer-preview',...params},users[1].sessionToken));ok(true,'普通老师不能调用管理转交接口');
  47. await parse('/users/'+users[2].objectId,'PUT',{isDisabled:true});await assert.rejects(ops('ops/lessons/transfer-preview',params));ok(true,'停用目标陪练被拒绝');
  48. const balances=await sql('SELECT "legacyUserData" FROM "_User" WHERE "objectId"=$1',[users[0].objectId]);ok(['Purse','SilverCoin','UserPoint','UserExp'].every(k=>balances[0].legacyUserData[k]===3),'转交不扣课时或余额');
  49. save('isolated-result.json',{passed:true,checks});return {passed:true,checks};
  50. }finally{
  51. // Fixed fixture prefix and IDs only; reward immutability bypass is transaction-local for teardown.
  52. await scoped(String.raw`await Psql.tx(async t=>{await t.none("SET LOCAL session_replication_role='replica'");const co=${JSON.stringify(co)},prefix=${JSON.stringify(prefix)},uid=${uid};for(const table of ['ReviewAssignmentConflict','ReviewTransferAudit'])await t.none('DELETE FROM "'+table+'" WHERE company=$1 AND "taskId" LIKE $2',[co,prefix+'_task%']);await t.none('DELETE FROM "ReviewTransferCommand" WHERE company=$1 AND result->>\'studentId\'=$2',[co,String(uid)]);await t.none('DELETE FROM "ReviewReward" WHERE company=$1 AND "taskId" LIKE $2',[co,prefix+'_task%']);for(const table of ['MemoryPracticeRecord','LessonRecord','DailyStudyRecord','CommonModel'])await t.none('DELETE FROM "'+table+'" WHERE company=$1 AND "objectId" LIKE $2',[co,prefix+'_%']);});return response.json({success:true,data:{cleaned:true}});`);
  53. for(const u of [...users].reverse())await parse('/users/'+u.objectId,'DELETE');save('isolated-fixtures-cleaned.json',{prefix,cleaned:true});console.log('ISOLATED_FIXTURES_CLEANED');
  54. }
  55. }