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