learning-word-duplicates.test.mjs 6.5 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. import {test,before,after} 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 db=new PGlite(),read=n=>readFileSync(new URL(n,import.meta.url),'utf8');
  7. const q=async(s,p=[])=>(await db.query(s,p)).rows,one=async(s,p=[])=>(await q(s,p))[0];
  8. let ctx;
  9. class Row {
  10. constructor(name,data={}){this.className=name;this.data={...data};this.id=data.objectId;}
  11. get(k){return this.data[k];}set(k,v){this.data[k]=v;}
  12. toJSON(){return JSON.parse(JSON.stringify({...this.data,...(this.id?{objectId:this.id}:{})}));}
  13. }
  14. before(async()=>{
  15. await db.exec(`CREATE TABLE "PracticeRecord"("objectId" text primary key,"sourceKey" text UNIQUE,company text,"createdAt" timestamptz,"updatedAt" timestamptz,id numeric,yhid numeric,scid numeric,kcid text,xxcs numeric,jrscb text);
  16. CREATE TABLE "CommonModel"(company text,"modelId" numeric,"generalId" numeric);
  17. INSERT INTO "CommonModel" VALUES('co',52,101),('co',52,102),('co',52,103);
  18. INSERT INTO "PracticeRecord" VALUES('old','legacy:old','co','2025-01-01','2025-01-01',1001,20,101,'80',1,'0'),('new','legacy:new','co','2026-01-01','2026-01-01',1002,20,101,'80',2,'1'),('normal','legacy:normal','co','2026-01-01','2026-01-01',1003,20,102,'80',1,'0');`);
  19. await db.exec(read('../sql/mobile-teaching.sql'));
  20. const app=read('../deploy-admin-functions.mjs').match(/const appGatewayCode = String.raw`([\s\S]*?)\n`;/)[1].replace("const DEFAULT_COMPANY_ID = '7pIbDBJmKx'","const DEFAULT_COMPANY_ID = 'co'");
  21. const tx=callback=>db.transaction(t=>callback({query:async(sql,args)=>(await t.query(sql,args)).rows,one:async(sql,args)=>(await t.query(sql,args)).rows[0]}));
  22. ctx=vm.createContext({Psql:{query:q,one,tx},Parse:{Object:Row,Query:class{constructor(name){this.name=name;}async get(id){return new Row(this.name,await one('SELECT * FROM "PracticeRecord" WHERE "objectId"=$1',[id]));}}}});
  23. vm.runInContext(app+read('../cloud/mobile-teaching.js'),ctx);ctx.authorizeRequestedUser=async()=>20;
  24. });
  25. after(()=>db.close());
  26. const teacher={id:'teacher',get:k=>({legacyUserId:30,legacyGroupId:3,company:{id:'co'}}[k])};
  27. const save=(key,ids='101,102',extra={})=>ctx.updateLearningWords({action:'e_add_words',uid:20,wordsId:ids,kcid:'80',save:1,requestKey:key,...extra},teacher);
  28. test('重复历史记录的整批保存保留各自身份与生词标记,推进最高学习次数,重试不重复增加',async()=>{
  29. await db.exec('BEGIN');try{
  30. const result=await save('duplicate-batch-request');assert.equal(result.affected,2);
  31. const rows=await q('SELECT "objectId","sourceKey",id,xxcs,jrscb FROM "PracticeRecord" ORDER BY "objectId"');
  32. assert.deepEqual(rows.map(r=>[r.objectId,r.sourceKey,Number(r.id),Number(r.xxcs),r.jrscb]),[['new','legacy:new',1002,3,'1'],['normal','legacy:normal',1003,2,'0'],['old','legacy:old',1001,1,'0']]);
  33. await save('duplicate-batch-request');assert.deepEqual(await q('SELECT "objectId","sourceKey",id,xxcs,jrscb FROM "PracticeRecord" ORDER BY "objectId"'),rows);
  34. assert.equal((await one('SELECT count(*)::int n FROM "MobileTeachingCommand"')).n,1);
  35. }finally{await db.exec('ROLLBACK');}
  36. });
  37. test('底层事务防止旧快照串改来源键,重复记录按最高次数稳定选择',async()=>{
  38. await db.exec('BEGIN');try{
  39. const op={table:'PracticeRecord',match:{company:'co',yhid:20,scid:101},data:{objectId:'old',company:'co',yhid:20,scid:101,sourceKey:'legacy:old',id:1001,xxcs:1},increment:['xxcs']};
  40. await q('SELECT xs_mobile_teaching_write($1,$2,$3,$4,$5::jsonb)',['co','20','stale-practice-snapshot','same',JSON.stringify([op])]);
  41. assert.equal((await one('SELECT xxcs FROM "PracticeRecord" WHERE "objectId"=\'new\'')).xxcs,'3');
  42. assert.equal((await one('SELECT "sourceKey" FROM "PracticeRecord" WHERE "objectId"=\'new\'')).sourceKey,'legacy:new');
  43. }finally{await db.exec('ROLLBACK');}
  44. });
  45. test('新词首次创建与只改生词标记保持兼容,不降低现有学习次数',async()=>{
  46. await db.exec('BEGIN');try{
  47. assert.equal((await save('create-new-practice','103')).created,1);
  48. await save('mark-duplicate-new-word','101',{save:0,ifnew:1});
  49. assert.equal((await one('SELECT xxcs FROM "PracticeRecord" WHERE "objectId"=\'new\'')).xxcs,'2');
  50. assert.equal((await one('SELECT jrscb FROM "PracticeRecord" WHERE "objectId"=\'new\'')).jrscb,'1');
  51. await assert.rejects(save('invalid-vocabulary','999'),/不存在/);
  52. }finally{await db.exec('ROLLBACK');}
  53. });
  54. test('与线上相同的同时间、同次数重复记录按 objectId 稳定选择,保留两份历史',async()=>{
  55. await db.exec('BEGIN');try{
  56. await db.exec(`INSERT INTO "PracticeRecord" VALUES('a-tie','legacy:tie-a','co','2025-01-01','2025-01-01',1004,20,103,'80',1,'1'),('z-tie','legacy:tie-z','co','2025-01-01','2025-01-01',1005,20,103,'80',1,'0')`);
  57. assert.equal((await save('equal-progress-duplicate','103')).affected,1);
  58. const rows=await q('SELECT "objectId","sourceKey",xxcs,jrscb FROM "PracticeRecord" WHERE scid=103 ORDER BY "objectId"');
  59. assert.deepEqual(rows.map(r=>[r.objectId,r.sourceKey,Number(r.xxcs),r.jrscb]),[['a-tie','legacy:tie-a',2,'1'],['z-tie','legacy:tie-z',1,'0']]);
  60. }finally{await db.exec('ROLLBACK');}
  61. });
  62. test('老师带练只增加所选学员现有生词的学习次数,整批失败可重试且不会重复增加',async()=>{
  63. const verifyTarget=ctx.coachWordbookTarget;
  64. ctx.coachWordbookTarget=async(_current,uid)=>{
  65. if(uid!==20)throw Error('不在你的生词本带练名单中');
  66. return uid;
  67. };
  68. try {
  69. const scope={scope:'wordbook'};
  70. await assert.rejects(save('guided-unauthorized','101',{...scope,uid:99}),/不在你的生词本带练名单/);
  71. await assert.rejects(save('guided-unmarked','101,102',scope),/已不在/);
  72. await assert.rejects(save('guided-no-delete','101',{...scope,ifnew:0}),/仅支持保存/);
  73. assert.equal((await one('SELECT xxcs FROM "PracticeRecord" WHERE "objectId"=\'new\'')).xxcs,'2');
  74. const first=await save('guided-practice-retry','101',scope);
  75. assert.equal(first.created,0);
  76. assert.deepEqual(Array.from(first.objectIds),['new']);
  77. await save('guided-practice-retry','101',scope);
  78. const rows=await q('SELECT "objectId",xxcs,jrscb FROM "PracticeRecord" ORDER BY "objectId"');
  79. assert.deepEqual(rows.map(r=>[r.objectId,Number(r.xxcs),r.jrscb]),[['new',3,'1'],['normal',1,'0'],['old',1,'0']]);
  80. assert.equal((await one('SELECT count(*)::int n FROM "MobileTeachingCommand"')).n,1);
  81. } finally {
  82. ctx.coachWordbookTarget=verifyTarget;
  83. await db.exec(`UPDATE "PracticeRecord" SET xxcs=2 WHERE "objectId"='new'; DELETE FROM "MobileTeachingCommand";`);
  84. }
  85. });