member-enrollment-gateway.test.mjs 7.6 KB

123456789101112131415161718192021222324252627282930313233
  1. import {test} from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import {readFileSync} from 'node:fs';
  4. import vm from 'node:vm';
  5. const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
  6. const ops=source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1];
  7. const extension=readFileSync(new URL('../cloud/member-enrollment.js',import.meta.url),'utf8');
  8. function gateway(){const c={Date};vm.createContext(c);vm.runInContext(ops+'\n'+extension,c);return c;}
  9. const plain=x=>JSON.parse(JSON.stringify(x));
  10. function setup(){const c=gateway();c.companyIdOf=()=> 'co';c.requireRole=()=>{};c.reasonOf=i=>i.reason;c.userByLegacyId=async()=>({objectId:'student',legacyUserData:{Purse:5,SilverCoin:5,UserPoint:5,UserExp:1}});c.reservedCreditsFor=async()=>({});c.teachingCategoryRows=async()=>vm.runInContext('TEACHING_CATEGORY_DEFAULTS',c);c.teachingRuleRows=async()=>c.defaultTeachingRules();c.businessDate=()=> '2026-09-09';c.enrollmentBindings=async()=>[];c.enrollmentCatalog=async()=>['primary','middle','high'].map((stageKey,index)=>({courseId:100+index,courseName:stageKey,stageKey,wordCount:20,active:true}));return c;}
  11. test('六个新分类完整可选,语法写作仅选择对应学阶',async()=>{const c=setup();for(const category of await c.teachingCategoryRows()){const options=await c.enrollmentOptions({}, {studentId:20,categoryKey:category.key});assert.equal(options.categories.length,6);assert.equal(options.eligible,true,category.key);assert.equal(options.items.length,category.stageKey?1:3);if(category.stageKey)assert.equal(options.items[0].stageKey,category.stageKey);}});
  12. test('零余额、配套陪练不足和空内容分别说明原因',async()=>{const c=setup();c.userByLegacyId=async()=>({legacyUserData:{}});let o=await c.enrollmentOptions({}, {studentId:20,categoryKey:'word'});assert.equal(o.eligible,false);assert.match(o.reason,/课时|陪练/);c.userByLegacyId=async()=>({legacyUserData:{Purse:5,SilverCoin:5}});o=await c.enrollmentOptions({}, {studentId:20,categoryKey:'word'});assert.equal(o.eligible,false);c.userByLegacyId=async()=>({legacyUserData:{Purse:5,SilverCoin:5,UserPoint:5}});c.enrollmentCatalog=async()=>[{courseId:100,wordCount:0,active:true},{courseId:101,wordCount:5,active:false}];o=await c.enrollmentOptions({}, {studentId:20,categoryKey:'word'});assert.match(o.items[0].reason,/空/);assert.match(o.items[1].reason,/停用/);});
  13. test('同一内容有多个开课分类时旧请求拒绝,指定 bindingId 匹配各自规则',async()=>{const c=setup();c.enrollmentBindings=async()=>[{bindingId:'word',courseId:100,categoryKey:'word'},{bindingId:'self',courseId:100,categoryKey:'word_self_study'}];await assert.rejects(c.enrollmentTeachingContext({}, {},{studentId:20,courseId:100,durationMinutes:60,deliveryMode:'online'},'2026-09-09'),/多个开课分类/);for(const [id,key] of [['word','word'],['self','word_self_study']]){const r=await c.enrollmentTeachingContext({}, {},{studentId:20,courseId:100,bindingId:id,durationMinutes:60,deliveryMode:'online'},'2026-09-09');assert.equal(r.rule.categoryKey,key);assert.equal(r.bindingId,id);}});
  14. test('开课请求成功后的重试复用结果,不重新校验已经耗尽的余额',async()=>{const c=setup(),req={uid:20,category:'word',courseIds:[100],actor:'a',reason:'正式开课'};c.enrollmentOptions=async()=>{throw Error('不应重新计算资格');};c.Psql={oneOrNone:async()=>({request:req,result:{bindingIds:['b'],count:1}}),one:async(sql,args)=>({same:JSON.stringify(JSON.parse(args[0]))===JSON.stringify(JSON.parse(args[1]))})};assert.deepEqual(plain(await c.saveEnrollment({current:{id:'a'}},{reason:'正式开课',idempotencyKey:'test-0001'},{studentId:20,categoryKey:'word',courseIds:[100]})),{bindingIds:['b'],count:1});});
  15. test('批量排课跨规则日期分别保存快照',async()=>{const c=setup();c.Psql={oneOrNone:async()=>({nodeName:'词表'})};c.enrollmentBindings=async()=>[{bindingId:'b',courseId:100,categoryKey:'trial'}];c.allAppointmentRows=async()=>[];c.availabilityRows=async()=>[];c.userByLegacyId=async()=>({objectId:'student',legacyUserData:{UserExp:10,UserPoint:10}});const result=await c.preflightAppointments({}, {},{studentId:20,courseId:100,bindingId:'b',durationMinutes:60,deliveryMode:'online',date:'2026-09-08',startTime:'10:00',recurrence:'daily',occurrences:2});assert.deepEqual(plain(result.candidates.map(r=>r.teachingRule.teacherPay)),[40,30]);assert.equal(result.creditAvailability.find(r=>r.account==='UserExp').required,2);});
  16. test('新后台列表按学员精确查询,筛选发生在全站记录上限之前',async()=>{const c=setup();const calls=[];c.Psql={query:async(sql,args)=>{calls.push([sql,args]);return[];}};for(const kind of ['course-bindings','appointments','lessons','learning-records'])await c.businessRows(kind,{}, {studentId:20});await c.moneyRows({}, {studentId:20});for(const [sql,args] of calls){assert.equal(args[1],'20');assert.ok(sql.indexOf('$2')<sql.indexOf('LIMIT 5000'));}});
  17. test('旧同步不能覆盖账号、开课和预约,跨分类进度查询不得随机选择第一条',async()=>{const c=setup();await assert.rejects(c.applyUserSync({}, {},{}),/禁止旧数据覆盖/);for(const kind of ['users','course-bindings','appointments','lessons'])await assert.rejects(c.applyProjectionRecord({},'upsert',{}, {},kind),/禁止旧数据覆盖/);});
  18. test('用户端通用内容接口不能绕过开课、预留及完课事务',async()=>{const c={};vm.createContext(c);vm.runInContext(source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1],c);for(const modelId of [54,58,59])await assert.rejects(c.createContentPair({content:JSON.stringify({modelId}),addon:'{}'},{}),/正式完课流程/);});
  19. test('会员预约详情沿用开课分类并识别回收站取消状态',async()=>{const c=setup();c.Psql={query:async()=>[{targetId:'a',generalId:1,commonStatus:-2,szyh:'20',pl:'30',dslx:'2',dszt:'0',yysj:'2026-09-09 10:00',courseCategoryKey:'word_self_study',courseCategoryName:'单词自学课',bindingId:'b',durationMinutes:60}]};const page=await c.businessRows('appointments',{}, {studentId:20,page:1,pageSize:20});assert.equal(page.items[0].bindingId,'b');assert.equal(page.items[0].classTypeLabel,'单词自学课·60分钟');assert.equal(page.items[0].statusLabel,'已取消');});
  20. test('会员上课记录使用教师姓名和开课分类,不将编号当姓名',async()=>{const c=setup();c.Psql={query:async()=>[{objectId:'l',xymz:'20',jsmz:'30',coachRealName:'测试老师',kclx:'2',courseCategoryName:'单词自学课',bindingId:'b'}]};const page=await c.businessRows('lessons',{}, {studentId:20});assert.equal(page.items[0].coachName,'测试老师');assert.equal(page.items[0].classTypeLabel,'单词自学课');});
  21. test('会员概览未结束数、体验记录与下次预约排除已取消,保留总历史数',async()=>{
  22. const {PGlite}=await import('@electric-sql/pglite');const db=new PGlite();
  23. try{
  24. await db.exec(`CREATE TABLE "CourseAppointment"("company" text,"id" int,"szyh" text,"dszt" text,"dslx" text,"yysj" text,"bxrq" text);
  25. CREATE TABLE "CommonModel"("company" text,"modelId" int,"itemId" int,"status" int);
  26. INSERT INTO "CourseAppointment" VALUES ('co',1,'20','0','3','2099-01-01',null),('co',2,'20','0','2','2099-02-01',null),('co',3,'20','30','2','2026-01-01',null),('other',4,'20','0','3','2099-01-01',null);
  27. INSERT INTO "CommonModel" VALUES ('co',54,1,-2),('other',54,2,-2);`);
  28. const c=setup();c.Psql={query:async(sql,args)=>(await db.query(sql,args)).rows};const [row]=await c.memberAppointmentStats('co');
  29. assert.equal(row.appointment_count,3);assert.equal(row.unfinished,1);assert.equal(row.trial_count,0);assert.equal(row.next_at,'2099-02-01');assert.equal(row.abnormal,0);
  30. }finally{await db.close();}
  31. });