import {test} from 'node:test'; import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import vm from 'node:vm'; import {PGlite} from '@electric-sql/pglite'; const source=readFileSync(new URL('../cloud/mobile-teaching.js',import.meta.url),'utf8'); const plain=v=>JSON.parse(JSON.stringify(v)); async function fixture(run){ const db=new PGlite(); try{ await db.exec(readFileSync(new URL('./fixtures/teacher-review-schema.sql',import.meta.url),'utf8')); await db.exec(`INSERT INTO "ReviewRewardIncome"(company,"taskId",amount,status) VALUES('co','r2',1,'earned'); INSERT INTO "_User"(company,"legacyUserId","legacyUserData") VALUES('co',20,'{"UserPoint":12.5,"Purse":8,"SilverCoin":7}'::jsonb);`); const calls=[]; const query=async(sql,args)=>{calls.push(sql);assert.match(sql,/^SELECT /,'homepage must not write');return (await db.query(sql,args)).rows;}; const ctx=vm.createContext({Date,Promise,DEFAULT_COMPANY_ID:'co',number:(v,f=0)=>Number.isFinite(Number(v))?Number(v):f, authorizeRequestedUser:async(_c,uid)=>Number(uid),appReviewTarget:async()=>20,appOverviewToday:()=> '2026-10-14',legacyAliases:r=>r, appLiveVersionSnapshot:async()=>({version:'v1'}),appLiveSyncStatus:async()=>({state:'delayed',lastSuccessAt:'2026-10-13T00:00:00Z',lagSeconds:100,pending:2}), syncLegacyReviews:async()=>{throw Error('legacy sync must not run on a database-only home read');}, Psql:{query,one:async(s,a)=>(await query(s,a))[0]}}); vm.runInContext(source,ctx); 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;}; await run({db,ctx,calls,snapshot}); }finally{await db.close();} } test('student home reads synced summaries without old-system requests, fee joins or any balance/receipt mutation',async()=>fixture(async({ctx,calls,snapshot})=>{ const before=await snapshot(); const result=plain(await ctx.appReviewPage({uid:20,view:'home',source:'database',status:'pending',refresh:true},{get:()=>1})); assert.deepEqual(result.summary,{total:50,completed:1,due:2,today:2,todayTotal:2,todayCompleted:1,todayPending:1,overduePending:1,income:0,incomeScope:'all'}); assert.equal(result.view,'home');assert.deepEqual(result.items,[]); 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}]); assert.equal(result.legacyReviewSync.state,'snapshot');assert.equal(result.syncStatus.state,'delayed'); assert.equal(calls.length,1,'one date aggregation replaces list/count/summary/version scans'); assert.ok(!calls.some(s=>s.includes('ReviewRewardIncome'))); assert.deepEqual(await snapshot(),before); })); test('home snapshot retains empty/undated/completed and other-tenant ownership semantics',async()=>fixture(async({db,ctx})=>{ await db.exec(`UPDATE "MemoryPracticeRecord" SET kywrq='',kywsj=NULL WHERE id=3; INSERT INTO "MemoryPracticeRecord" VALUES('foreign','other',500,30,20,100,1,'0','2026-10-14','2026-10-14');`); const result=plain(await ctx.appReviewPage({uid:20,view:'home',source:'database'},{get:()=>1})); assert.equal(result.summary.total,50);assert.equal(result.summary.due,1);assert.equal(result.summary.overduePending,0); const empty=plain(await ctx.appReviewPage({uid:99,view:'home',source:'database'},{get:()=>1})); assert.equal(empty.summary.total,0);assert.equal(empty.summary.income,0);assert.deepEqual(empty.dates,[]); })); test('today teacher list reuses exact summary count while preserving rows and fee result',async()=>fixture(async({ctx,calls})=>{ const result=plain(await ctx.appReviewPage({uid:30,scope:'today',includeMetadata:false,includeIncome:false,pageSize:1},{get:()=>3})); 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'); assert.equal(result.summary.total,50);assert.equal(result.summary.completed,1);assert.equal(result.summary.income,0); assert.equal(calls.length,2,'reuse today from summary rather than issuing another count'); calls.length=0; const filtered=plain(await ctx.appReviewPage({uid:30,scope:'today',status:'completed',includeMetadata:false,includeIncome:false},{get:()=>3})); assert.equal(filtered.page.itemCount,1);assert.equal(filtered.items[0].money,1);assert.equal(filtered.items[0].rewardStatus,'earned'); })); test('database home mode cannot bypass account authorization',async()=>fixture(async({ctx,calls})=>{ ctx.authorizeRequestedUser=async()=>{throw Error('denied');}; await assert.rejects(ctx.appReviewPage({uid:99,view:'home',source:'database'},{get:()=>1}),/denied/); assert.equal(calls.length,0); })); test('teacher home may omit unused version scans without changing tasks, summary or reward values',async()=>fixture(async({ctx})=>{ let versionReads=0;ctx.appLiveVersionSnapshot=async()=>{versionReads++;return {version:'v1'};}; const input={uid:30,scope:'today',includeMetadata:false,includeIncome:false}; const before=plain(await ctx.appReviewPage(input,{get:()=>3}));versionReads=0; const after=plain(await ctx.appReviewPage({...input,includeVersions:false},{get:()=>3})); assert.equal(versionReads,0);delete before.version;delete before.refreshedAt;delete after.refreshedAt; assert.deepEqual(after,before); }));