|
|
@@ -4,12 +4,12 @@ import { readFileSync } from 'node:fs';
|
|
|
import vm from 'node:vm';
|
|
|
|
|
|
const source = readFileSync(new URL('../deploy-admin-functions.mjs', import.meta.url), 'utf8');
|
|
|
-const functions = source.slice(source.indexOf('function importCell('), source.indexOf('async function commitVocabularyImport('));
|
|
|
+const functions = source.slice(source.indexOf('function vocabularyFieldErrors('), source.indexOf('async function commitVocabularyImport('));
|
|
|
function importer(nodes = [{ nodeId: 42, nodeName: '第一单元', bookName: '真实教材', stageName: '小学' }], existing = null) {
|
|
|
const queries = [];
|
|
|
class Batch { constructor() { this.id = 'batch'; this.createdAt = new Date(); } set() {} async save() {} }
|
|
|
const context = vm.createContext({
|
|
|
- Parse: { Object: Batch }, Psql: { query: async () => nodes, oneOrNone: async (sql, args) => { queries.push({ sql, args }); return existing; } },
|
|
|
+ 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})):[]; } },
|
|
|
text: value => String(value ?? '').trim(), number: value => Number(value) || 0,
|
|
|
requireRole() {}, reasonOf: () => 'test', companyIdOf: () => 'tenant', companyPointer: () => ({}),
|
|
|
fail: (_status, message) => { throw new Error(message); }, randomObjectId: () => 'random', audit: async () => {},
|
|
|
@@ -23,7 +23,7 @@ test('old template headers preserve GeneralID and exported audio URLs', async ()
|
|
|
const { run, queries } = importer(undefined, { objectId: 'word', title: 'time', nodeId: 42, sy: '时间', yp: 'time.mp3' });
|
|
|
const result = await run([{ ' GeneralID(可选) ': '123', 单元ID: '42', 单词: 'time', 释义: '时间', 音频URL: 'https://a018.2018.z01.com/UploadFiles/time.mp3' }]);
|
|
|
assert.equal(result.summary.unchanged, 1);
|
|
|
- assert.equal(queries[0].args[1], '123');
|
|
|
+ assert.equal(JSON.parse(queries[0].args[1])[0].generalId, '123');
|
|
|
assert.match(queries[0].sql, /generalId/);
|
|
|
});
|
|
|
test('old sample paths fail with actionable guidance, explicit chosen target resolves them', async () => {
|
|
|
@@ -53,3 +53,42 @@ test('unknown GeneralID blocks import rather than creating an unexpected word',
|
|
|
const result = await importer().run([{ 'GeneralID(可选)': 999, 单元ID: 42, 单词: 'time' }]);
|
|
|
assert.equal(result.summary.error, 1); assert.match(result.items[0].errors[0], /GeneralID.*不存在/);
|
|
|
});
|
|
|
+
|
|
|
+test('2000-row preview uses one lookup rather than a query for every word',async()=>{
|
|
|
+ const {run,queries}=importer();const result=await run(Array.from({length:2000},(_,i)=>({单元ID:42,单词:'word'+i})));
|
|
|
+ assert.equal(result.summary.create,2000);assert.equal(queries.length,1);
|
|
|
+});
|
|
|
+test('all save limits are checked before commit, at the exact boundary',async()=>{
|
|
|
+ const limits=[['单词',200],['释义',4000],['音标',500],['例句',8000],['音频URL',2000]];
|
|
|
+ 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)));}
|
|
|
+});
|
|
|
+
|
|
|
+function committer(items,save){
|
|
|
+ const data={kind:'vocabulary',status:'preview',items};let locked=false;
|
|
|
+ const batch={id:'batch',createdAt:new Date(),updatedAt:new Date(),get:k=>data[k],set:(k,v)=>{data[k]=v},save:async()=>{},fetch:async()=>{}};
|
|
|
+ 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}}}});
|
|
|
+ vm.runInContext(source.slice(source.indexOf('function vocabularyImportResult('),source.indexOf('async function saveVocabularyNode(')),context);
|
|
|
+ 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({},{})))};
|
|
|
+}
|
|
|
+test('commit checkpoints bounded chunks, reports failed rows and retries only failures',async()=>{
|
|
|
+ const calls=[];let fail=true;
|
|
|
+ const items=Array.from({length:12},(_,i)=>({row:i+2,word:'word'+i,action:i===0?'unchanged':'create',errors:[]}));
|
|
|
+ const c=committer(items,async(_c,_i,row)=>{calls.push(row.row);if(row.row===5&&fail)throw Error('单元已停用');return{targetId:'saved'+row.row}});
|
|
|
+ let result=await c.run();assert.equal(result.status,'partial');assert.equal(result.summary.processed,5);
|
|
|
+ while(result.status==='partial')result=await c.run();
|
|
|
+ assert.equal(result.status,'failed');assert.equal(result.summary.created,10);assert.equal(result.summary.skipped,1);
|
|
|
+ assert.equal(result.items.find(x=>x.row===5).message,'单元已停用');assert.equal((await c.status()).items.find(x=>x.row===5).status,'failed');
|
|
|
+ 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);
|
|
|
+ await c.run(true);assert.equal(calls.length,before+1);
|
|
|
+});
|
|
|
+test('concurrent calls cannot process the same batch twice',async()=>{
|
|
|
+ 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'}});
|
|
|
+ const first=c.run();while(!release)await new Promise(r=>setTimeout(r,0));
|
|
|
+ const second=await c.run();assert.equal(second.busy,true);assert.equal(calls,1);release();assert.equal((await first).status,'completed');
|
|
|
+});
|
|
|
+
|
|
|
+test('older clients receive a terminal result, never a partial success',async()=>{
|
|
|
+ const c=committer(Array.from({length:7},(_,i)=>({row:i+2,action:'create',errors:[]})),async()=>({targetId:'word'}));
|
|
|
+ const result=await c.run(false,false);assert.equal(result.status,'completed');assert.equal(result.summary.processed,7);
|
|
|
+ const failing=committer([{row:2,action:'create',errors:[]}],async()=>{throw Error('invalid')});await assert.rejects(failing.run(false,false),/逐行原因/);
|
|
|
+});
|