|
|
@@ -0,0 +1,141 @@
|
|
|
+import {test,before,after,beforeEach} from 'node:test';
|
|
|
+import assert from 'node:assert/strict';
|
|
|
+import {readFileSync} from 'node:fs';
|
|
|
+import {mkdtemp,rm} from 'node:fs/promises';
|
|
|
+import {tmpdir} from 'node:os';
|
|
|
+import {join} from 'node:path';
|
|
|
+import vm from 'node:vm';
|
|
|
+import {PGlite} from '@electric-sql/pglite';
|
|
|
+import {buildAppointmentDeletionGateway,patchAppointmentDeletionCapability} from '../lib/legacy-appointment-deletions.mjs';
|
|
|
+import {reconcileAppointmentDeletions} from '../reconcile-legacy-appointment-deletions.mjs';
|
|
|
+import {runDirectSync} from '../run-nightly-direct-sync.mjs';
|
|
|
+
|
|
|
+const db=new PGlite(),runId='12345678-1234-4234-8234-123456789abc';
|
|
|
+const sql=async(q,args=[])=>(await db.query(q,args)).rows;
|
|
|
+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);}};
|
|
|
+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;}};
|
|
|
+let context,liveIds,fail,wrongFilter,canaryMissing,readHook;
|
|
|
+before(async()=>{
|
|
|
+ await db.exec(readFileSync(new URL('../sql/legacy-direct-sync.sql',import.meta.url),'utf8'));
|
|
|
+ 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);
|
|
|
+ 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);
|
|
|
+ CREATE TABLE "LessonRecord"("objectId" text,company text,yyds text);
|
|
|
+ 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);`);
|
|
|
+});
|
|
|
+after(()=>db.close());
|
|
|
+beforeEach(async()=>{
|
|
|
+ await db.exec('TRUNCATE "CommonModel","CourseAppointment","LessonRecord","LegacySyncConflict","LegacyDirectBaseline","LegacyDirectBatch","LegacyDirectSourcePage"');
|
|
|
+ liveIds=new Set([100]);fail=false;wrongFilter=false;canaryMissing=false;readHook=null;
|
|
|
+ context={Psql,URLSearchParams,AbortSignal,Date,Parse:{Config:{get:async()=>({get:key=>({legacyScheduleApiUrl:'https://old.invalid/api',legacyScheduleApiId:'test',legacyScheduleApiKey:'test'}[key])})}},fetch:async url=>{
|
|
|
+ const p=new URL(url).searchParams,requested=p.get('ids')?.split(',').map(Number);
|
|
|
+ if(readHook&&requested&&!requested.includes(100))await readHook();
|
|
|
+ if(fail&&requested&&!requested.includes(100))return{ok:true,json:async()=>({retcode:-1,result:[],page:{itemCount:0,pageCount:0}})};
|
|
|
+ let ids=requested?requested.filter(id=>liveIds.has(id)):[100];
|
|
|
+ if(requested&&wrongFilter&&!requested.includes(100))ids=[999];
|
|
|
+ if(requested&&canaryMissing&&requested.includes(100))ids=[];
|
|
|
+ return{ok:true,json:async()=>({retcode:1,result:JSON.stringify(ids.map(GeneralID=>({GeneralID}))),page:{itemCount:ids.length,pageCount:ids.length?1:0}})};
|
|
|
+ }};
|
|
|
+ vm.createContext(context);vm.runInContext(buildAppointmentDeletionGateway('co'),context);
|
|
|
+});
|
|
|
+async function call(params,role='super-admin',company='co'){
|
|
|
+ let status=200,body;const response={status:n=>{status=n;return response;},json:value=>{body=value;return value;}};
|
|
|
+ await context.handler({user:{id:'operator',fetch:async()=>{},get:key=>({adminRoleKey:role,company:{id:company}}[key])},body:{params:{runId,...params}}},response);
|
|
|
+ return{status,...body};
|
|
|
+}
|
|
|
+async function seed(id,{source='legacy',status='0',managed=false,hold='',binding='',pay='',company='co'}={}){
|
|
|
+ const sourceKey=source==='legacy'?'legacy:model:54:general:'+id:'cloud:appointment:'+id;
|
|
|
+ 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]);
|
|
|
+ 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]);
|
|
|
+}
|
|
|
+async function snapshot(){const result=await call({action:'source',page:1});assert.equal(result.success,true,result.message);}
|
|
|
+const status=async id=>(await sql('SELECT status FROM "CommonModel" WHERE "generalId"=$1',[id]))[0].status;
|
|
|
+
|
|
|
+test('确认缺失的旧预约标记取消,保留附表及业务字段;同一批次重放不重复修改',async()=>{
|
|
|
+ await seed(101);await snapshot();const before=(await sql('SELECT to_jsonb(a) AS data FROM "CourseAppointment" a'))[0].data;
|
|
|
+ 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);
|
|
|
+ assert.deepEqual((await sql('SELECT to_jsonb(a) AS data FROM "CourseAppointment" a'))[0].data,before);
|
|
|
+ assert.deepEqual(JSON.parse(JSON.stringify((await call({action:'reconcile'})).data)),JSON.parse(JSON.stringify(first.data)));
|
|
|
+ assert.equal((await sql('SELECT COUNT(*)::int count FROM "LegacyDirectBatch"'))[0].count,1);
|
|
|
+ assert.equal(first.data.changes[0].confirmedAbsent,true);
|
|
|
+});
|
|
|
+test('旧系统恢复的编号即使不在快照中,也不能被取消',async()=>{
|
|
|
+ await seed(101);await snapshot();liveIds.add(101);
|
|
|
+ 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);
|
|
|
+});
|
|
|
+test('预览不改业务数据或保存冲突,预览后仍可执行实际核对',async()=>{
|
|
|
+ await seed(101);await seed(102,{status:'30'});await snapshot();
|
|
|
+ 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);
|
|
|
+ assert.equal((await sql('SELECT count(*)::int count FROM "LegacySyncConflict"'))[0].count,0);
|
|
|
+ assert.equal((await call({action:'reconcile'})).data.cancelled,1);
|
|
|
+});
|
|
|
+test('接口失败、空目录正向验证失败及编号筛选失效都不能触发取消',async()=>{
|
|
|
+ for(const mode of ['failure','canary','filter']){
|
|
|
+ await db.exec('TRUNCATE "CommonModel","CourseAppointment","LegacyDirectBatch"');await seed(101);await snapshot();
|
|
|
+ fail=mode==='failure';canaryMissing=mode==='canary';wrongFilter=mode==='filter';
|
|
|
+ const result=await call({action:'reconcile'});assert.equal(result.success,false,mode);assert.equal(Number(await status(101)),99);
|
|
|
+ fail=false;canaryMissing=false;wrongFilter=false;
|
|
|
+ }
|
|
|
+});
|
|
|
+test('分页不完整、重复编号或过期快照拒绝删除',async()=>{
|
|
|
+ await seed(101);await snapshot();
|
|
|
+ for(const update of ["total=2","ids='[100,100]'::jsonb,total=2",'"capturedAt"=now()-interval \'1 hour\'']){
|
|
|
+ await sql('UPDATE "LegacyDirectSourcePage" SET '+update);
|
|
|
+ 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);
|
|
|
+ await snapshot();
|
|
|
+ }
|
|
|
+});
|
|
|
+test('新建、已上课、上课中、已预留课时、已计薪和已修改课次受保护,冲突幂等保存',async()=>{
|
|
|
+ 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);
|
|
|
+ await sql('INSERT INTO "LessonRecord" VALUES(\'lesson\',\'co\',\'108\')');
|
|
|
+ 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];
|
|
|
+ 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');
|
|
|
+ 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);
|
|
|
+ for(let id=101;id<=109;id++)assert.equal(Number(await status(id)),99);
|
|
|
+ assert.equal((await sql('SELECT count(*)::int count FROM "LegacySyncConflict"'))[0].count,8);
|
|
|
+});
|
|
|
+test('回查接口期间发生的新系统写入保留,不被删除覆盖',async()=>{
|
|
|
+ await seed(101);await snapshot();readHook=()=>sql('UPDATE "CourseAppointment" SET pl=\'concurrent\' WHERE id=101');
|
|
|
+ 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);
|
|
|
+});
|
|
|
+test('其他帐套、非超级管理员不能访问,查询不得碰其他帐套课次',async()=>{
|
|
|
+ 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);
|
|
|
+ await snapshot();const result=await call({action:'reconcile'});assert.equal(result.data.scanned,0);assert.equal(Number(await status(101)),99);
|
|
|
+});
|
|
|
+test('客户端完整分页后才核对,空目录和游标停滞明确失败,报告可追踪',async()=>{
|
|
|
+ const root=await mkdtemp(join(tmpdir(),'appointment-delete-'));
|
|
|
+ try{
|
|
|
+ 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};}});
|
|
|
+ assert.deepEqual(actions,['status','source','source','reconcile']);assert.equal(report.status,'completed-with-preserved-conflicts');assert.equal(report.cancelled,1);
|
|
|
+ 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/);
|
|
|
+ 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/);
|
|
|
+ }finally{await rm(root,{recursive:true,force:true});}
|
|
|
+});
|
|
|
+test('同步能力补丁可重复使用,并保留现有复习和财务代码',()=>{
|
|
|
+ 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(){}';
|
|
|
+ const patched=patchAppointmentDeletionCapability(original);assert.equal(patchAppointmentDeletionCapability(patched),patched);assert.ok(patched.includes(original.slice(original.indexOf(" if(p.action==='status')"))));
|
|
|
+});
|
|
|
+test('夜间任务在预约对账后自动检查删除,并将保留冲突纳入汇总',async()=>{
|
|
|
+ const root=await mkdtemp(join(tmpdir(),'nightly-appointment-delete-')),saved=globalThis.fetch;const actions=[];
|
|
|
+ const {createHash}=await import('node:crypto');
|
|
|
+ try{
|
|
|
+ globalThis.fetch=async(url,options)=>{
|
|
|
+ const p=JSON.parse(options.body).params;let data;
|
|
|
+ if(String(url).endsWith('/appointment-deletions-v1')){
|
|
|
+ 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};
|
|
|
+ }else switch(p.action){
|
|
|
+ case 'status':data={source:'old-api/WXAPP',bridgeUsed:false,appointmentDeletionVersion:'confirmed-ids-v1'};break;
|
|
|
+ case 'claim':data={runId,businessDate:'2026-11-02'};break;
|
|
|
+ case 'supplement':data={accounts:{conflicts:0},nodes:{}};break;
|
|
|
+ case 'source':data={items:p.modelId===54?[{GeneralID:100}]:[],page:{itemCount:p.modelId===54?1:0,pageCount:1}};break;
|
|
|
+ case 'read':data=[{generalId:100,common:{objectId:'c100'},addon:{id:100}}];break;
|
|
|
+ case 'evidence':data={sourceChecksum:createHash('md5').update(p.modelId===54?'100':'').digest('hex'),reconciled:true};break;
|
|
|
+ case 'finance-members':data=[];break;
|
|
|
+ case 'finish':data=p.summary;break;
|
|
|
+ default:throw Error('Unexpected action '+p.action);
|
|
|
+ }
|
|
|
+ return{ok:true,json:async()=>({success:true,data})};
|
|
|
+ };
|
|
|
+ const result=await runDirectSync({XIAOSHU_SYNC_REPORT_DIR:root,XIAOSHU_SYNC_OPERATOR_TOKEN:'test'});
|
|
|
+ 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']);
|
|
|
+ }finally{globalThis.fetch=saved;await rm(root,{recursive:true,force:true});}
|
|
|
+});
|