word-audio.test.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import {test,before,after} from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import vm from 'node:vm';
  4. import {readFileSync} from 'node:fs';
  5. import {webcrypto} from 'node:crypto';
  6. import {PGlite} from '@electric-sql/pglite';
  7. const code=readFileSync(new URL('../cloud/word-audio.js',import.meta.url),'utf8');
  8. const migration=readFileSync(new URL('../sql/word-audio.sql',import.meta.url),'utf8');
  9. const db=new PGlite();
  10. const one=async(sql,args)=>(await db.query(sql,args)).rows[0];
  11. let serial=0;
  12. const user={id:'student',get:key=>({company:{id:'co'},legacyGroupId:1}[key])};
  13. 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'}};
  14. 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)}});}
  15. function setup(){
  16. const c=context();vm.runInContext(code,c);c.wordAudioConfig=async()=>structuredClone(config);
  17. let calls=0,uploads=0;c.wordAudioSynthesize=async()=>{calls++;return 'SUQzYWJj';};c.wordAudioUpload=async id=>{uploads++;return 'https://cdn.test/'+id+'.mp3';};
  18. return {c,calls:()=>calls,uploads:()=>uploads,ensure:(wordId='101',input={},actor=user)=>c.appWordAudioEnsure({wordId,...input},actor)};
  19. }
  20. before(async()=>{
  21. await db.exec(`CREATE TABLE "CommonModel" (company text,"modelId" int,"generalId" int,"itemId" int,title text,status int,"isDeleted" boolean);
  22. CREATE TABLE "VocabularyWord" ("objectId" text,company text,id int,yb text,yp text,"isDeleted" boolean,"updatedAt" timestamptz);`);
  23. await db.exec(migration);
  24. });
  25. after(()=>db.close());
  26. async function reset(){
  27. await db.exec(`TRUNCATE "WordAudioCache","WordAudioAttempt","CommonModel","VocabularyWord";
  28. 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);
  29. 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());`);
  30. }
  31. test('已有读音不加载讯飞配置也不生成;空缺首次保存后跨词条/用户复用',async()=>{
  32. await reset();const {c,ensure,calls,uploads}=setup();
  33. const first=await ensure();assert.equal(first.state,'ready');assert.equal(calls(),1);assert.equal(uploads(),1);
  34. assert.equal((await ensure('102',{}, {id:'teacher',get:key=>({company:{id:'co'},legacyGroupId:3}[key])})).url,first.url);assert.equal(calls(),1);
  35. c.wordAudioConfig=async()=>{throw Error('must not load');};assert.equal((await ensure()).url,first.url);assert.equal(calls(),1);
  36. });
  37. test('保留大小写与音标区分;不接受客户端任意文本',async()=>{
  38. await reset();const {ensure,calls}=setup();await ensure('101',{text:'injected'});await ensure('103');await ensure('104');assert.equal(calls(),3);
  39. const rows=(await db.query('SELECT word,phonetic FROM "WordAudioCache"')).rows;assert.equal(rows.some(row=>row.word==='injected'),false);
  40. });
  41. test('未登录、跨租户、非师生、草稿和无效词条被拒绝',async()=>{
  42. await reset();const {ensure,calls}=setup();
  43. 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),/无权/);
  44. for(const id of ['105','106','999','101 OR 1=1'])await assert.rejects(ensure(id),/词条/);assert.equal(calls(),0);
  45. });
  46. test('同时点击只发起一次合成,其余返回pending;完成后可复用',async()=>{
  47. await reset();const {c,ensure}=setup();let release,calls=0;
  48. c.wordAudioSynthesize=()=>{calls++;return new Promise(resolve=>release=resolve);};
  49. const first=ensure();while(!release)await new Promise(resolve=>setTimeout(resolve,5));
  50. assert.equal((await ensure()).state,'pending');assert.equal((await ensure('102')).state,'pending');assert.equal(calls,1);
  51. release('SUQzYWJj');await first;assert.equal((await ensure('102')).state,'ready');assert.equal(calls,1);
  52. });
  53. test('上传失败保留音频;冷却后重试保存而不重复调用讯飞',async()=>{
  54. await reset();const {c,ensure,calls}=setup();c.wordAudioUpload=async()=>{throw Error('secret signed URL');};
  55. await assert.rejects(ensure(),error=>!error.message.includes('secret')&&error.status===502);
  56. await assert.rejects(ensure(),/一分钟/);await db.exec('UPDATE "WordAudioCache" SET "retryAfter"=now()-interval \'1 second\'');
  57. c.wordAudioUpload=async()=> 'https://cdn.test/recovered.mp3';assert.equal((await ensure()).url,'https://cdn.test/recovered.mp3');assert.equal(calls(),1);
  58. });
  59. test('生成后文件404可用保存的字节修复,不重新合成',async()=>{
  60. await reset();const {c,ensure,calls,uploads}=setup();await ensure();c.wordAudioMissing=async()=>true;
  61. await ensure('101',{playbackFailed:true});assert.equal(calls(),1);assert.equal(uploads(),2);
  62. });
  63. test('关闭开关仍可复用缓存;新单词不生成;缺配置拒绝生成',async()=>{
  64. await reset();const {c,ensure,calls}=setup();await ensure();c.wordAudioConfig=async()=>({...config,enabled:false});
  65. assert.equal((await ensure('102')).state,'ready');await assert.rejects(ensure('103'),/尚未开启/);
  66. c.wordAudioConfig=async()=>({...config,apiSecret:''});await assert.rejects(ensure('103'),/配置/);assert.equal(calls(),1);
  67. });
  68. test('日额度/用户速率限制生效;并发上限不启动额外任务',async()=>{
  69. await reset();const {c,ensure,calls}=setup();c.wordAudioConfig=async()=>({...config,dayLimit:1});await ensure();await assert.rejects(ensure('103'),/额度/);
  70. c.wordAudioConfig=async()=>({...config,minuteLimit:1});await assert.rejects(ensure('103'),/频繁/);assert.equal(calls(),1);
  71. });
  72. test('回填时词条被编辑,不覆盖编辑者的录音',async()=>{
  73. 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';};
  74. await assert.rejects(ensure());assert.equal((await one('SELECT yp FROM "VocabularyWord" WHERE id=1')).yp,'https://cdn.test/manual.mp3');
  75. assert.equal((await ensure('102')).url,'https://cdn.test/generated.mp3');
  76. });
  77. test('仅可信主机的404/410确认为失效,不将超时、403、500或跳转视作失效',async()=>{
  78. const c=context();vm.runInContext(code,c);let calls=0;
  79. 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));}
  80. 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);
  81. c.fetch=async()=>{throw Error('timeout');};assert.equal(await c.wordAudioMissing('https://cdn.test/word.mp3',config),false);
  82. });
  83. test('失效旧链接才补齐;网络错误不生成',async()=>{
  84. await reset();const {c,ensure,calls}=setup();await db.exec('UPDATE "VocabularyWord" SET yp=\'missing.mp3\' WHERE id=1');c.wordAudioMissing=async()=>false;
  85. assert.equal((await ensure('101',{playbackFailed:true})).source,'existing');assert.equal(calls(),0);
  86. c.wordAudioMissing=async()=>true;assert.equal((await ensure('101',{playbackFailed:true})).source,'xfyun');assert.equal(calls(),1);
  87. });
  88. test('讯飞WebSocket签名与MP3分片组装;不完整/错误数据拒绝',async()=>{
  89. const c=context();let sent,opened;
  90. 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(){} }
  91. c.WebSocket=Socket;vm.runInContext(code,c);
  92. 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');
  93. c.WebSocket=class extends Socket {send(){queueMicrotask(()=>this.onmessage({data:JSON.stringify({code:11200,message:'secret'})}));}};
  94. await assert.rejects(c.wordAudioSynthesize('apple',config),/11200/);
  95. c.WebSocket=class extends Socket {send(){queueMicrotask(()=>this.onclose());}};await assert.rejects(c.wordAudioSynthesize('apple',config),/中断/);
  96. });
  97. test('对象存储使用固定词条对象键,MP3上传可重试覆盖同一文件',async()=>{
  98. const c=context();vm.runInContext(code,c);let requestUrl,request;c.fetch=async(url,options)=>{requestUrl=url;request=options;return{ok:true}};
  99. 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');
  100. });
  101. test('无WebSocket的云函数通过受保护适配器合成,并验证返回音频',async()=>{
  102. const c=context();vm.runInContext(code,c);c.WebSocket=undefined;let sent;
  103. const bridge={...config,workerUrl:'https://worker.test/synthesize',workerToken:'server-only-secret'};
  104. 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'})};};
  105. assert.equal(await c.wordAudioSynthesize('apple',bridge),'SUQzYWJj');assert.equal(sent.voice,config.voice);assert.equal(sent.word,'apple');
  106. c.fetch=async()=>({ok:true,json:async()=>({audio:'bm90LWF1ZGlv'})});await assert.rejects(c.wordAudioSynthesize('apple',bridge),/无效音频/);
  107. });
  108. test('没有S3配置时复用现有媒体存储;客户端通用上传规则保持独立',async()=>{
  109. const c=context();vm.runInContext(code,c);let params;
  110. c.uploadAppMedia=async(input,types)=>{params=input;assert.equal(types['audio/mpeg'],'');return 'https://legacy.test/file.mp3';};
  111. assert.equal(await c.wordAudioUpload('hash','SUQzYWJj',{...config,storage:{}}),'https://legacy.test/file.mp3');assert.equal(params.fileName,'hash.mp3');
  112. await assert.rejects(c.wordAudioUpload('hash','bm90LWF1ZGlv',{...config,storage:{}}),/无效/);
  113. });
  114. test('租约过期后接管保存过的音频,旧任务不能覆盖新任务;新词遵守全局并发上限',async()=>{
  115. 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]);
  116. assert.equal((await claim('key1','old-token')).result.state,'claimed');assert.equal((await claim('key2','next-token')).result.state,'pending');
  117. await db.exec('UPDATE "WordAudioCache" SET audio=\'SUQzYWJj\',"leaseUntil"=now()-interval \'1 second\' WHERE "objectId"=\'key1\'');
  118. const resumed=(await claim('key1','new-token')).result;assert.equal(resumed.state,'claimed');assert.equal(resumed.audio,'SUQzYWJj');
  119. 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);
  120. const events=(await db.query('SELECT "providerCall" FROM "WordAudioAttempt" ORDER BY "createdAt"')).rows;assert.deepEqual(events.map(row=>row.providerCall),[true,false]);
  121. });