import {test,before,after} from 'node:test'; import assert from 'node:assert/strict'; import vm from 'node:vm'; import {readFileSync} from 'node:fs'; import {webcrypto} from 'node:crypto'; import {PGlite} from '@electric-sql/pglite'; const code=readFileSync(new URL('../cloud/word-audio.js',import.meta.url),'utf8'); const migration=readFileSync(new URL('../sql/word-audio.sql',import.meta.url),'utf8'); const db=new PGlite(); const one=async(sql,args)=>(await db.query(sql,args)).rows[0]; let serial=0; const user={id:'student',get:key=>({company:{id:'co'},legacyGroupId:1}[key])}; const config={enabled:true,appId:'test',apiKey:'test',apiSecret:'test',voice:'english-test',speed:50,dayLimit:500,minuteLimit:30,concurrency:1,storage:{endpoint:'https://s3.test',bucket:'test',region:'test',accessKey:'test',secretKey:'test',publicBase:'https://cdn.test'}}; function context(){return vm.createContext({WebSocket:class {},crypto:webcrypto,TextEncoder,URL,URLSearchParams,Uint8Array,AbortSignal,setTimeout,clearTimeout,btoa,atob,DEFAULT_COMPANY_ID:'co',isAdminUser:()=>false,randomObjectId:()=>`attempt-${++serial}`,fail:(status,message)=>{throw Object.assign(new Error(message),{status});},fetch:async()=>{throw Error('Unexpected network call');},Psql:{one,oneOrNone:one,query:async(sql,args)=>(await db.query(sql,args)).rows,none:async(sql,args)=>db.query(sql,args)}});} function setup(){ const c=context();vm.runInContext(code,c);c.wordAudioConfig=async()=>structuredClone(config); let calls=0,uploads=0;c.wordAudioSynthesize=async()=>{calls++;return 'SUQzYWJj';};c.wordAudioUpload=async id=>{uploads++;return 'https://cdn.test/'+id+'.mp3';}; return {c,calls:()=>calls,uploads:()=>uploads,ensure:(wordId='101',input={},actor=user)=>c.appWordAudioEnsure({wordId,...input},actor)}; } before(async()=>{ await db.exec(`CREATE TABLE "CommonModel" (company text,"modelId" int,"generalId" int,"itemId" int,title text,status int,"isDeleted" boolean); CREATE TABLE "VocabularyWord" ("objectId" text,company text,id int,yb text,yp text,"isDeleted" boolean,"updatedAt" timestamptz);`); await db.exec(migration); }); after(()=>db.close()); async function reset(){ await db.exec(`TRUNCATE "WordAudioCache","WordAudioAttempt","CommonModel","VocabularyWord"; INSERT INTO "CommonModel" VALUES ('co',52,101,1,'apple',99,false),('co',52,102,2,'apple',99,false),('co',52,103,3,'Apple',99,false),('co',52,104,4,'apple',99,false),('co',52,105,5,'draft',0,false),('other',52,106,6,'foreign',99,false); INSERT INTO "VocabularyWord" VALUES ('v1','co',1,'a','',false,now()),('v2','co',2,'a','',false,now()),('v3','co',3,'a','',false,now()),('v4','co',4,'different','',false,now()),('v5','co',5,'','',false,now()),('v6','other',6,'','',false,now());`); } test('已有读音不加载讯飞配置也不生成;空缺首次保存后跨词条/用户复用',async()=>{ await reset();const {c,ensure,calls,uploads}=setup(); const first=await ensure();assert.equal(first.state,'ready');assert.equal(calls(),1);assert.equal(uploads(),1); assert.equal((await ensure('102',{}, {id:'teacher',get:key=>({company:{id:'co'},legacyGroupId:3}[key])})).url,first.url);assert.equal(calls(),1); c.wordAudioConfig=async()=>{throw Error('must not load');};assert.equal((await ensure()).url,first.url);assert.equal(calls(),1); }); test('保留大小写与音标区分;不接受客户端任意文本',async()=>{ await reset();const {ensure,calls}=setup();await ensure('101',{text:'injected'});await ensure('103');await ensure('104');assert.equal(calls(),3); const rows=(await db.query('SELECT word,phonetic FROM "WordAudioCache"')).rows;assert.equal(rows.some(row=>row.word==='injected'),false); }); test('未登录、跨租户、非师生、草稿和无效词条被拒绝',async()=>{ await reset();const {ensure,calls}=setup(); for(const actor of [null,{get:()=>null},{get:key=>({company:{id:'other'},legacyGroupId:1}[key])},{get:key=>({company:{id:'co'},legacyGroupId:2}[key])}])await assert.rejects(ensure('101',{},actor),/无权/); for(const id of ['105','106','999','101 OR 1=1'])await assert.rejects(ensure(id),/词条/);assert.equal(calls(),0); }); test('同时点击只发起一次合成,其余返回pending;完成后可复用',async()=>{ await reset();const {c,ensure}=setup();let release,calls=0; c.wordAudioSynthesize=()=>{calls++;return new Promise(resolve=>release=resolve);}; const first=ensure();while(!release)await new Promise(resolve=>setTimeout(resolve,5)); assert.equal((await ensure()).state,'pending');assert.equal((await ensure('102')).state,'pending');assert.equal(calls,1); release('SUQzYWJj');await first;assert.equal((await ensure('102')).state,'ready');assert.equal(calls,1); }); test('上传失败保留音频;冷却后重试保存而不重复调用讯飞',async()=>{ await reset();const {c,ensure,calls}=setup();c.wordAudioUpload=async()=>{throw Error('secret signed URL');}; await assert.rejects(ensure(),error=>!error.message.includes('secret')&&error.status===502); await assert.rejects(ensure(),/一分钟/);await db.exec('UPDATE "WordAudioCache" SET "retryAfter"=now()-interval \'1 second\''); c.wordAudioUpload=async()=> 'https://cdn.test/recovered.mp3';assert.equal((await ensure()).url,'https://cdn.test/recovered.mp3');assert.equal(calls(),1); }); test('生成后文件404可用保存的字节修复,不重新合成',async()=>{ await reset();const {c,ensure,calls,uploads}=setup();await ensure();c.wordAudioMissing=async()=>true; await ensure('101',{playbackFailed:true});assert.equal(calls(),1);assert.equal(uploads(),2); }); test('关闭开关仍可复用缓存;新单词不生成;缺配置拒绝生成',async()=>{ await reset();const {c,ensure,calls}=setup();await ensure();c.wordAudioConfig=async()=>({...config,enabled:false}); assert.equal((await ensure('102')).state,'ready');await assert.rejects(ensure('103'),/尚未开启/); c.wordAudioConfig=async()=>({...config,apiSecret:''});await assert.rejects(ensure('103'),/配置/);assert.equal(calls(),1); }); test('日额度/用户速率限制生效;并发上限不启动额外任务',async()=>{ await reset();const {c,ensure,calls}=setup();c.wordAudioConfig=async()=>({...config,dayLimit:1});await ensure();await assert.rejects(ensure('103'),/额度/); c.wordAudioConfig=async()=>({...config,minuteLimit:1});await assert.rejects(ensure('103'),/频繁/);assert.equal(calls(),1); }); test('回填时词条被编辑,不覆盖编辑者的录音',async()=>{ await reset();const {c,ensure}=setup();c.wordAudioUpload=async()=>{await db.query('UPDATE "VocabularyWord" SET yp=$1 WHERE id=1',['https://cdn.test/manual.mp3']);return 'https://cdn.test/generated.mp3';}; await assert.rejects(ensure());assert.equal((await one('SELECT yp FROM "VocabularyWord" WHERE id=1')).yp,'https://cdn.test/manual.mp3'); assert.equal((await ensure('102')).url,'https://cdn.test/generated.mp3'); }); test('仅可信主机的404/410确认为失效,不将超时、403、500或跳转视作失效',async()=>{ const c=context();vm.runInContext(code,c);let calls=0; for(const status of [200,403,404,410,500,302]){c.fetch=async()=>{calls++;return{status,body:{cancel:async()=>{}}};};assert.equal(await c.wordAudioMissing('https://cdn.test/word.mp3',config),[404,410].includes(status));} const before=calls;assert.equal(await c.wordAudioMissing('http://127.0.0.1/admin',config),false);assert.equal(await c.wordAudioMissing('https://untrusted.test/audio',config),false);assert.equal(calls,before); c.fetch=async()=>{throw Error('timeout');};assert.equal(await c.wordAudioMissing('https://cdn.test/word.mp3',config),false); }); test('失效旧链接才补齐;网络错误不生成',async()=>{ await reset();const {c,ensure,calls}=setup();await db.exec('UPDATE "VocabularyWord" SET yp=\'missing.mp3\' WHERE id=1');c.wordAudioMissing=async()=>false; assert.equal((await ensure('101',{playbackFailed:true})).source,'existing');assert.equal(calls(),0); c.wordAudioMissing=async()=>true;assert.equal((await ensure('101',{playbackFailed:true})).source,'xfyun');assert.equal(calls(),1); }); test('讯飞WebSocket签名与MP3分片组装;不完整/错误数据拒绝',async()=>{ const c=context();let sent,opened; class Socket { constructor(url){opened=url;queueMicrotask(()=>this.onopen());} send(data){sent=JSON.parse(data);queueMicrotask(()=>{this.onmessage({data:JSON.stringify({code:0,data:{audio:'SUQz',status:1}})});this.onmessage({data:JSON.stringify({code:0,data:{audio:'YWJj',status:2}})});});} close(){} } c.WebSocket=Socket;vm.runInContext(code,c); assert.equal(await c.wordAudioSynthesize('apple',config),'SUQzYWJj');assert.match(opened,/^wss:\/\/tts-api.xfyun.cn\/v2\/tts\?/);assert.equal(sent.business.aue,'lame');assert.equal(sent.business.sfl,1);assert.equal(sent.business.reg,'0');assert.equal(atob(sent.data.text),'apple'); c.WebSocket=class extends Socket {send(){queueMicrotask(()=>this.onmessage({data:JSON.stringify({code:11200,message:'secret'})}));}}; await assert.rejects(c.wordAudioSynthesize('apple',config),/11200/); c.WebSocket=class extends Socket {send(){queueMicrotask(()=>this.onclose());}};await assert.rejects(c.wordAudioSynthesize('apple',config),/中断/); }); test('对象存储使用固定词条对象键,MP3上传可重试覆盖同一文件',async()=>{ const c=context();vm.runInContext(code,c);let requestUrl,request;c.fetch=async(url,options)=>{requestUrl=url;request=options;return{ok:true}}; const url=await c.wordAudioUpload('hash','SUQzYWJj',config);assert.equal(url,'https://cdn.test/co/audio/words/hash.mp3');assert.match(requestUrl,/X-Amz-Signature=/);assert.equal(request.method,'PUT');assert.equal(request.headers['Content-Type'],'audio/mpeg');assert.equal(Buffer.from(request.body).toString(),'ID3abc'); }); test('无WebSocket的云函数通过受保护适配器合成,并验证返回音频',async()=>{ const c=context();vm.runInContext(code,c);c.WebSocket=undefined;let sent; const bridge={...config,workerUrl:'https://worker.test/synthesize',workerToken:'server-only-secret'}; c.fetch=async(url,options)=>{assert.equal(url,bridge.workerUrl);assert.equal(options.headers.Authorization,'Bearer '+bridge.workerToken);sent=JSON.parse(options.body);return{ok:true,json:async()=>({audio:'SUQzYWJj'})};}; assert.equal(await c.wordAudioSynthesize('apple',bridge),'SUQzYWJj');assert.equal(sent.voice,config.voice);assert.equal(sent.word,'apple'); c.fetch=async()=>({ok:true,json:async()=>({audio:'bm90LWF1ZGlv'})});await assert.rejects(c.wordAudioSynthesize('apple',bridge),/无效音频/); }); test('没有S3配置时复用现有媒体存储;客户端通用上传规则保持独立',async()=>{ const c=context();vm.runInContext(code,c);let params; c.uploadAppMedia=async(input,types)=>{params=input;assert.equal(types['audio/mpeg'],'');return 'https://legacy.test/file.mp3';}; assert.equal(await c.wordAudioUpload('hash','SUQzYWJj',{...config,storage:{}}),'https://legacy.test/file.mp3');assert.equal(params.fileName,'hash.mp3'); await assert.rejects(c.wordAudioUpload('hash','bm90LWF1ZGlv',{...config,storage:{}}),/无效/); }); test('租约过期后接管保存过的音频,旧任务不能覆盖新任务;新词遵守全局并发上限',async()=>{ await reset();const claim=(id,token)=>one('SELECT xs_word_audio_claim($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) result',['co',id,'student',token,'apple','a','voice',500,30,1]); assert.equal((await claim('key1','old-token')).result.state,'claimed');assert.equal((await claim('key2','next-token')).result.state,'pending'); await db.exec('UPDATE "WordAudioCache" SET audio=\'SUQzYWJj\',"leaseUntil"=now()-interval \'1 second\' WHERE "objectId"=\'key1\''); const resumed=(await claim('key1','new-token')).result;assert.equal(resumed.state,'claimed');assert.equal(resumed.audio,'SUQzYWJj'); const oldWrite=await db.query('UPDATE "WordAudioCache" SET url=\'stale\' WHERE "objectId"=\'key1\' AND lease=\'old-token\' RETURNING "objectId"');assert.equal(oldWrite.rows.length,0); const events=(await db.query('SELECT "providerCall" FROM "WordAudioAttempt" ORDER BY "createdAt"')).rows;assert.deepEqual(events.map(row=>row.providerCall),[true,false]); });