import {test} from 'node:test'; import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import vm from 'node:vm'; const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8'); const baseline=readFileSync(new URL('./fixtures/mobile-read-performance-baseline.js',import.meta.url),'utf8'); const currentSource=source.slice(source.indexOf('async function appCompanionOverview('),source.indexOf('async function ',source.indexOf('async function appAccountOverview(')+10)); const plain=value=>JSON.parse(JSON.stringify(value)); function harness(code,{coach=true,deny=false,delay=false,failQuery=false}={}){ const calls=[],sideReads=[];const sideRead=(name,value)=>async()=>{sideReads.push(name);return value;};let active=0,peak=0,payCalls=0,authorized=false; const rows=[{generalId:1,commonStatus:99,dszt:'30',teacherPay:0,payStatus:'earned'},{generalId:2,commonStatus:99,dszt:'30',teacherPay:45,payStatus:'earned'},{generalId:3,commonStatus:99,dszt:'30',payStatus:'pending'},{generalId:4,commonStatus:-2,dszt:'30',teacherPay:45,payStatus:'earned'}]; const query=async(sql,args)=>{ assert.equal(authorized,true,'authorization must precede business queries'); calls.push({sql,args:plain(args)});active++;peak=Math.max(peak,active); if(delay)await new Promise(resolve=>setTimeout(resolve,5));active--; if(failQuery&&sql.includes('GROUP BY'))throw Error('read failed'); if(sql.startsWith('SELECT c."generalId"'))return plain(sql.includes(' LIMIT ')?rows.slice(0,2):rows); if(sql.includes('AS date'))return[{date:'2026-11-10',finished:3,planned:1}]; if(sql.includes(' AS name'))return[{id:'2',name:'学生',count:4}]; if(sql.includes('FROM "CourseBinding"'))return[{generalId:5}]; return[]; }; const ctx=vm.createContext({Promise,Date,DEFAULT_COMPANY_ID:'co',number:(v,f=0)=>Number(v)||f, authorizeRequestedUser:async()=>{if(deny)throw Error('denied');authorized=true;return 30;}, assertLessonTenant:()=>{assert.equal(authorized,true);},mobilePage:()=>({index:1,size:2}),mobilePageMeta:(total,p)=>({itemCount:total,pageSize:p.size}),companionStudentNameSql:()=>"'学生'", databaseAppointmentVersions:sideRead('databaseVersion',{version:'db-v'}),appLiveVersionSnapshot:sideRead('liveVersion',{version:'v'}),mobileSource:()=>({source:'database'}),appOverviewToday:()=> '2026-11-10',appOverviewScheduleRow:r=>r,appOverviewCourseRow:r=>r, teacherSchedulePay:async r=>{payCalls++;return r;},legacyData:()=>({GroupID:coach?3:1}),legacyUser:()=>({UserID:30,Purse:8}),legacyUserAddon:()=>({Purse:7.5,UserPoint:2}), teacherIncomeSummary:sideRead('income',{amount:45}),syncLegacyReviews:sideRead('syncReviews',{state:'current'}),syncLegacyAppointments:sideRead('syncAppointments',{state:'current',items:[]}),withLegacyAppointmentVersion:v=>v, Psql:{query,one:async(sql,args)=>{await query(sql,args);return{total:4,completed:3,today:2,t30:1,t60:1,trial:0,minutes:60};}}}); vm.runInContext(code,ctx); return{ctx,current:{get:()=>coach?3:1},calls,sideReads,stats:()=>({active,peak,payCalls})}; } for(const coach of [true,false])for(const history of [true,false])test(`companion preserves exact SQL, arguments, output and earnings (coach=${coach}, history=${history})`,async()=>{ const input={source:'database',history,month:'2026-11',dateStart:'2026-11-01',dateEnd:'2026-11-30',filter:'pending',studentId:20,search:'学生'}; const before=harness(baseline,{coach}),after=harness(currentSource,{coach,delay:true}); assert.deepEqual(plain(await after.ctx.appCompanionOverview(input,after.current)),plain(await before.ctx.appCompanionOverview(input,before.current))); const sortCalls=c=>c.map(v=>JSON.stringify(v)).sort();assert.deepEqual(sortCalls(after.calls),sortCalls(before.calls)); assert.equal(after.stats().payCalls,coach&&history?1:0); assert.ok(after.stats().peak>1,'independent cold reads should overlap');assert.ok(after.stats().peak<=3,'at most three SQL reads in flight');assert.equal(after.stats().active,0); }); test('companion failure waits for its active bounded batch and does not leak errors',async()=>{ const h=harness(currentSource,{delay:true,failQuery:true}); await assert.rejects(h.ctx.appCompanionOverview({source:'database'},h.current),/read failed/); assert.equal(h.stats().active,0);assert.ok(h.stats().peak<=3); }); for(const coach of [true,false])test(`profileOnly keeps fresh profile and skips all statistics (coach=${coach})`,async()=>{ const before=harness(baseline,{coach}),after=harness(currentSource,{coach}); const full=await before.ctx.appAccountOverview({source:'database'},before.current); const profile=await after.ctx.appAccountOverview({profileOnly:true,source:'database'},after.current); assert.deepEqual(plain(profile),{profile:plain(full.profile)});assert.equal(after.calls.length,0);assert.deepEqual(after.sideReads,[]); const defaultRead=harness(currentSource,{coach});assert.deepEqual(plain(await defaultRead.ctx.appAccountOverview({source:'database'},defaultRead.current)),plain(full)); }); test('profileOnly and companion cannot bypass requested-user authorization',async()=>{ const h=harness(currentSource,{deny:true}); await assert.rejects(h.ctx.appAccountOverview({profileOnly:true,uid:123},h.current),/denied/); await assert.rejects(h.ctx.appCompanionOverview({source:'database',uid:123},h.current),/denied/);assert.equal(h.calls.length,0); });