vocabulary-import.test.mjs 8.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
  1. import { test } from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { readFileSync } from 'node:fs';
  4. import vm from 'node:vm';
  5. const source = readFileSync(new URL('../deploy-admin-functions.mjs', import.meta.url), 'utf8');
  6. const functions = source.slice(source.indexOf('function vocabularyFieldErrors('), source.indexOf('async function commitVocabularyImport('));
  7. function importer(nodes = [{ nodeId: 42, nodeName: '第一单元', bookName: '真实教材', stageName: '小学' }], existing = null) {
  8. const queries = [];
  9. class Batch { constructor() { this.id = 'batch'; this.createdAt = new Date(); } set() {} async save() {} }
  10. const context = vm.createContext({
  11. Parse: { Object: Batch }, Psql: { query: async (sql,args) => { if(!sql.startsWith('WITH requested'))return nodes; queries.push({sql,args});return existing?JSON.parse(args[1]).map(r=>({...existing,matchKey:r.key})):[]; } },
  12. text: value => String(value ?? '').trim(), number: value => Number(value) || 0,
  13. requireRole() {}, reasonOf: () => 'test', companyIdOf: () => 'tenant', companyPointer: () => ({}),
  14. fail: (_status, message) => { throw new Error(message); }, randomObjectId: () => 'random', audit: async () => {},
  15. dateIso: value => value.toISOString(), legacyVocabularyMedia: value => String(value ?? '').replace('https://a018.2018.z01.com/UploadFiles/', ''),
  16. });
  17. vm.runInContext(functions, context);
  18. return { queries, run: async (rows, targetNodeId) => JSON.parse(JSON.stringify(await context.previewVocabularyImport({ current: { id: 'operator' } }, {}, { rows, ...(targetNodeId === undefined ? {} : { targetNodeId }) }))) };
  19. }
  20. test('old template headers preserve GeneralID and exported audio URLs', async () => {
  21. const { run, queries } = importer(undefined, { objectId: 'word', title: 'time', nodeId: 42, sy: '时间', yp: 'time.mp3' });
  22. const result = await run([{ ' GeneralID(可选) ': '123', 单元ID: '42', 单词: 'time', 释义: '时间', 音频URL: 'https://a018.2018.z01.com/UploadFiles/time.mp3' }]);
  23. assert.equal(result.summary.unchanged, 1);
  24. assert.equal(JSON.parse(queries[0].args[1])[0].generalId, '123');
  25. assert.match(queries[0].sql, /generalId/);
  26. });
  27. test('old sample paths fail with actionable guidance, explicit chosen target resolves them', async () => {
  28. const { run } = importer(), rows = [{ 学段: '小学', 教材: '示例教材', 单元: '第一单元', 单词: 'time' }];
  29. const invalid = await run(rows);
  30. assert.equal(invalid.summary.error, 1); assert.match(invalid.items[0].errors[0], /示例教材.*单元ID.*页面选择/);
  31. const valid = await run(rows, 42);
  32. assert.equal(valid.summary.create, 1); assert.equal(valid.items[0].nodeId, 42); assert.equal(valid.summary.missingMedia, 1);
  33. });
  34. test('explicit invalid, recycled or cross-tenant IDs never fall back to matching names', async () => {
  35. const { run } = importer();
  36. const result = await run([{ 单元ID: '999', 学段: '小学', 教材: '真实教材', 单元: '第一单元', 单词: 'time' }]);
  37. assert.equal(result.summary.error, 1); assert.match(result.items[0].errors[0], /单元ID不存在/);
  38. await assert.rejects(run([{ 单词: 'time' }], 999), /有效的目标/);
  39. });
  40. test('duplicate names require ID rather than choosing the last matching directory', async () => {
  41. const { run } = importer([42, 43].map(nodeId => ({ nodeId, nodeName: '第一单元', bookName: '真实教材', stageName: '小学' })));
  42. assert.match((await run([{ 学段: '小学', 教材: '真实教材', 单元: '第一单元', 单词: 'time' }])).items[0].errors[0], /不唯一/);
  43. });
  44. test('duplicate rows identify the original Excel row; different units remain separate', async () => {
  45. const { run } = importer([42, 43].map(nodeId => ({ nodeId })));
  46. const result = await run([{ 单元ID: 42, 单词: 'time' }, { 单元ID: 42, 单词: 'TIME' }, { 单元ID: 43, 单词: 'time' }]);
  47. assert.equal(result.summary.duplicate, 1); assert.equal(result.summary.create, 2);
  48. assert.match(result.items[1].errors[0], /第 2 行/);
  49. });
  50. test('unknown GeneralID blocks import rather than creating an unexpected word', async () => {
  51. const result = await importer().run([{ 'GeneralID(可选)': 999, 单元ID: 42, 单词: 'time' }]);
  52. assert.equal(result.summary.error, 1); assert.match(result.items[0].errors[0], /GeneralID.*不存在/);
  53. });
  54. test('2000-row preview uses one lookup rather than a query for every word',async()=>{
  55. const {run,queries}=importer();const result=await run(Array.from({length:2000},(_,i)=>({单元ID:42,单词:'word'+i})));
  56. assert.equal(result.summary.create,2000);assert.equal(queries.length,1);
  57. });
  58. test('all save limits are checked before commit, at the exact boundary',async()=>{
  59. const limits=[['单词',200],['释义',4000],['音标',500],['例句',8000],['音频URL',2000]];
  60. for(const [field,max] of limits){const {run}=importer();assert.equal((await run([{单元ID:42,单词:'word',[field]:'x'.repeat(max)}])).summary.error,0);const bad=await run([{单元ID:42,单词:'word',[field]:'x'.repeat(max+1)}]);assert.equal(bad.summary.error,1);assert.match(bad.items[0].errors[0],new RegExp(String(max)));}
  61. });
  62. function committer(items,save){
  63. const data={kind:'vocabulary',status:'preview',items};let locked=false;
  64. const batch={id:'batch',createdAt:new Date(),updatedAt:new Date(),get:k=>data[k],set:(k,v)=>{data[k]=v},save:async()=>{},fetch:async()=>{}};
  65. const context=vm.createContext({requireRole(){},reasonOf:()=> 'test',text:v=>String(v??'').trim(),number:v=>Number(v)||0,companyIdOf:()=> 'tenant',scopedObject:async()=>batch,dateIso:v=>v.toISOString(),audit:async()=>{},fail:(status,message)=>{throw Error(message)},saveVocabulary:save,Psql:{tx:async cb=>{let owned=false;try{return await cb({one:async()=>{if(locked)return{acquired:false};locked=true;owned=true;return{acquired:true}}})}finally{if(owned)locked=false}}}});
  66. vm.runInContext(source.slice(source.indexOf('function vocabularyImportResult('),source.indexOf('async function saveVocabularyNode(')),context);
  67. return {run:async (retryFailed,incremental=true)=>JSON.parse(JSON.stringify(await context.commitVocabularyImport({}, {targetId:'batch',retryFailed,incremental}))),status:async()=>JSON.parse(JSON.stringify(await context.vocabularyImportStatus({},{})))};
  68. }
  69. test('commit checkpoints bounded chunks, reports failed rows and retries only failures',async()=>{
  70. const calls=[];let fail=true;
  71. const items=Array.from({length:12},(_,i)=>({row:i+2,word:'word'+i,action:i===0?'unchanged':'create',errors:[]}));
  72. const c=committer(items,async(_c,_i,row)=>{calls.push(row.row);if(row.row===5&&fail)throw Error('单元已停用');return{targetId:'saved'+row.row}});
  73. let result=await c.run();assert.equal(result.status,'partial');assert.equal(result.summary.processed,5);
  74. while(result.status==='partial')result=await c.run();
  75. assert.equal(result.status,'failed');assert.equal(result.summary.created,10);assert.equal(result.summary.skipped,1);
  76. assert.equal(result.items.find(x=>x.row===5).message,'单元已停用');assert.equal((await c.status()).items.find(x=>x.row===5).status,'failed');
  77. fail=false;const before=calls.length;result=await c.run(true);assert.equal(result.status,'completed');assert.equal(calls.length,before+1);assert.equal(result.summary.created,11);
  78. await c.run(true);assert.equal(calls.length,before+1);
  79. });
  80. test('concurrent calls cannot process the same batch twice',async()=>{
  81. let release;let calls=0;const c=committer([{row:2,word:'time',action:'create',errors:[]}],async()=>{calls++;await new Promise(r=>release=r);return{targetId:'saved'}});
  82. const first=c.run();while(!release)await new Promise(r=>setTimeout(r,0));
  83. const second=await c.run();assert.equal(second.busy,true);assert.equal(calls,1);release();assert.equal((await first).status,'completed');
  84. });
  85. test('older clients receive a terminal result, never a partial success',async()=>{
  86. const c=committer(Array.from({length:7},(_,i)=>({row:i+2,action:'create',errors:[]})),async()=>({targetId:'word'}));
  87. const result=await c.run(false,false);assert.equal(result.status,'completed');assert.equal(result.summary.processed,7);
  88. const failing=committer([{row:2,action:'create',errors:[]}],async()=>{throw Error('invalid')});await assert.rejects(failing.run(false,false),/逐行原因/);
  89. });