lesson-export.test.mjs 3.9 KB

12345678910111213141516171819202122232425262728293031
  1. import {test} from 'node:test';import assert from 'node:assert/strict';import {PGlite} from '@electric-sql/pglite';import {readFileSync,existsSync} from 'node:fs';import vm from 'node:vm';
  2. const file=new URL('../cloud/lesson-export.js',import.meta.url);
  3. test('export jobs are idempotent, owner scoped, globally leased and cancellation fences workers',async()=>{
  4. assert.ok(existsSync(file),'durable export implementation exists');const db=new PGlite();
  5. try{
  6. await db.exec(readFileSync(new URL('../sql/lesson-export.sql',import.meta.url),'utf8'));
  7. const statements=[];const adapter=tx=>({query:async(s,p=[])=>{statements.push(s);return(await tx.query(s,p)).rows;},one:async(s,p=[])=>(await tx.query(s,p)).rows[0],none:async(s,p=[])=>{await tx.query(s,p);}});
  8. const Psql={...adapter(db),tx:work=>db.transaction(tx=>work(adapter(tx)))};
  9. const context={current:{id:'owner'},company:'co',roles:['ops-manager'],isSuperAdmin:true};
  10. const ctx=vm.createContext({Psql,text:v=>String(v??''),number:v=>Number(v)||0,companyIdOf:c=>c.company,roleOf:c=>c.roles[0],requireRole:()=>{},requireSuperAdmin:c=>{if(!c.isSuperAdmin)throw Error('forbidden');},fail:(_,m)=>{throw Error(m);},Date,JSON});vm.runInContext(readFileSync(file,'utf8'),ctx);
  11. const input={idempotencyKey:'export-key-12345678',search:'林'};
  12. const first=await ctx.lessonExportCreate(context,input),again=await ctx.lessonExportCreate(context,input);assert.equal(first.jobId,again.jobId);
  13. await assert.rejects(ctx.lessonExportStatus({...context,current:{id:'other'}},{jobId:first.jobId}),/未找到/);
  14. const claim=await ctx.lessonExportClaim(context);assert.equal(claim.jobId,first.jobId);assert.ok(claim.lease);assert.equal((await ctx.lessonExportClaim(context)).jobId,undefined);
  15. await ctx.lessonExportCancel(context,{jobId:first.jobId});await assert.rejects(ctx.lessonExportChunk(context,{jobId:first.jobId,lease:claim.lease,offset:0}),/租约/);
  16. assert.equal((await ctx.lessonExportStatus(context,{jobId:first.jobId})).status,'cancelled');assert.ok(!/SELECT \*/.test(statements.at(-1)),'polling must not load file_base64');
  17. const resumed=await ctx.lessonExportCreate(context,{idempotencyKey:'resume-key-12345678'}),lease1=await ctx.lessonExportClaim(context);
  18. await db.query("UPDATE xs_history_export_job SET snapshot_ready=true,total=1 WHERE id=$1",[resumed.jobId]);await db.query("INSERT INTO xs_history_export_row(job_id,position,value) VALUES($1,1,'{\"lessonId\":\"fixed\"}')",[resumed.jobId]);
  19. await ctx.lessonExportRelease(context,{jobId:resumed.jobId,lease:lease1.lease});await assert.rejects(ctx.lessonExportChunk(context,{jobId:resumed.jobId,lease:lease1.lease}),/租约/);
  20. const lease2=await ctx.lessonExportClaim(context);assert.notEqual(lease2.lease,lease1.lease);assert.equal(lease2.snapshotReady,true);
  21. assert.equal((await ctx.lessonExportChunk(context,{jobId:resumed.jobId,lease:lease2.lease})).items[0].lessonId,'fixed');
  22. await assert.rejects(ctx.lessonExportFinish(context,{jobId:resumed.jobId,lease:lease1.lease,rowCount:1,base64:'eA=='}),/租约/);
  23. await db.query("UPDATE xs_history_export_job SET status='ready',file_base64='eA==' WHERE id=$1",[resumed.jobId]);assert.equal((await ctx.lessonExportDownload(context,{jobId:resumed.jobId})).base64,'eA==');assert.ok(statements.at(-1).includes('file_base64'),'download explicitly fetches the file');
  24. }finally{await db.close();}
  25. });
  26. test('worker releases transient failures and retains fixed snapshots for retry',async()=>{
  27. const {processLessonExport}=await import('../lesson-export-worker.mjs');const calls=[];
  28. await assert.rejects(processLessonExport(async(action,params)=>{calls.push({action,params});if(action==='claim')return{jobId:'job',lease:'owner-lease',snapshotReady:true};if(action==='chunk')throw Object.assign(Error('503'),{transient:true});return{};}),e=>e.message==='export_job_failed'&&e.transient===true);
  29. assert.deepEqual(calls.map(c=>c.action),['claim','chunk','release']);assert.deepEqual(calls.at(-1).params,{jobId:'job',lease:'owner-lease'});
  30. });