home-review-read.test.mjs 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
  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. import {PGlite} from '@electric-sql/pglite';
  6. const source=readFileSync(new URL('../cloud/mobile-teaching.js',import.meta.url),'utf8');
  7. const plain=v=>JSON.parse(JSON.stringify(v));
  8. async function fixture(run){
  9. const db=new PGlite();
  10. try{
  11. await db.exec(readFileSync(new URL('./fixtures/teacher-review-schema.sql',import.meta.url),'utf8'));
  12. await db.exec(`INSERT INTO "ReviewRewardIncome"(company,"taskId",amount,status) VALUES('co','r2',1,'earned');
  13. INSERT INTO "_User"(company,"legacyUserId","legacyUserData") VALUES('co',20,'{"UserPoint":12.5,"Purse":8,"SilverCoin":7}'::jsonb);`);
  14. const calls=[];
  15. const query=async(sql,args)=>{calls.push(sql);assert.match(sql,/^SELECT /,'homepage must not write');return (await db.query(sql,args)).rows;};
  16. const ctx=vm.createContext({Date,Promise,DEFAULT_COMPANY_ID:'co',number:(v,f=0)=>Number.isFinite(Number(v))?Number(v):f,
  17. authorizeRequestedUser:async(_c,uid)=>Number(uid),appReviewTarget:async()=>20,appOverviewToday:()=> '2026-10-14',legacyAliases:r=>r,
  18. appLiveVersionSnapshot:async()=>({version:'v1'}),appLiveSyncStatus:async()=>({state:'delayed',lastSuccessAt:'2026-10-13T00:00:00Z',lagSeconds:100,pending:2}),
  19. syncLegacyReviews:async()=>{throw Error('legacy sync must not run on a database-only home read');},
  20. Psql:{query,one:async(s,a)=>(await query(s,a))[0]}});
  21. vm.runInContext(source,ctx);
  22. const snapshot=async()=>{const result={};for(const t of ['MemoryPracticeRecord','CommonModel','ReviewRewardIncome','_User','LessonRecord','CourseAppointment','DailyStudyRecord'])result[t]=(await db.query(`SELECT * FROM "${t}"`)).rows;return result;};
  23. await run({db,ctx,calls,snapshot});
  24. }finally{await db.close();}
  25. }
  26. test('student home reads synced summaries without old-system requests, fee joins or any balance/receipt mutation',async()=>fixture(async({ctx,calls,snapshot})=>{
  27. const before=await snapshot();
  28. const result=plain(await ctx.appReviewPage({uid:20,view:'home',source:'database',status:'pending',refresh:true},{get:()=>1}));
  29. assert.deepEqual(result.summary,{total:50,completed:1,due:2,today:2,todayTotal:2,todayCompleted:1,todayPending:1,overduePending:1,income:0,incomeScope:'all'});
  30. assert.equal(result.view,'home');assert.deepEqual(result.items,[]);
  31. assert.deepEqual(result.dates.sort((a,b)=>a.date.localeCompare(b.date)),[{date:'2026-10-13',total:1,completed:0},{date:'2026-10-14',total:2,completed:1},{date:'2026-10-20',total:47,completed:0}]);
  32. assert.equal(result.legacyReviewSync.state,'snapshot');assert.equal(result.syncStatus.state,'delayed');
  33. assert.equal(calls.length,1,'one date aggregation replaces list/count/summary/version scans');
  34. assert.ok(!calls.some(s=>s.includes('ReviewRewardIncome')));
  35. assert.deepEqual(await snapshot(),before);
  36. }));
  37. test('home snapshot retains empty/undated/completed and other-tenant ownership semantics',async()=>fixture(async({db,ctx})=>{
  38. await db.exec(`UPDATE "MemoryPracticeRecord" SET kywrq='',kywsj=NULL WHERE id=3;
  39. INSERT INTO "MemoryPracticeRecord" VALUES('foreign','other',500,30,20,100,1,'0','2026-10-14','2026-10-14');`);
  40. const result=plain(await ctx.appReviewPage({uid:20,view:'home',source:'database'},{get:()=>1}));
  41. assert.equal(result.summary.total,50);assert.equal(result.summary.due,1);assert.equal(result.summary.overduePending,0);
  42. const empty=plain(await ctx.appReviewPage({uid:99,view:'home',source:'database'},{get:()=>1}));
  43. assert.equal(empty.summary.total,0);assert.equal(empty.summary.income,0);assert.deepEqual(empty.dates,[]);
  44. }));
  45. test('today teacher list reuses exact summary count while preserving rows and fee result',async()=>fixture(async({ctx,calls})=>{
  46. const result=plain(await ctx.appReviewPage({uid:30,scope:'today',includeMetadata:false,includeIncome:false,pageSize:1},{get:()=>3}));
  47. assert.equal(result.page.itemCount,2);assert.equal(result.page.pageCount,2);assert.equal(result.items.length,1);assert.equal(result.items[0].fxzt,'0');
  48. assert.equal(result.summary.total,50);assert.equal(result.summary.completed,1);assert.equal(result.summary.income,0);
  49. assert.equal(calls.length,2,'reuse today from summary rather than issuing another count');
  50. calls.length=0;
  51. const filtered=plain(await ctx.appReviewPage({uid:30,scope:'today',status:'completed',includeMetadata:false,includeIncome:false},{get:()=>3}));
  52. assert.equal(filtered.page.itemCount,1);assert.equal(filtered.items[0].money,1);assert.equal(filtered.items[0].rewardStatus,'earned');
  53. }));
  54. test('database home mode cannot bypass account authorization',async()=>fixture(async({ctx,calls})=>{
  55. ctx.authorizeRequestedUser=async()=>{throw Error('denied');};
  56. await assert.rejects(ctx.appReviewPage({uid:99,view:'home',source:'database'},{get:()=>1}),/denied/);
  57. assert.equal(calls.length,0);
  58. }));
  59. test('teacher home may omit unused version scans without changing tasks, summary or reward values',async()=>fixture(async({ctx})=>{
  60. let versionReads=0;ctx.appLiveVersionSnapshot=async()=>{versionReads++;return {version:'v1'};};
  61. const input={uid:30,scope:'today',includeMetadata:false,includeIncome:false};
  62. const before=plain(await ctx.appReviewPage(input,{get:()=>3}));versionReads=0;
  63. const after=plain(await ctx.appReviewPage({...input,includeVersions:false},{get:()=>3}));
  64. assert.equal(versionReads,0);delete before.version;delete before.refreshedAt;delete after.refreshedAt;
  65. assert.deepEqual(after,before);
  66. }));