import{test,before,after}from'node:test';import assert from'node:assert/strict';import{readFileSync}from'node:fs';import vm from'node:vm';import{PGlite}from'@electric-sql/pglite';import{legacyAppointmentDefaults}from'../legacy-appointment-transform.mjs'; const db=new PGlite(),source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8'),ops=source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1],ctx=vm.createContext({});vm.runInContext(legacyAppointmentDefaults(ops),ctx);const seeds=JSON.stringify(ctx.legacyAppointmentSeedRules()); const q=async(s,p=[])=>(await db.query(s,p)).rows,one=async(s,p=[])=>(await q(s,p))[0]; const prepare=async(id,actor='teacher')=>(await one('SELECT xs_prepare_legacy_appointment(\'co\',$1,$2,$3::jsonb) r',[id,actor,seeds])).r; before(async()=>{ await db.exec(`CREATE TABLE "_User"("objectId" text primary key,company text,"legacyUserId" numeric,"legacyUserData" jsonb,"isDisabled" boolean,"isDeleted" boolean,"adminRoleKey" text); CREATE TABLE "CommonModel"("objectId" text primary key,company text,"modelId" numeric,"itemId" numeric,"generalId" numeric,status numeric,"sourceKey" text); CREATE TABLE "CourseAppointment"("objectId" text primary key,company text,id numeric,szyh text,pl text,kcid text,yykcid text,dszt text,dslx text,jffs text,bxrq text,yysj text,"sourceKey" text,"teachingManaged" boolean,"bindingId" text,"courseCategoryKey" text,"courseCategoryName" text,"categoryEvidence" text,"durationMinutes" numeric,"deliveryMode" text,"teachingRuleId" text,"teacherPaySnapshot" numeric,"creditCostsSnapshot" jsonb,"creditHoldStatus" text,"creditHoldReason" text,"updatedAt" timestamptz); CREATE TABLE "CourseBinding"("objectId" text primary key,company text,id numeric,yhid text,kcid text,"categoryKey" text,"categoryName" text,"enrollmentStatus" text,"sourceKey" text,"updatedAt" timestamptz); CREATE TABLE "CourseTeachingProfile"(company text,"courseId" numeric,"categoryKey" text,confirmed boolean); CREATE TABLE "TeachingCourseCategory"(company text,key text,active boolean); CREATE TABLE "TeachingCompensationRule"(company text,"objectId" text,"categoryKey" text,"durationMinutes" numeric,"deliveryMode" text,"effectiveFrom" text,active boolean,"teacherPay" numeric,"creditCosts" jsonb,revision numeric); CREATE TABLE "CourseCreditReservation"("objectId" text primary key,company text,"sourceKey" text,"createdAt" timestamptz,"updatedAt" timestamptz,"appointmentId" text unique,"appointmentGeneralId" numeric,"studentId" numeric,"ruleId" text,"creditCosts" jsonb,state text,"reservedAt" timestamptz); CREATE TABLE "Node"(company text,"nodeId" numeric,"parentId" numeric,zstatus numeric); CREATE TABLE audits(target text,before_data jsonb,after_data jsonb); CREATE FUNCTION xs_payroll_audit(co text,actor text,action text,target text,reason text,before_data jsonb,after_data jsonb) RETURNS void LANGUAGE sql AS $$ INSERT INTO audits VALUES(target,before_data,after_data) $$; INSERT INTO "_User" VALUES('teacher','co',30,'{}',false,false,null),('student','co',20,'{"Purse":100,"SilverCoin":100,"UserPoint":100}',false,false,null),('other','other',30,'{}',false,false,null),('stranger','co',31,'{}',false,false,null); INSERT INTO "Node" VALUES('co',100,null,99),('co',101,null,99); INSERT INTO "CommonModel" VALUES('word','co',52,null,1,99,null),('binding-common','co',58,1,10,99,'legacy:model:58:general:10');UPDATE "CommonModel" SET "itemId"=NULL WHERE "objectId"='word'; ALTER TABLE "CommonModel" ADD "nodeId" numeric;UPDATE "CommonModel" SET "nodeId"=100 WHERE "objectId"='word'; INSERT INTO "CourseBinding" VALUES('binding','co',1,'20','100','word','单词课','pending','legacy:model:58:general:10',now()); INSERT INTO "CourseTeachingProfile" VALUES('co',100,'word',true);`); const enrollment=readFileSync(new URL('../sql/member-enrollment.sql',import.meta.url),'utf8');for(const name of ['xs_object_id','xs_insert','xs_appointment_day','xs_appointment_valid_at','xs_reserved','xs_course_size']){const def=enrollment.match(new RegExp('CREATE OR REPLACE FUNCTION '+name+'\\([\\s\\S]*?\\$\\$;'))[0];await db.exec(def);} await db.exec(readFileSync(new URL('../sql/legacy-appointment-preparation.sql',import.meta.url),'utf8')); });after(()=>db.close()); let serial=0;async function fixture(values={}){const id='a'+(++serial),day=(await one("SELECT to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD') AS day")).day;const r={objectId:id,company:'co',id:serial,szyh:'20',pl:'30',kcid:'100',yykcid:'100',dszt:'0',dslx:'2',jffs:'线上',bxrq:day,yysj:day+' 00:00',sourceKey:'legacy:model:54:general:'+serial,...values};await q('INSERT INTO "CourseAppointment" SELECT * FROM jsonb_populate_record(NULL::"CourseAppointment",$1::jsonb)',[JSON.stringify(r)]);await q('INSERT INTO "CommonModel"("objectId",company,"modelId","itemId","generalId",status,"sourceKey") VALUES($1,\'co\',54,$2,$2,99,$3)',[id+'c',serial,r.sourceKey]);return id+'c';} test('当天已过钟点的旧预约恢复唯一开课、工资快照和预留,重试不改余额或状态',async()=>{const id=await fixture(),before=await one('SELECT "legacyUserData" FROM "_User" WHERE "objectId"=\'student\'');assert.equal((await prepare(id)).changed,true);assert.equal((await prepare(id)).changed,false);const a=await one('SELECT * FROM "CourseAppointment" WHERE "objectId"=$1',[id.slice(0,-1)]);assert.equal(a.bindingId,'binding');assert.equal(a.kcid,'100');assert.equal(a.dszt,'0');assert.equal(Number(a.teacherPaySnapshot),30);assert.equal(a.creditHoldStatus,'ready');assert.deepEqual(await one('SELECT "legacyUserData" FROM "_User" WHERE "objectId"=\'student\''),before);assert.equal((await one('SELECT count(*)::int n FROM audits WHERE target=$1',[id])).n,1);assert.equal((await one('SELECT count(*)::int n FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[id])).n,1);}); test('越权、跨帐套、过期、取消、新系统预约及已开课不能自动修复',async()=>{const id=await fixture();for(const actor of ['stranger','other'])await assert.rejects(prepare(id,actor),/无权/);await assert.rejects(prepare(await fixture({pl:null}),'stranger'),/无权/);for(const changes of [{bxrq:'2000-01-01'},{dszt:'10'},{sourceKey:'schedule:new'},{kcid:'101'}])await assert.rejects(prepare(await fixture(changes)),/过期|未开始|旧预约|不一致/);const cancelled=await fixture();await q('UPDATE "CommonModel" SET status=-2 WHERE "objectId"=$1',[cancelled]);await assert.rejects(prepare(cancelled),/未取消/);}); test('课时不足时整个事务回滚,不激活开课、不保存快照或审计',async()=>{await db.exec(`UPDATE "CourseBinding" SET "enrollmentStatus"='pending';UPDATE "_User" SET "legacyUserData"='{}' WHERE "objectId"='student'`);const id=await fixture();await assert.rejects(prepare(id),/不足/);assert.equal((await one('SELECT "enrollmentStatus" FROM "CourseBinding" WHERE "objectId"=\'binding\'')).enrollmentStatus,'pending');assert.equal((await one('SELECT "bindingId" FROM "CourseAppointment" WHERE "objectId"=$1',[id.slice(0,-1)])).bindingId,null);await db.exec(`UPDATE "_User" SET "legacyUserData"='{"Purse":100,"SilverCoin":100,"UserPoint":100}' WHERE "objectId"='student'`);}); test('重复开课、回收开课、未确认课程分类不能猜测恢复',async()=>{const id=await fixture();await db.exec(`INSERT INTO "CourseBinding" SELECT 'duplicate',company,2,yhid,kcid,"categoryKey","categoryName","enrollmentStatus","sourceKey",now() FROM "CourseBinding" WHERE "objectId"='binding';INSERT INTO "CommonModel"("objectId",company,"modelId","itemId",status) VALUES('duplicate-common','co',58,2,99)`);await assert.rejects(prepare(id),/唯一/);await db.exec(`DELETE FROM "CourseBinding" WHERE "objectId"='duplicate';DELETE FROM "CommonModel" WHERE "objectId"='duplicate-common';UPDATE "CourseBinding" SET "enrollmentStatus"='recycled'`);await assert.rejects(prepare(id),/唯一/);await db.exec(`UPDATE "CourseBinding" SET "enrollmentStatus"='pending';UPDATE "CourseTeachingProfile" SET confirmed=false`);await assert.rejects(prepare(id),/分类/);await db.exec(`UPDATE "CourseTeachingProfile" SET confirmed=true`);}); test('新系统已有工资快照保留,正式规则覆盖默认版本,禁用版本不能回退同版本默认价',async()=>{await db.exec(`INSERT INTO "TeachingCompensationRule" VALUES('co','custom','word',60,'online','2026-09-09',true,35,'[{"account":"SilverCoin","amount":1},{"account":"UserPoint","amount":1}]',1)`);const id=await fixture({teacherPaySnapshot:37});await prepare(id);assert.equal(Number((await one('SELECT "teacherPaySnapshot" FROM "CourseAppointment" WHERE "objectId"=$1',[id.slice(0,-1)])).teacherPaySnapshot),37);const next=await fixture();await prepare(next);assert.equal(Number((await one('SELECT "teacherPaySnapshot" FROM "CourseAppointment" WHERE "objectId"=$1',[next.slice(0,-1)])).teacherPaySnapshot),35);}); test('教师详情自动核对后返回开始权限与实际绑定;学习单元采用同一开课记录',async()=>{ await db.exec(`ALTER TABLE "CourseAppointment" ADD kssj text,ADD jssj text;CREATE TABLE "LessonRecord"(company text,yyds text,pf numeric,pjnr text,pldp text,"createdAt" timestamptz);CREATE TABLE "LessonReview"(company text,"appointmentGeneralId" text,score numeric,comment text,"submittedAt" timestamptz);`); const app=source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1].replace("const DEFAULT_COMPANY_ID = '7pIbDBJmKx'","const DEFAULT_COMPANY_ID = 'co'"); const c=vm.createContext({Psql:{one,oneOrNone:one,query:q}});vm.runInContext(app+legacyAppointmentDefaults(ops)+readFileSync(new URL('../cloud/legacy-appointment.js',import.meta.url),'utf8')+readFileSync(new URL('../cloud/lesson-workflow.js',import.meta.url),'utf8'),c); const id=await fixture(),teacher={id:'teacher',get:k=>({company:{id:'co'},legacyUserId:30}[k])}; const detail=await c.lessonOrderCapabilities({GeneralID:String(serial),objectId:id,pl:'30',szyh:'20',dszt:'0',kcid:'100'},teacher); assert.equal(detail.bindingId,'binding');assert.equal(detail.kcid,'100');assert.deepEqual(JSON.parse(JSON.stringify(detail.allowedActions)),['start']);assert.equal(detail.blockedReason,''); const binding=await one('SELECT "enrollmentStatus" FROM "CourseBinding" WHERE "objectId"=$1',[detail.bindingId]);assert.equal(binding.enrollmentStatus,'active'); }); test('审计失败回滚已激活的开课、课时预留和快照,修复可重试',async()=>{ await db.exec(`UPDATE "CourseBinding" SET "enrollmentStatus"='pending';CREATE FUNCTION reject_audit() RETURNS trigger LANGUAGE plpgsql AS $$BEGIN RAISE EXCEPTION 'audit unavailable';END$$;CREATE TRIGGER reject_audit BEFORE INSERT ON audits FOR EACH ROW EXECUTE FUNCTION reject_audit()`); const id=await fixture();await assert.rejects(prepare(id),/audit unavailable/); assert.equal((await one('SELECT "enrollmentStatus" FROM "CourseBinding" WHERE "objectId"=\'binding\'')).enrollmentStatus,'pending'); assert.equal((await one('SELECT "bindingId" FROM "CourseAppointment" WHERE "objectId"=$1',[id.slice(0,-1)])).bindingId,null); assert.equal((await one('SELECT count(*)::int n FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[id])).n,0); await db.exec('DROP TRIGGER reject_audit ON audits');assert.equal((await prepare(id)).changed,true); }); test('新导入的空分类开课只补明确的非写作分类;全局写作映射不能替代逐人确认',async()=>{ await db.exec(`UPDATE "CourseBinding" SET "enrollmentStatus"=null,"categoryKey"=null,"categoryName"=null`);const id=await fixture();await prepare(id);assert.equal((await one('SELECT "categoryKey" FROM "CourseBinding" WHERE "objectId"=\'binding\'')).categoryKey,'word'); await db.exec(`UPDATE "CourseBinding" SET "enrollmentStatus"=null,"categoryKey"=null,"categoryName"=null;UPDATE "CourseTeachingProfile" SET "categoryKey"='high_writing'`);await assert.rejects(prepare(await fixture()),/按学员/);await db.exec(`UPDATE "CourseTeachingProfile" SET "categoryKey"='word'`); }); test('旧阅读写作预约经逐人确认高中后免词库核验,保留原课程并预留课时',async()=>{ const id=await fixture({kcid:'398',yykcid:'398',courseCategoryKey:'high_writing',categoryEvidence:'用户确认高中'}); const r=await prepare(id);assert.equal(r.changed,true); const a=await one('SELECT * FROM "CourseAppointment" WHERE "objectId"=$1',[id.slice(0,-1)]); assert.equal(a.kcid,'398');assert.equal(a.bindingId,null);assert.equal(a.creditHoldStatus,'ready');assert.equal(Number(a.teacherPaySnapshot),65); assert.equal((await one('SELECT count(*)::int n FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[id])).n,1); assert.equal((await prepare(id)).changed,false); }); test('旧阅读写作未确认学段不得自动预留',async()=>{const id=await fixture({kcid:'398',yykcid:'398'});await assert.rejects(prepare(id),/单词|分类|课程/);}); test('旧系统体验课按体验课时预留,不按词库的普通课分类扣费',async()=>{ await db.exec('BEGIN');try{ await db.exec(`UPDATE "_User" SET "legacyUserData"='{"UserExp":1,"UserPoint":0,"Purse":0,"SilverCoin":0}' WHERE "objectId"='student';UPDATE "CourseBinding" SET "categoryKey"=NULL,"categoryName"=NULL,"enrollmentStatus"=NULL WHERE "objectId"='binding';`); const id=await fixture({dslx:'3'}),before=await one('SELECT "legacyUserData" FROM "_User" WHERE "objectId"=\'student\''); const first=await prepare(id),a=await one('SELECT * FROM "CourseAppointment" WHERE "objectId"=$1',[id.slice(0,-1)]); assert.equal(first.changed,true);assert.equal(a.courseCategoryKey,'trial');assert.equal(a.creditHoldStatus,'ready');assert.equal(Number(a.teacherPaySnapshot),30);assert.deepEqual(a.creditCostsSnapshot.map(c=>[c.account,Number(c.amount)]),[['UserExp',1]]); assert.equal((await one('SELECT "categoryKey" FROM "CourseBinding" WHERE "objectId"=\'binding\'')).categoryKey,'word');assert.deepEqual(await one('SELECT "legacyUserData" FROM "_User" WHERE "objectId"=\'student\''),before); assert.equal((await prepare(id)).changed,false);assert.equal((await one('SELECT count(*)::int n FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[id])).n,1); }finally{await db.exec('ROLLBACK');} }); test('旧体验课可复用已开通的普通词库,不重写词库分类且准备幂等',async()=>{ await db.exec('BEGIN');try{ await db.exec(`UPDATE "_User" SET "legacyUserData"='{"UserExp":1,"UserPoint":0,"Purse":0,"SilverCoin":0}' WHERE "objectId"='student';UPDATE "CourseBinding" SET "categoryKey"='word',"categoryName"='单词课',"enrollmentStatus"='active' WHERE "objectId"='binding'`); const id=await fixture({dslx:'3'});assert.equal((await prepare(id)).changed,true);assert.equal((await prepare(id)).changed,false); const b=await one(`SELECT "categoryKey","enrollmentStatus" FROM "CourseBinding" WHERE "objectId"='binding'`);assert.equal(b.categoryKey,'word');assert.equal(b.enrollmentStatus,'active'); }finally{await db.exec('ROLLBACK');} }); test('体验课时不足时拒绝预留,且不激活原有开课记录',async()=>{ await db.exec('BEGIN');try{ await db.exec(`UPDATE "_User" SET "legacyUserData"='{"UserExp":0,"UserPoint":30,"Purse":30,"SilverCoin":30}' WHERE "objectId"='student';UPDATE "CourseBinding" SET "categoryKey"=NULL,"enrollmentStatus"=NULL WHERE "objectId"='binding'`); const id=await fixture({dslx:'3'});await db.exec('SAVEPOINT insufficient');await assert.rejects(prepare(id),/UserExp/);await db.exec('ROLLBACK TO SAVEPOINT insufficient'); assert.equal((await one(`SELECT "enrollmentStatus" FROM "CourseBinding" WHERE "objectId"='binding'`)).enrollmentStatus,null);assert.equal((await one('SELECT count(*)::int n FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[id])).n,0); }finally{await db.exec('ROLLBACK');} }); test('先准备体验课再准备同教材普通课,各自使用正确课时且不改写共享词库分类',async()=>{ await db.exec('BEGIN');try{ await db.exec(`UPDATE "_User" SET "legacyUserData"='{"UserExp":1,"UserPoint":100,"Purse":0,"SilverCoin":100}' WHERE "objectId"='student';UPDATE "CourseBinding" SET "categoryKey"=NULL,"categoryName"=NULL,"enrollmentStatus"=NULL WHERE "objectId"='binding'`); const trial=await fixture({dslx:'3'});await prepare(trial); const normal=await fixture({dslx:'2'});await prepare(normal); const a=await one('SELECT "courseCategoryKey","creditCostsSnapshot" FROM "CourseAppointment" WHERE "objectId"=$1',[normal.slice(0,-1)]); assert.equal(a.courseCategoryKey,'word');assert.deepEqual(a.creditCostsSnapshot.map(c=>c.account).sort(),['SilverCoin','UserPoint']); }finally{await db.exec('ROLLBACK');} });