legacy-appointment-deletions.test.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
  1. import {test,before,after,beforeEach} from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import {readFileSync} from 'node:fs';
  4. import {mkdtemp,rm} from 'node:fs/promises';
  5. import {tmpdir} from 'node:os';
  6. import {join} from 'node:path';
  7. import vm from 'node:vm';
  8. import {PGlite} from '@electric-sql/pglite';
  9. import {buildAppointmentDeletionGateway,patchAppointmentDeletionCapability} from '../lib/legacy-appointment-deletions.mjs';
  10. import {reconcileAppointmentDeletions} from '../reconcile-legacy-appointment-deletions.mjs';
  11. import {runDirectSync} from '../run-nightly-direct-sync.mjs';
  12. const db=new PGlite(),runId='12345678-1234-4234-8234-123456789abc';
  13. const sql=async(q,args=[])=>(await db.query(q,args)).rows;
  14. const Psql={any:sql,query:sql,one:async(q,a)=>(await sql(q,a))[0],oneOrNone:async(q,a)=>(await sql(q,a))[0]||null,none:async(q,a)=>{await sql(q,a);}};
  15. Psql.tx=async fn=>{await db.exec('BEGIN');try{const value=await fn(Psql);await db.exec('COMMIT');return value;}catch(error){await db.exec('ROLLBACK');throw error;}};
  16. let context,liveIds,fail,wrongFilter,canaryMissing,readHook;
  17. before(async()=>{
  18. await db.exec(readFileSync(new URL('../sql/legacy-direct-sync.sql',import.meta.url),'utf8'));
  19. await db.exec(`CREATE TABLE "CommonModel"("objectId" text PRIMARY KEY,company text,"sourceKey" text,"generalId" numeric,"itemId" numeric,"modelId" numeric,status numeric,title text,"createdAt" timestamptz,"updatedAt" timestamptz,"teachingManaged" boolean);
  20. CREATE TABLE "CourseAppointment"("objectId" text PRIMARY KEY,company text,id numeric,"sourceKey" text,dszt text,pl text,"bindingId" text,"creditHoldStatus" text,"courseCategoryKey" text,"payStatus" text,"createdAt" timestamptz,"updatedAt" timestamptz,"teachingManaged" boolean);
  21. CREATE TABLE "LessonRecord"("objectId" text,company text,yyds text);
  22. CREATE TABLE "LegacySyncConflict"("objectId" text PRIMARY KEY,company text,"sourceKey" text,dataset text,"legacyKey" text,"conflictType" text,message text,resolved boolean,resolution text,"createdAt" timestamptz,"updatedAt" timestamptz);`);
  23. });
  24. after(()=>db.close());
  25. beforeEach(async()=>{
  26. await db.exec('TRUNCATE "CommonModel","CourseAppointment","LessonRecord","LegacySyncConflict","LegacyDirectBaseline","LegacyDirectBatch","LegacyDirectSourcePage"');
  27. liveIds=new Set([100]);fail=false;wrongFilter=false;canaryMissing=false;readHook=null;
  28. context={Psql,URLSearchParams,AbortSignal,Date,Parse:{Config:{get:async()=>({get:key=>({legacyScheduleApiUrl:'https://old.invalid/api',legacyScheduleApiId:'test',legacyScheduleApiKey:'test'}[key])})}},fetch:async url=>{
  29. const p=new URL(url).searchParams,requested=p.get('ids')?.split(',').map(Number);
  30. if(readHook&&requested&&!requested.includes(100))await readHook();
  31. if(fail&&requested&&!requested.includes(100))return{ok:true,json:async()=>({retcode:-1,result:[],page:{itemCount:0,pageCount:0}})};
  32. let ids=requested?requested.filter(id=>liveIds.has(id)):[100];
  33. if(requested&&wrongFilter&&!requested.includes(100))ids=[999];
  34. if(requested&&canaryMissing&&requested.includes(100))ids=[];
  35. return{ok:true,json:async()=>({retcode:1,result:JSON.stringify(ids.map(GeneralID=>({GeneralID}))),page:{itemCount:ids.length,pageCount:ids.length?1:0}})};
  36. }};
  37. vm.createContext(context);vm.runInContext(buildAppointmentDeletionGateway('co'),context);
  38. });
  39. async function call(params,role='super-admin',company='co'){
  40. let status=200,body;const response={status:n=>{status=n;return response;},json:value=>{body=value;return value;}};
  41. await context.handler({user:{id:'operator',fetch:async()=>{},get:key=>({adminRoleKey:role,company:{id:company}}[key])},body:{params:{runId,...params}}},response);
  42. return{status,...body};
  43. }
  44. async function seed(id,{source='legacy',status='0',managed=false,hold='',binding='',pay='',company='co'}={}){
  45. const sourceKey=source==='legacy'?'legacy:model:54:general:'+id:'cloud:appointment:'+id;
  46. await sql('INSERT INTO "CommonModel" VALUES($1,$2,$3,$4,$4,54,99,\'original\',\'2026-10-01\',\'2026-10-01\',$5)',['c'+id,company,sourceKey,id,managed]);
  47. await sql('INSERT INTO "CourseAppointment" VALUES($1,$2,$3,$4,$5,\'30\',$6,$7,\'\',$8,\'2026-10-01\',\'2026-10-01\',$9)',['a'+id,company,id,sourceKey,status,binding,hold,pay,managed]);
  48. }
  49. async function snapshot(){const result=await call({action:'source',page:1});assert.equal(result.success,true,result.message);}
  50. const status=async id=>(await sql('SELECT status FROM "CommonModel" WHERE "generalId"=$1',[id]))[0].status;
  51. test('确认缺失的旧预约标记取消,保留附表及业务字段;同一批次重放不重复修改',async()=>{
  52. await seed(101);await snapshot();const before=(await sql('SELECT to_jsonb(a) AS data FROM "CourseAppointment" a'))[0].data;
  53. const first=await call({action:'reconcile'});assert.equal(first.success,true,first.message);assert.equal(first.data.cancelled,1);assert.equal(Number(await status(101)),-2);
  54. assert.deepEqual((await sql('SELECT to_jsonb(a) AS data FROM "CourseAppointment" a'))[0].data,before);
  55. assert.deepEqual(JSON.parse(JSON.stringify((await call({action:'reconcile'})).data)),JSON.parse(JSON.stringify(first.data)));
  56. assert.equal((await sql('SELECT COUNT(*)::int count FROM "LegacyDirectBatch"'))[0].count,1);
  57. assert.equal(first.data.changes[0].confirmedAbsent,true);
  58. });
  59. test('旧系统恢复的编号即使不在快照中,也不能被取消',async()=>{
  60. await seed(101);await snapshot();liveIds.add(101);
  61. const result=await call({action:'reconcile'});assert.equal(result.data.cancelled,0);assert.equal(result.data.present,1);assert.equal(Number(await status(101)),99);
  62. });
  63. test('预览不改业务数据或保存冲突,预览后仍可执行实际核对',async()=>{
  64. await seed(101);await seed(102,{status:'30'});await snapshot();
  65. const result=await call({action:'reconcile',dryRun:true});assert.equal(result.data.wouldCancel,1);assert.equal(result.data.cancelled,0);assert.equal(Number(await status(101)),99);
  66. assert.equal((await sql('SELECT count(*)::int count FROM "LegacySyncConflict"'))[0].count,0);
  67. assert.equal((await call({action:'reconcile'})).data.cancelled,1);
  68. });
  69. test('接口失败、空目录正向验证失败及编号筛选失效都不能触发取消',async()=>{
  70. for(const mode of ['failure','canary','filter']){
  71. await db.exec('TRUNCATE "CommonModel","CourseAppointment","LegacyDirectBatch"');await seed(101);await snapshot();
  72. fail=mode==='failure';canaryMissing=mode==='canary';wrongFilter=mode==='filter';
  73. const result=await call({action:'reconcile'});assert.equal(result.success,false,mode);assert.equal(Number(await status(101)),99);
  74. fail=false;canaryMissing=false;wrongFilter=false;
  75. }
  76. });
  77. test('分页不完整、重复编号或过期快照拒绝删除',async()=>{
  78. await seed(101);await snapshot();
  79. for(const update of ["total=2","ids='[100,100]'::jsonb,total=2",'"capturedAt"=now()-interval \'1 hour\'']){
  80. await sql('UPDATE "LegacyDirectSourcePage" SET '+update);
  81. const result=await call({action:'reconcile'});assert.equal(result.success,false);assert.match(result.message,/incomplete_appointment_snapshot/);assert.equal(Number(await status(101)),99);
  82. await snapshot();
  83. }
  84. });
  85. test('新建、已上课、上课中、已预留课时、已计薪和已修改课次受保护,冲突幂等保存',async()=>{
  86. await seed(101,{source:'cloud'});await seed(102,{status:'30'});await seed(103,{status:'10'});await seed(104,{hold:'ready'});await seed(105,{binding:'binding'});await seed(106,{pay:'earned'});await seed(107,{managed:true});await seed(108);
  87. await sql('INSERT INTO "LessonRecord" VALUES(\'lesson\',\'co\',\'108\')');
  88. await seed(109);const row=(await sql('SELECT to_jsonb(c) c,to_jsonb(a) a FROM "CommonModel" c JOIN "CourseAppointment" a ON a.id=c."itemId" WHERE c."generalId"=109'))[0];
  89. await sql('INSERT INTO "LegacyDirectBaseline"(company,model,gid,fingerprint) VALUES(\'co\',54,109,md5($1::jsonb::text))',[JSON.stringify([row.c,row.a])]);await sql('UPDATE "CourseAppointment" SET pl=\'changed\' WHERE id=109');
  90. await snapshot();const result=await call({action:'reconcile'});assert.equal(result.success,true,result.message);assert.equal(result.data.cancelled,0);assert.equal(result.data.conflicts,8);
  91. for(let id=101;id<=109;id++)assert.equal(Number(await status(id)),99);
  92. assert.equal((await sql('SELECT count(*)::int count FROM "LegacySyncConflict"'))[0].count,8);
  93. });
  94. test('回查接口期间发生的新系统写入保留,不被删除覆盖',async()=>{
  95. await seed(101);await snapshot();readHook=()=>sql('UPDATE "CourseAppointment" SET pl=\'concurrent\' WHERE id=101');
  96. const result=await call({action:'reconcile'});assert.equal(result.data.cancelled,0);assert.equal(result.data.conflicts,1);assert.equal(Number(await status(101)),99);
  97. });
  98. test('其他帐套、非超级管理员不能访问,查询不得碰其他帐套课次',async()=>{
  99. await seed(101,{company:'other'});assert.equal((await call({action:'status'},'ops-manager')).status,403);assert.equal((await call({action:'status'},'super-admin','other')).status,403);
  100. await snapshot();const result=await call({action:'reconcile'});assert.equal(result.data.scanned,0);assert.equal(Number(await status(101)),99);
  101. });
  102. test('客户端完整分页后才核对,空目录和游标停滞明确失败,报告可追踪',async()=>{
  103. const root=await mkdtemp(join(tmpdir(),'appointment-delete-'));
  104. try{
  105. const actions=[];const report=await reconcileAppointmentDeletions({XIAOSHU_SYNC_REPORT_DIR:root},{call:async p=>{actions.push(p.action);if(p.action==='status')return{version:'confirmed-ids-v1'};if(p.action==='source')return{total:1001,pages:2};return{scanned:2,cancelled:1,present:0,conflicts:1,hasMore:false,after:101};}});
  106. assert.deepEqual(actions,['status','source','source','reconcile']);assert.equal(report.status,'completed-with-preserved-conflicts');assert.equal(report.cancelled,1);
  107. await assert.rejects(reconcileAppointmentDeletions({XIAOSHU_SYNC_REPORT_DIR:root},{call:async p=>p.action==='status'?{version:'confirmed-ids-v1'}:{total:0,pages:1}}),/incomplete_appointment_snapshot/);
  108. await assert.rejects(reconcileAppointmentDeletions({XIAOSHU_SYNC_REPORT_DIR:root},{sourceRunId:runId,call:async p=>p.action==='status'?{version:'confirmed-ids-v1'}:{scanned:50,hasMore:true,after:0}}),/cursor_stalled/);
  109. }finally{await rm(root,{recursive:true,force:true});}
  110. });
  111. test('同步能力补丁可重复使用,并保留现有复习和财务代码',()=>{
  112. const original='async function handler(request,response){\n const p=request.body.params,co="co";\n if(p.action===\'status\')return directControl(p,co,u);\n}\nfunction directControl(){}';
  113. const patched=patchAppointmentDeletionCapability(original);assert.equal(patchAppointmentDeletionCapability(patched),patched);assert.ok(patched.includes(original.slice(original.indexOf(" if(p.action==='status')"))));
  114. });
  115. test('夜间任务在预约对账后自动检查删除,并将保留冲突纳入汇总',async()=>{
  116. const root=await mkdtemp(join(tmpdir(),'nightly-appointment-delete-')),saved=globalThis.fetch;const actions=[];
  117. const {createHash}=await import('node:crypto');
  118. try{
  119. globalThis.fetch=async(url,options)=>{
  120. const p=JSON.parse(options.body).params;let data;
  121. if(String(url).endsWith('/appointment-deletions-v1')){
  122. actions.push(p.action);data=p.action==='status'?{version:'confirmed-ids-v1'}:p.action==='source'?{total:1,pages:1,count:1}:{scanned:3,cancelled:2,conflicts:1,hasMore:false,after:101};
  123. }else switch(p.action){
  124. case 'status':data={source:'old-api/WXAPP',bridgeUsed:false,appointmentDeletionVersion:'confirmed-ids-v1'};break;
  125. case 'claim':data={runId,businessDate:'2026-11-02'};break;
  126. case 'supplement':data={accounts:{conflicts:0},nodes:{}};break;
  127. case 'source':data={items:p.modelId===54?[{GeneralID:100}]:[],page:{itemCount:p.modelId===54?1:0,pageCount:1}};break;
  128. case 'read':data=[{generalId:100,common:{objectId:'c100'},addon:{id:100}}];break;
  129. case 'evidence':data={sourceChecksum:createHash('md5').update(p.modelId===54?'100':'').digest('hex'),reconciled:true};break;
  130. case 'finance-members':data=[];break;
  131. case 'finish':data=p.summary;break;
  132. default:throw Error('Unexpected action '+p.action);
  133. }
  134. return{ok:true,json:async()=>({success:true,data})};
  135. };
  136. const result=await runDirectSync({XIAOSHU_SYNC_REPORT_DIR:root,XIAOSHU_SYNC_OPERATOR_TOKEN:'test'});
  137. assert.equal(result.status,'completed-with-preserved-conflicts',result.reason);assert.equal(result.appointmentDeletions.cancelled,2);assert.equal(result.datasets.find(row=>row.modelId===54).deleted,2);assert.equal(result.conflicts,1);assert.deepEqual(actions,['status','source','reconcile']);
  138. }finally{globalThis.fetch=saved;await rm(root,{recursive:true,force:true});}
  139. });