wordbook-gateway.test.mjs 5.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  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. import { PGlite } from '@electric-sql/pglite';
  6. const source = readFileSync(new URL('../deploy-admin-functions.mjs', import.meta.url), 'utf8');
  7. const gateway = source.match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)?.[1];
  8. assert.ok(gateway, 'app gateway source must be available');
  9. async function setup(t) {
  10. const db = new PGlite();
  11. t.after(() => db.close());
  12. await db.exec(`
  13. CREATE TABLE "PracticeRecord" (id double precision, company text, yhid text, scid text, kcid text, xxcs text, jrscb text, "updatedAt" timestamptz);
  14. CREATE TABLE "CommonModel" (company text, "modelId" numeric, "generalId" numeric, "itemId" numeric, title text);
  15. CREATE TABLE "VocabularyWord" (company text, id numeric, sy text);
  16. INSERT INTO "CommonModel" VALUES
  17. ('7pIbDBJmKx',52,101,1,'apple'), ('7pIbDBJmKx',52,102,2,'banana'),
  18. ('7pIbDBJmKx',52,103,3,'orange'), ('7pIbDBJmKx',53,901,11,'practice'),
  19. ('other',52,101,1,'foreign');
  20. INSERT INTO "VocabularyWord" VALUES ('7pIbDBJmKx',1,'苹果');
  21. INSERT INTO "PracticeRecord" VALUES
  22. (11,'7pIbDBJmKx','42','101','100',NULL,'1','2026-10-14'),
  23. (12,'7pIbDBJmKx','42','102','100','1','true','2026-10-13'),
  24. (13,'7pIbDBJmKx','42','103','100','2.0','1','2026-10-12'),
  25. (14,'7pIbDBJmKx','42','103','100','0','0','2026-10-11'),
  26. (15,'7pIbDBJmKx','99','101','100','0','1','2026-10-14'),
  27. (16,'other','42','101','100','0','1','2026-10-14'),
  28. (17,'7pIbDBJmKx','42','999','100','0','1','2026-10-14');
  29. `);
  30. let queries = 0;const calls=[];
  31. const context = vm.createContext({
  32. Psql: {
  33. one: async (sql, values) => { queries++;calls.push({sql,values}); return (await db.query(sql, values)).rows[0]; },
  34. query: async (sql, values) => { queries++;calls.push({sql,values}); return (await db.query(sql, values)).rows; },
  35. },
  36. });
  37. // Execute the actual app scope without injecting helpers from the admin gateway.
  38. vm.runInContext(gateway, context);
  39. context.authorizeRequestedUser = async (_current, uid) => {
  40. if (uid !== 42) throw new Error('无权访问该学员');
  41. return uid;
  42. };
  43. return { read: input => context.newWordPage({ uid: 42, ...input }, { get: () => 1 }), queryCount: () => queries, db, calls };
  44. }
  45. test('生词本未学分页保留全量状态计数,并隔离其他学员和帐套', async t => {
  46. const { read } = await setup(t);
  47. const first = await read({ learningStatus: ' pending ', psize: 1, cpage: 1 });
  48. assert.deepEqual(JSON.parse(JSON.stringify(first.page)), { itemCount: 2, pageCount: 2, pageIndex: 1, pageSize: 1, pendingCount: 2, completedCount: 1 });
  49. assert.equal(first.rows[0].Title, 'apple');
  50. assert.equal(first.rows[0].GeneralID, 901);
  51. assert.equal(first.rows[0].detail[0].sy, '苹果');
  52. const second = await read({ learningStatus: 'pending', psize: 1, cpage: 2 });
  53. assert.equal(second.rows[0].Title, 'banana');
  54. });
  55. test('生词本已学及旧版混合列表均可读取', async t => {
  56. const { read } = await setup(t);
  57. const completed = await read({ learningStatus: 'completed' });
  58. assert.equal(completed.page.itemCount, 1);
  59. assert.equal(completed.rows[0].Title, 'orange');
  60. const mixed = await read({});
  61. assert.equal(mixed.page.itemCount, 3);
  62. assert.equal(mixed.rows.length, 3);
  63. });
  64. test('生词本越界页返回空列表并保留正确计数', async t => {
  65. const { read } = await setup(t);
  66. const result = await read({ learningStatus: 'pending', psize: 30, cpage: 2 });
  67. assert.equal(result.rows.length, 0);
  68. assert.equal(result.page.itemCount, 2);
  69. });
  70. test('生词本拒绝非法状态和越权访问,不执行查询', async t => {
  71. const { read, queryCount } = await setup(t);
  72. await assert.rejects(read({ learningStatus: 'invalid' }), /生词学习状态无效/);
  73. await assert.rejects(read({ uid: 99 }), /无权访问/);
  74. assert.equal(queryCount(), 0);
  75. });
  76. test('生词分页使用公司、模型和词条编号完整索引键,而非逐词扫描全部记录',async t=>{
  77. const {read,db,calls}=await setup(t);
  78. await db.exec('INSERT INTO "CommonModel" SELECT \'7pIbDBJmKx\',52,i,i,\'extra-word\' FROM generate_series(10000,20000) i;CREATE INDEX wordbook_word_lookup ON "CommonModel"(company,"modelId","generalId");CREATE INDEX wordbook_practice_lookup ON "PracticeRecord"(company,yhid);ANALYZE;SET enable_seqscan=off;SET enable_hashjoin=off;SET enable_mergejoin=off;');
  79. await read({learningStatus:'pending'});
  80. const {sql,values}=calls.find(call=>call.sql.startsWith('SELECT p.*'));
  81. const result=await db.query('EXPLAIN (FORMAT JSON) '+sql,values),conditions=[];
  82. const walk=node=>{if(node['Index Name']==='wordbook_word_lookup'&&node.Alias==='c')conditions.push(node['Index Cond']||'');for(const child of node.Plans||[])walk(child);};
  83. walk(result.rows[0]['QUERY PLAN'][0].Plan);
  84. assert.ok(conditions.some(condition=>condition.includes('modelId')&&condition.includes('generalId')),'词条查找必须使用索引中的模型和编号,避免随词库规模逐词扫描');
  85. });
  86. test('索引优化保留原文本编号匹配,不把带前导零或小数的历史键误配成单词',async t=>{
  87. const {read,db}=await setup(t);
  88. await db.exec(`INSERT INTO "PracticeRecord" VALUES (21,'7pIbDBJmKx','42','00101','100','0','1',now()),(22,'7pIbDBJmKx','42','101.0','100','0','1',now()),(23,'7pIbDBJmKx','42','invalid','100','0','1',now());`);
  89. const result=await read({learningStatus:'pending'});assert.equal(result.page.itemCount,2);assert.deepEqual(result.rows.map(row=>row.Title),['apple','banana']);
  90. });