mobile-read-performance.test.mjs 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657
  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 baseline=readFileSync(new URL('./fixtures/mobile-read-performance-baseline.js',import.meta.url),'utf8');
  7. const currentSource=source.slice(source.indexOf('async function appCompanionOverview('),source.indexOf('async function ',source.indexOf('async function appAccountOverview(')+10));
  8. const plain=value=>JSON.parse(JSON.stringify(value));
  9. function harness(code,{coach=true,deny=false,delay=false,failQuery=false}={}){
  10. const calls=[],sideReads=[];const sideRead=(name,value)=>async()=>{sideReads.push(name);return value;};let active=0,peak=0,payCalls=0,authorized=false;
  11. 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'}];
  12. const query=async(sql,args)=>{
  13. assert.equal(authorized,true,'authorization must precede business queries');
  14. calls.push({sql,args:plain(args)});active++;peak=Math.max(peak,active);
  15. if(delay)await new Promise(resolve=>setTimeout(resolve,5));active--;
  16. if(failQuery&&sql.includes('GROUP BY'))throw Error('read failed');
  17. if(sql.startsWith('SELECT c."generalId"'))return plain(sql.includes(' LIMIT ')?rows.slice(0,2):rows);
  18. if(sql.includes('AS date'))return[{date:'2026-11-10',finished:3,planned:1}];
  19. if(sql.includes(' AS name'))return[{id:'2',name:'学生',count:4}];
  20. if(sql.includes('FROM "CourseBinding"'))return[{generalId:5}];
  21. return[];
  22. };
  23. const ctx=vm.createContext({Promise,Date,DEFAULT_COMPANY_ID:'co',number:(v,f=0)=>Number(v)||f,
  24. authorizeRequestedUser:async()=>{if(deny)throw Error('denied');authorized=true;return 30;},
  25. assertLessonTenant:()=>{assert.equal(authorized,true);},mobilePage:()=>({index:1,size:2}),mobilePageMeta:(total,p)=>({itemCount:total,pageSize:p.size}),companionStudentNameSql:()=>"'学生'",
  26. databaseAppointmentVersions:sideRead('databaseVersion',{version:'db-v'}),appLiveVersionSnapshot:sideRead('liveVersion',{version:'v'}),mobileSource:()=>({source:'database'}),appOverviewToday:()=> '2026-11-10',appOverviewScheduleRow:r=>r,appOverviewCourseRow:r=>r,
  27. teacherSchedulePay:async r=>{payCalls++;return r;},legacyData:()=>({GroupID:coach?3:1}),legacyUser:()=>({UserID:30,Purse:8}),legacyUserAddon:()=>({Purse:7.5,UserPoint:2}),
  28. teacherIncomeSummary:sideRead('income',{amount:45}),syncLegacyReviews:sideRead('syncReviews',{state:'current'}),syncLegacyAppointments:sideRead('syncAppointments',{state:'current',items:[]}),withLegacyAppointmentVersion:v=>v,
  29. 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};}}});
  30. vm.runInContext(code,ctx);
  31. return{ctx,current:{get:()=>coach?3:1},calls,sideReads,stats:()=>({active,peak,payCalls})};
  32. }
  33. 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()=>{
  34. const input={source:'database',history,month:'2026-11',dateStart:'2026-11-01',dateEnd:'2026-11-30',filter:'pending',studentId:20,search:'学生'};
  35. const before=harness(baseline,{coach}),after=harness(currentSource,{coach,delay:true});
  36. assert.deepEqual(plain(await after.ctx.appCompanionOverview(input,after.current)),plain(await before.ctx.appCompanionOverview(input,before.current)));
  37. const sortCalls=c=>c.map(v=>JSON.stringify(v)).sort();assert.deepEqual(sortCalls(after.calls),sortCalls(before.calls));
  38. assert.equal(after.stats().payCalls,coach&&history?1:0);
  39. 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);
  40. });
  41. test('companion failure waits for its active bounded batch and does not leak errors',async()=>{
  42. const h=harness(currentSource,{delay:true,failQuery:true});
  43. await assert.rejects(h.ctx.appCompanionOverview({source:'database'},h.current),/read failed/);
  44. assert.equal(h.stats().active,0);assert.ok(h.stats().peak<=3);
  45. });
  46. for(const coach of [true,false])test(`profileOnly keeps fresh profile and skips all statistics (coach=${coach})`,async()=>{
  47. const before=harness(baseline,{coach}),after=harness(currentSource,{coach});
  48. const full=await before.ctx.appAccountOverview({source:'database'},before.current);
  49. const profile=await after.ctx.appAccountOverview({profileOnly:true,source:'database'},after.current);
  50. assert.deepEqual(plain(profile),{profile:plain(full.profile)});assert.equal(after.calls.length,0);assert.deepEqual(after.sideReads,[]);
  51. const defaultRead=harness(currentSource,{coach});assert.deepEqual(plain(await defaultRead.ctx.appAccountOverview({source:'database'},defaultRead.current)),plain(full));
  52. });
  53. test('profileOnly and companion cannot bypass requested-user authorization',async()=>{
  54. const h=harness(currentSource,{deny:true});
  55. await assert.rejects(h.ctx.appAccountOverview({profileOnly:true,uid:123},h.current),/denied/);
  56. await assert.rejects(h.ctx.appCompanionOverview({source:'database',uid:123},h.current),/denied/);assert.equal(h.calls.length,0);
  57. });