| 1234567891011121314151617181920212223242526272829303132333435363738 |
- 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';
- const file=new URL('../cloud/lesson-export.js',import.meta.url);
- test('export jobs are idempotent, owner scoped, globally leased and cancellation fences workers',async()=>{
- assert.ok(existsSync(file),'durable export implementation exists');const db=new PGlite();
- try{
- await db.exec(readFileSync(new URL('../sql/lesson-export.sql',import.meta.url),'utf8'));
- 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);}});
- const Psql={...adapter(db),tx:work=>db.transaction(tx=>work(adapter(tx)))};
- const context={current:{id:'owner'},company:'co',roles:['ops-manager'],isSuperAdmin:true};
- 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);
- const input={idempotencyKey:'export-key-12345678',search:'林'};
- const first=await ctx.lessonExportCreate(context,input),again=await ctx.lessonExportCreate(context,input);assert.equal(first.jobId,again.jobId);
- await assert.rejects(ctx.lessonExportStatus({...context,current:{id:'other'}},{jobId:first.jobId}),/未找到/);
- const claim=await ctx.lessonExportClaim(context);assert.equal(claim.jobId,first.jobId);assert.ok(claim.lease);assert.equal((await ctx.lessonExportClaim(context)).jobId,undefined);
- await ctx.lessonExportCancel(context,{jobId:first.jobId});await assert.rejects(ctx.lessonExportChunk(context,{jobId:first.jobId,lease:claim.lease,offset:0}),/租约/);
- assert.equal((await ctx.lessonExportStatus(context,{jobId:first.jobId})).status,'cancelled');assert.ok(!/SELECT \*/.test(statements.at(-1)),'polling must not load file_base64');
- const resumed=await ctx.lessonExportCreate(context,{idempotencyKey:'resume-key-12345678'}),lease1=await ctx.lessonExportClaim(context);
- 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]);
- await ctx.lessonExportRelease(context,{jobId:resumed.jobId,lease:lease1.lease});await assert.rejects(ctx.lessonExportChunk(context,{jobId:resumed.jobId,lease:lease1.lease}),/租约/);
- const lease2=await ctx.lessonExportClaim(context);assert.notEqual(lease2.lease,lease1.lease);assert.equal(lease2.snapshotReady,true);
- assert.equal((await ctx.lessonExportChunk(context,{jobId:resumed.jobId,lease:lease2.lease})).items[0].lessonId,'fixed');
- await assert.rejects(ctx.lessonExportFinish(context,{jobId:resumed.jobId,lease:lease1.lease,rowCount:1,base64:'eA=='}),/租约/);
- 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');
- }finally{await db.close();}
- });
- test('worker releases transient failures and retains fixed snapshots for retry',async()=>{
- const {processLessonExport}=await import('../lesson-export-worker.mjs');const calls=[];
- 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('export_gateway_504'),{transient:true});return{};}),e=>e.message==='export_job_failed'&&e.transient===true&&e.reason==='export_gateway_504');
- assert.deepEqual(calls.map(c=>c.action),['claim','chunk','release']);assert.deepEqual(calls.at(-1).params,{jobId:'job',lease:'owner-lease'});
- });
- test('worker recognizes PM2 and direct CLI entrypoints but stays idle when imported',async()=>{
- const module=await import('../lesson-export-worker.mjs');assert.equal(typeof module.isLessonExportEntrypoint,'function');
- const url='file:///opt/exports/worker.mjs';assert.equal(module.isLessonExportEntrypoint(url,['node','/pm2/ProcessContainerFork.js'],{pm_exec_path:'/opt/exports/worker.mjs'}),true);
- assert.equal(module.isLessonExportEntrypoint(url,['node','/opt/exports/worker.mjs'],{}),true);
- assert.equal(module.isLessonExportEntrypoint(url,['node','/opt/exports/worker.mjs'],{pm_exec_path:'/opt/server/main.js'}),true);
- assert.equal(module.isLessonExportEntrypoint(url,['node','/opt/tests/test.mjs'],{}),false);
- });
|