legacy-credit-balance.test.mjs 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  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 ctx=vm.createContext({Date});
  7. vm.runInContext(readFileSync(new URL('../cloud/legacy-finance.js',import.meta.url),'utf8'),ctx);
  8. const sourceRows=[
  9. {legacyFinanceId:'1',score:35,scoreBefore:0,hisTime:'2026-08-04T08:00:00Z'},
  10. {legacyFinanceId:'2',score:-14,scoreBefore:35,hisTime:'2026-08-31T08:00:00Z'},
  11. {legacyFinanceId:'3',score:-19,scoreBefore:21,hisTime:'2026-10-30T08:00:00Z'},
  12. ];
  13. const input=(extra={})=>({account:'SilverCoin',current:19,sourceBalance:2,importedAt:'2026-09-01T03:11:28Z',
  14. legacy:sourceRows,local:[{sourceKey:'credit:complete:first',score:-1},{sourceKey:'credit:complete:second',score:-1}],
  15. receiptIds:['1','2','3'],...extra});
  16. const plan=p=>{assert.equal(typeof ctx.legacyCreditBalancePlan,'function');return ctx.legacyCreditBalancePlan(p);};
  17. test('樊文昊旧余额21已扣两次变19,补齐旧系统漏同步19次后为0,保留两笔新扣课',()=>{
  18. const result=plan(input());assert.equal(result.status,'ready');assert.equal(result.balance,0);assert.equal(result.localNet,-2);assert.equal(result.delta,-19);
  19. });
  20. test('同时保留新系统充值、退款与完课扣减,不直接覆盖成旧余额',()=>{
  21. const result=plan(input({current:24,local:[{sourceKey:'credit:transfer:new',score:10},{sourceKey:'credit:refund:new',score:-3},{sourceKey:'credit:complete:new',score:-4}]}));
  22. assert.equal(result.balance,5);assert.equal(result.localNet,3);
  23. });
  24. test('陪练时长保留旧系统扣减行将score_before存成扣后值的原始口径',()=>{
  25. const result=plan(input({account:'UserPoint',legacy:sourceRows.map(r=>r.legacyFinanceId==='3'?{...r,scoreBefore:2}:r)}));assert.equal(result.balance,0);
  26. });
  27. test('来源余额与导入后的原始流水不一致时保留当前余额',()=>{
  28. assert.equal(plan(input({sourceBalance:3})).status,'conflict');
  29. });
  30. test('未知本地流水、来源缺页、重复来源与无凭据的余额改动不能被自动覆盖',()=>{
  31. for(const patch of [{local:[{sourceKey:'manual:unknown',score:-2}]},{receiptIds:['1','2']},{legacy:[...sourceRows,sourceRows[2]]},{current:18}])assert.equal(plan(input(patch)).status,'conflict');
  32. });
  33. test('小数课时与旧系统负余额保持真实数值,不能把欠费强制归零',()=>{
  34. const result=plan(input({account:'UserPoint',current:0.5,sourceBalance:-1,legacy:[{legacyFinanceId:'1',score:2,scoreBefore:0,hisTime:'2026-08-31T08:00:00Z'},{legacyFinanceId:'2',score:-3,scoreBefore:-1,hisTime:'2026-10-30T08:00:00Z'}],receiptIds:['1','2'],local:[{sourceKey:'credit:complete:new',score:-1.5}]}));assert.equal(result.balance,-2.5);
  35. });
  36. test('重跑不重复扣减;建立基线后只合并新增旧流水与新增本地流水',()=>{
  37. const previous={balance:0,sourceBalance:2,localNet:-2,sourceIds:['1','2','3']};
  38. assert.equal(plan(input({current:0,previous})).delta,0);
  39. const next=plan(input({current:-1,sourceBalance:1,previous,legacy:[...sourceRows,{legacyFinanceId:'4',score:-1,scoreBefore:2,hisTime:'2026-11-06T08:00:00Z'}],receiptIds:['1','2','3','4'],local:[...input().local,{sourceKey:'credit:complete:third',score:-1}]}));
  40. assert.equal(next.balance,-2);assert.equal(next.delta,-1);
  41. assert.equal(plan(input({current:1,previous})).status,'conflict');
  42. });
  43. test('既有来源凭据被删除,或来源变化没有新增流水时拒绝重算',()=>{
  44. const previous={balance:0,sourceBalance:2,localNet:-2,sourceIds:['1','2','3']};
  45. assert.equal(plan(input({current:0,previous,receiptIds:['1','2'],legacy:sourceRows.slice(0,2)})).status,'conflict');
  46. assert.equal(plan(input({current:0,previous,sourceBalance:5})).status,'conflict');
  47. });
  48. test('同一预约在新旧系统各扣一次时只计一次,不删除任一原始流水',()=>{
  49. const legacy=[...sourceRows.slice(0,2),{...sourceRows[2],score:-18},{legacyFinanceId:'4',score:-1,scoreBefore:3,hisTime:'2026-10-31T08:00:00Z'}];
  50. const p=input({legacy,receiptIds:['1','2','3','4'],local:[{objectId:'local-1',sourceKey:'credit:complete:first',score:-1},{objectId:'local-2',sourceKey:'credit:complete:second',score:-1}],duplicates:[{localId:'local-1',sourceId:'4'}]});
  51. const r=plan(p);assert.equal(r.balance,1);assert.equal(r.duplicateCredit,1);assert.equal(r.delta,-18);
  52. assert.equal(plan({...p,current:1,previous:{...r}}).delta,0);
  53. assert.equal(plan({...p,duplicates:[{localId:'local-1',sourceId:'3'}]}).status,'conflict');
  54. assert.equal(plan({...p,duplicates:[...p.duplicates,...p.duplicates]}).status,'conflict');
  55. });
  56. test('早期来源抓取先于账号入库时,只接受一天窗口内可逐笔核清的起点',()=>{
  57. const result=plan(input({current:38,sourceBalance:21,importedAt:'2026-10-12T23:00:00Z',legacy:[{legacyFinanceId:'1',score:40,scoreBefore:0,hisTime:'2026-10-11T12:00:00Z'},{legacyFinanceId:'2',score:-14,scoreBefore:40,hisTime:'2026-10-12T15:00:00Z'},{legacyFinanceId:'3',score:-5,scoreBefore:26,hisTime:'2026-10-30T15:00:00Z'}],local:[{sourceKey:'credit:complete:one',score:-2}]}));
  58. assert.equal(result.balance,19);assert.equal(result.delta,-19);
  59. });
  60. test('数据库修复原子更新两类课时,保留业务流水,重复执行零扣减且未知改动受保护',async()=>{
  61. const db=new PGlite();try{
  62. await db.exec(`CREATE TABLE "_User"("objectId" text,company text,"legacyUserId" numeric,"sourceKey" text,"createdAt" timestamptz,"updatedAt" timestamptz,"legacyUserData" jsonb,"identityType" text,"isDeleted" boolean);
  63. CREATE TABLE "LegacyFinanceReceipt"(company text,"studentId" numeric,account text,"runId" text,ids jsonb,checksum text);
  64. CREATE TABLE "CommonModel"(company text,"objectId" text,"modelId" numeric,"generalId" numeric);
  65. INSERT INTO "_User" VALUES('fan','co',1826,'legacy-sync:user:1826','2026-09-01T03:11:28Z',now(),'{"SilverCoin":19,"UserPoint":19,"Purse":0,"UserExp":0,"HoneyName":"樊文昊"}','member',false);`);
  66. await db.exec(readFileSync(new URL('../sql/legacy-credit-balances.sql',import.meta.url),'utf8'));
  67. const query=async(s,a=[])=>(await db.query(s,a)).rows,one=async(s,a)=>(await query(s,a))[0];
  68. const Psql={any:query,query,one,oneOrNone:async(s,a)=>(await query(s,a))[0]||null,none:async(s,a)=>{assert.equal((await query(s,a)).length,0,'Psql.none must not receive rows');}};
  69. Psql.tx=async f=>{await db.exec('BEGIN');try{const r=await f(Psql);await db.exec('COMMIT');return r;}catch(e){await db.exec('ROLLBACK');throw e;}};
  70. const live=vm.createContext({Date,Psql});vm.runInContext(readFileSync(new URL('../cloud/legacy-finance.js',import.meta.url),'utf8'),live);
  71. for(const [account,table] of [['Purse','UserExpDomP'],['SilverCoin','UserSIcon'],['UserExp','UserExpHis'],['UserPoint','UserUserPoint']]){
  72. await db.exec(`CREATE TABLE "${table}"("objectId" text,company text,"userId" numeric,"sourceKey" text,score numeric,"scoreBefore" numeric,"hisTime" timestamptz,"legacyFinanceId" text,"legacyFinanceHash" text,"transactionId" text,detail text,"legacyFinanceRaw" jsonb)`);
  73. if(account==='SilverCoin'||account==='UserPoint'){
  74. for(const row of sourceRows)await query(`INSERT INTO "${table}"("objectId",company,"userId","sourceKey",score,"scoreBefore","hisTime","legacyFinanceId","legacyFinanceHash") VALUES($1,'co',1826,$2,$3,$4,$5,$1,'verified-'||$1)`,[row.legacyFinanceId,'legacy:finance:'+row.legacyFinanceId,row.score,account==='UserPoint'&&row.score<0?row.scoreBefore+row.score:row.scoreBefore,row.hisTime]);
  75. await db.exec(`INSERT INTO "${table}"("objectId",company,"userId","sourceKey",score) VALUES('local-1','co',1826,'credit:complete:first',-1),('local-2','co',1826,'credit:complete:second',-1)`);
  76. }
  77. await query(`INSERT INTO "LegacyFinanceReceipt" SELECT 'co',1826,$1,'repair-0001',COALESCE(jsonb_agg("legacyFinanceId" ORDER BY "legacyFinanceId"::numeric),'[]'::jsonb),md5(COALESCE(jsonb_agg(jsonb_build_object('id',"legacyFinanceId",'hash',"legacyFinanceHash") ORDER BY "legacyFinanceId"::numeric),'[]'::jsonb)::text) FROM "${table}" WHERE "legacyFinanceId" IS NOT NULL`,[account]);
  78. }
  79. await db.exec(`INSERT INTO "LegacyCreditBalanceSnapshot"(company,"studentId","runId",balances) VALUES('co',1826,'repair-0001','{"SilverCoin":2,"UserPoint":2,"Purse":0,"UserExp":0}')`);
  80. const result=await live.reconcileLegacyCreditBalances('co',1826,'repair-0001');assert.equal(result.applied,2);assert.equal(result.conflicts,0);
  81. const balance=(await one('SELECT "legacyUserData" b FROM "_User"')).b;assert.equal(balance.SilverCoin,0);assert.equal(balance.UserPoint,0);assert.equal(balance.HoneyName,'樊文昊');
  82. assert.equal((await live.reconcileLegacyCreditBalances('co',1826,'repair-0001')).applied,0);
  83. assert.equal((await one('SELECT count(*)::int n FROM "LegacyCreditBalanceAudit"')).n,2);
  84. assert.equal((await one('SELECT result FROM "LegacyCreditBalanceReceipt"')).result.applied,2);
  85. assert.equal((await one('SELECT count(*)::int n FROM "UserSIcon"')).n,5);
  86. await db.exec(`INSERT INTO "CommonModel" VALUES('co','appointment',54,102);
  87. UPDATE "UserSIcon" SET "transactionId"='complete:appointment' WHERE "objectId"='local-1';
  88. INSERT INTO "UserSIcon"("objectId",company,"userId","sourceKey",score,"scoreBefore","hisTime","legacyFinanceId","legacyFinanceHash",detail,"legacyFinanceRaw") VALUES('4','co',1826,'legacy:finance:4',-1,2,'2026-11-06T08:00:00Z','4','verified-4','学习结束,订单:102','{"Detail":"学习结束,订单:102"}');
  89. UPDATE "LegacyCreditBalanceSnapshot" SET balances=balances||'{"SilverCoin":1}';`);
  90. await db.exec(`UPDATE "LegacyFinanceReceipt" SET ids='["1","2","3","4"]',checksum=(SELECT md5(jsonb_agg(jsonb_build_object('id',"legacyFinanceId",'hash',"legacyFinanceHash") ORDER BY "legacyFinanceId"::numeric)::text) FROM "UserSIcon" WHERE "legacyFinanceId" IS NOT NULL) WHERE account='SilverCoin'`);
  91. const deduped=await live.reconcileLegacyCreditBalances('co',1826,'repair-0001');assert.equal(deduped.accounts.find(a=>a.account==='SilverCoin').duplicateCredit,1);assert.equal((await one('SELECT "legacyUserData" b FROM "_User"')).b.SilverCoin,0);
  92. await db.exec(`UPDATE "_User" SET "legacyUserData"="legacyUserData"||'{"SilverCoin":7}'`);
  93. const protectedResult=await live.reconcileLegacyCreditBalances('co',1826,'repair-0001');assert.equal(protectedResult.conflicts,1);assert.equal((await one('SELECT "legacyUserData" b FROM "_User"')).b.SilverCoin,7);
  94. }finally{await db.close();}
  95. });