import {test,before,after} from 'node:test'; import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import {PGlite} from '@electric-sql/pglite'; import vm from 'node:vm'; const db=new PGlite(),query=async(s,p=[])=>(await db.query(s,p)).rows,one=async(s,p=[])=>(await query(s,p))[0]; const ctx={Psql:{query,one,oneOrNone:one},text:v=>String(v??'').trim(),number:(v,d=0)=>Number.isFinite(Number(v))?Number(v):d,dateIso:v=>v?new Date(v).toISOString():'',classType:v=>({'2':'60 分钟'})[v]||v,companyIdOf:c=>c.company,roleOf:c=>c.role,requireRole:(c,r)=>{if(!r.includes(c.role))throw Error('forbidden');},fail:(_,m)=>{throw Error(m);},pageInput:i=>({page:Math.max(1,Number(i.page)||1),pageSize:Math.min(100,Math.max(1,Number(i.pageSize)||20))})}; vm.createContext(ctx);vm.runInContext(readFileSync(new URL('../cloud/lesson-records.js',import.meta.url),'utf8'),ctx); const manager={company:'c',current:{id:'manager'},role:'ops-manager'}; const input={studentId:20,appointmentId:'1000',scope:'lesson',coachId:11}; const preview=i=>ctx.lessonTransferPreview(manager,{...input,...i}); const commit=async(p,extra={})=>ctx.lessonTransferCommit(manager,{...input,version:p.version,reason:'离岗交接',idempotencyKey:'transfer-test-0001',...extra}); async function task(id,day,extra={}){await query(`INSERT INTO "MemoryPracticeRecord" ("objectId",company,id,plid,yhid,xxjlid,"orderId",kywrq,fxzt,wcsj,"updatedAt") VALUES($1,'c',$2,$3,$4,$5,1000,$6,$7,'',now())`,[id,Number(id.replace(/\D/g,''))||10,extra.coach??10,extra.student??20,extra.study??100,day,extra.status??'0']);} before(async()=>{ await db.exec(readFileSync(new URL('./fixtures/lesson-records-schema.sql',import.meta.url),'utf8')); await db.exec(readFileSync(new URL('../sql/review-rewards.sql',import.meta.url),'utf8')); await db.exec(readFileSync(new URL('../sql/lesson-records.sql',import.meta.url),'utf8')); await db.exec(`INSERT INTO "_User"("objectId",company,"legacyUserId","legacyGroupId","identityType","adminRoleKey","realName","legacyUserData") VALUES ('manager','c',1,1,'member','ops-manager','管理员','{}'),('staff','c',2,1,'member','ops-staff','员工','{}'),('old','c',10,3,'coach',null,'原老师','{}'),('new','c',11,3,'coach',null,'新老师','{}'),('student','c',20,1,'member',null,'林可欣','{"ParentUserID":40}'),('store','c',40,2,'store',null,'南昌总店','{}'); INSERT INTO "DailyStudyRecord"("objectId",company,id,"userId",dsid) VALUES('study','c',100,20,'1000'); INSERT INTO "CommonModel"("objectId",company,"generalId","itemId","modelId") VALUES('studyCommon','c',100,100,56); INSERT INTO "Node" VALUES('c',30,'单词课');`); for(let i=1;i<=55;i++){await query(`INSERT INTO "LessonRecord"("objectId",company,id,xymz,jsmz,kcid,kcmc,yyds,kclx,pldp,pjnr,plpjsj,"updatedAt") VALUES($1,'c',$2,'20','10','30','七年级单词',$3,'2','老师点评','学生评价',$4,now())`,['lesson'+i,i,String(1000+i-1),'2026-10-'+String(Math.ceil(i/3)).padStart(2,'0')+' 10:00:00']);} await query(`INSERT INTO "LessonRecord"("objectId",company,id,xymz,jsmz,yyds,plpjsj) VALUES('duplicate','c',56,'20','10','1000','2026-10-01 10:00:00')`); await query(`INSERT INTO "LessonCompletionReport"("objectId",company,"lessonId","appointmentGeneralId","contentSummary","coachComment") VALUES('report','c','lesson2',1001,'学习17词','新式完整点评')`); await task('pending1','2026-01-01');await task('pending2','2099-01-01');await task('done3','2026-01-03',{status:'1'}); }); after(()=>db.close()); test('历史查询按实际日期倒序、分页计数去重;姓名ID门店课程日期组合筛选',async()=>{ const p=await ctx.lessonHistoryPage(manager,{});assert.equal(p.total,55);assert.equal(p.cumulativeTotal,55);assert.equal(p.items.length,20);assert.equal(p.items[0].lessonId,'lesson55'); const next=await ctx.lessonHistoryPage(manager,{page:3});assert.equal(next.items.length,15);assert.equal(next.items.at(-1).duplicateCount,2); const filtered=await ctx.lessonHistoryPage(manager,{studentId:20,studentName:'林',coachName:'原',storeName:'南昌',courseName:'七年级',dateFrom:'2026-10-02',dateTo:'2026-10-02'});assert.equal(filtered.total,3);assert.equal(filtered.cumulativeTotal,55); assert.equal((await ctx.lessonHistoryPage({...manager,company:'other'},{})).total,0); assert.equal((await ctx.lessonHistoryPage(manager,{search:"%' OR 1=1--"})).total,0); }); test('按姓名搜索时累计课次只统计匹配学员,日期只影响当前筛选',async()=>{ await query(`INSERT INTO "LessonRecord"("objectId",company,id,xymz,jsmz,yyds,plpjsj) VALUES('otherStudent','c',999,'21','10','other','2026-10-01 10:00:00')`); const p=await ctx.lessonHistoryPage(manager,{search:'林可欣',dateFrom:'2026-10-02',dateTo:'2026-10-02'});assert.equal(p.total,3);assert.equal(p.cumulativeTotal,55); await query(`DELETE FROM "LessonRecord" WHERE "objectId"='otherStudent'`); }); test('反馈详情保留双方原始文本,读取新报告,缺失课次返回失败',async()=>{ const old=await ctx.lessonHistoryDetail(manager,{lessonId:'lesson3'});assert.equal(old.coachComment,'老师点评');assert.equal(old.studentReview.comment,'学生评价');assert.equal(old.hasReport,false); const modern=await ctx.lessonHistoryDetail(manager,{appointmentId:'1001'});assert.equal(modern.report.contentSummary,'学习17词');assert.equal(modern.coachComment,'新式完整点评');assert.equal(modern.canTransfer,true); await assert.rejects(ctx.lessonHistoryDetail(manager,{lessonId:'missing'}),/未找到/); }); test('预览包含逾期与未来,已完成及工资排除;转交幂等、逐任务审计',async()=>{ const p=await preview();assert.equal(p.count,2);assert.equal(p.lessonCount,1);assert.equal(p.dateFrom,'2026-01-01'); const rewardBefore=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'done3\''); const result=await commit(p);assert.equal(result.count,2);assert.deepEqual(await commit(p),result); assert.equal(Number((await one('SELECT plid FROM "MemoryPracticeRecord" WHERE "objectId"=\'pending1\'')).plid),11); assert.deepEqual(await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'done3\''),rewardBefore); assert.equal((await one('SELECT count(*)::int n FROM "ReviewTransferAudit"')).n,2); await assert.rejects(commit(p,{reason:'更改原因'}),/请求编号/); }); test('原老师旧页面不能提交,新老师完成按新归属计薪,重复完成无重复工资',async()=>{ await assert.rejects(query('SELECT xs_complete_assigned_review($1,$2,$3)',['c','pending1','old']),/复习已转交/); await query('SELECT xs_complete_assigned_review($1,$2,$3)',['c','pending1','new']);await query('SELECT xs_complete_assigned_review($1,$2,$3)',['c','pending1','new']); const r=await one('SELECT * FROM "ReviewReward" WHERE "taskId"=\'pending1\'');assert.equal(r.coachId,11);assert.equal(Number(r.amount),1); }); test('旧同步不能回退手工归属或代新老师完成;冲突留痕',async()=>{ await query(`UPDATE "MemoryPracticeRecord" SET plid=10,fxzt='1',wcsj='2026-10-01' WHERE "objectId"='pending2'`); const t=await one('SELECT * FROM "MemoryPracticeRecord" WHERE "objectId"=\'pending2\'');assert.equal(Number(t.plid),11);assert.equal(t.fxzt,'0');assert.equal((await one('SELECT count(*)::int n FROM "ReviewAssignmentConflict"')).n,1); }); test('完成抢先或版本改变后旧预览失败;权限和停用陪练拒绝',async()=>{ const p=await preview({coachId:10});await query('SELECT xs_complete_assigned_review($1,$2,$3)',['c','pending2','new']); await assert.rejects(commit(p,{coachId:10,idempotencyKey:'stale-preview-002'}),/已变化/); await assert.rejects(ctx.lessonTransferPreview({...manager,role:'ops-staff'},input),/forbidden/); await assert.rejects(query('SELECT xs_review_transfer($1,$2,$3,$4,$5,$6,$7,$8)',['c','staff','20','1000','11','x','测试','unauth-000']),/仅管理/); await query(`UPDATE "_User" SET "isDisabled"=true WHERE "objectId"='new'`);await assert.rejects(preview(),/停用/);await query(`UPDATE "_User" SET "isDisabled"=false WHERE "objectId"='new'`); }); test('全部学员范围只覆盖已有任务,不更改未来课程默认,跨帐套不可转交',async()=>{ await task('pending4','2099-02-01');await task('pending5','2099-02-02',{student:21}); const p=await preview({scope:'student'});assert.equal(p.count,1);await commit(p,{scope:'student',idempotencyKey:'student-scope-003'}); assert.equal(Number((await one('SELECT plid FROM "MemoryPracticeRecord" WHERE "objectId"=\'pending5\'')).plid),10); await assert.rejects(ctx.lessonTransferPreview({...manager,company:'other'},input),/仅管理/); });