|
|
@@ -0,0 +1,52 @@
|
|
|
+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,"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'`);
|
|
|
+});
|