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});} });