|
@@ -0,0 +1,76 @@
|
|
|
|
|
+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),/仅管理/);
|
|
|
|
|
+});
|