payroll-source-index.test.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. import {test,before,after} from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import {readFileSync,existsSync} from 'node:fs';
  4. import vm from 'node:vm';
  5. import {PGlite} from '@electric-sql/pglite';
  6. const db=new PGlite();
  7. const q=async(sql,args=[])=>(await db.query(sql,args)).rows;
  8. const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
  9. const integrity=readFileSync(new URL('../sql/payroll-integrity.sql',import.meta.url),'utf8');
  10. const incremental=readFileSync(new URL('../sql/payroll-incremental-reads.sql',import.meta.url),'utf8');
  11. const indexPath=new URL('../sql/payroll-source-index.sql',import.meta.url);
  12. const bulkPath=new URL('../sql/payroll-source-bulk.sql',import.meta.url);
  13. const sqlFunction=(text,name)=>text.match(new RegExp('CREATE OR REPLACE FUNCTION '+name.replaceAll('(','\\(').replaceAll(')','\\)')+'[\\s\\S]*?\\$\\$;'))[0];
  14. const rows=async(name,co='co')=>(await q(`SELECT s FROM ${name}($1) s`,[co])).map(r=>r.s);
  15. async function equal(){for(const co of ['co','other','empty']){
  16. const a=await rows('xs_payroll_sources',co),b=await rows('xs_payroll_sources_indexed',co);
  17. assert.deepEqual(b,a);
  18. const hashes=await q(`SELECT (SELECT md5(string_agg(s::text,'|' ORDER BY s::text)) FROM xs_payroll_sources($1) s) original,(SELECT md5(string_agg(s::text,'|' ORDER BY s::text)) FROM xs_payroll_sources_indexed($1) s) bulk`,[co]);
  19. assert.equal(hashes[0].bulk,hashes[0].original);
  20. }}
  21. before(async()=>{
  22. const block=source.slice(source.indexOf('async function ensurePayrollSchemas()'));
  23. const schemas=vm.runInNewContext('('+block.match(/const schemas = (\{[\s\S]*?\n });/)[1]+')');
  24. for(const name of ['LessonRecord','LessonRecordFact','CourseAppointment']){
  25. const cols={objectId:'text PRIMARY KEY',createdAt:'timestamptz',updatedAt:'timestamptz',...Object.fromEntries(Object.entries(schemas[name]).map(([k,v])=>[k,({Number:'double precision',Date:'timestamptz',Boolean:'boolean',Array:'jsonb',Object:'jsonb'})[v.type]||'text']))};
  26. await db.exec(`CREATE TABLE "${name}" (${Object.entries(cols).map(([k,v])=>`"${k}" ${v}`).join(',')})`);
  27. }
  28. await db.exec(`CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,company text,"generalId" numeric,"itemId" numeric,"modelId" numeric,"createdAt" timestamptz,status numeric,title text,subtitle text);
  29. ALTER TABLE "CourseAppointment" ADD COLUMN jffs text,ADD COLUMN jssj text;
  30. ALTER TABLE "LessonRecord" ADD COLUMN "courseCategoryKey" text,ADD COLUMN "courseCategoryName" text,ADD COLUMN "teacherPaySnapshot" double precision;
  31. CREATE TABLE "PayrollReadAuthority"(name text PRIMARY KEY,hash text);
  32. CREATE FUNCTION xs_payroll_source_version(co text) RETURNS text LANGUAGE sql AS $$ SELECT co $$;
  33. CREATE FUNCTION xs_payroll_cutoff_snapshot(co text,mon text) RETURNS jsonb LANGUAGE sql AS $$ SELECT '{}'::jsonb $$;`);
  34. await db.exec(sqlFunction(integrity,'xs_payroll_time'));
  35. await db.exec(sqlFunction(integrity,'xs_payroll_sources'));
  36. await db.exec(sqlFunction(incremental,'xs_payroll_authority_ready'));
  37. await db.exec(`INSERT INTO "PayrollReadAuthority" SELECT name,md5(pg_get_functiondef(to_regprocedure(name))) FROM (VALUES ('xs_payroll_sources(text)'),('xs_payroll_source_version(text)'),('xs_payroll_cutoff_snapshot(text,text)')) v(name);
  38. INSERT INTO "CourseAppointment"("objectId",company,id,"teacherPaySnapshot","courseCategoryKey",jssj) VALUES ('a','co',1,40,'word','2026-10-01 01:00:00'),('other-a','other',1,99,'other','2026-10-02');
  39. INSERT INTO "CommonModel"("objectId",company,"modelId","itemId","generalId",status,title) VALUES
  40. ('a-missing','co',54,999,100,99,''),('b-appt','co',54,1,100,99,''),('other-appt','other',54,1,100,99,''),
  41. ('a-lesson','co',59,1,200,-2,'saved student'),('z-lesson','co',59,1,999,99,'wrong student');
  42. INSERT INTO "LessonRecord"("objectId",company,id,"legacyGeneralId",yyds,"teacherPaySnapshot",plpjsj) VALUES
  43. ('l','co',1,201,'100',0,'invalid'),('l-plain','co',2,0,'0100',null,'2026-09-30 23:59:59'),('other-l','other',1,300,'100',null,'2026-10-02');
  44. INSERT INTO "LessonRecordFact"("objectId",company,"legacyGeneralId",yyds,plpjsj) VALUES ('suppressed','co',200,'100','2026-09-01'),('fact','co',400,'100','2026-10-03'),('fact-zero','co',0,'100','2026-10-04');`);
  45. if(existsSync(bulkPath))await db.exec(readFileSync(bulkPath,'utf8'));
  46. await db.exec(`CREATE TABLE "MemoryPracticeRecord"("objectId" text PRIMARY KEY,company text,id numeric);
  47. CREATE TABLE "TeachingCompensationRule"("objectId" text PRIMARY KEY,company text,amount numeric);
  48. CREATE TABLE "CourseTeachingProfile"("objectId" text PRIMARY KEY,company text,category text);
  49. CREATE TABLE "CoachPayRate"("objectId" text PRIMARY KEY,company text,amount numeric);
  50. CREATE TABLE "ReviewRewardIncome"("objectId" text PRIMARY KEY,company text,status text,amount numeric);
  51. SET TimeZone='Etc/GMT-8';SET extra_float_digits=1;`);
  52. await db.exec(sqlFunction(integrity,'xs_payroll_source_version'));
  53. await db.exec(`UPDATE "PayrollReadAuthority" SET hash=md5(pg_get_functiondef(to_regprocedure(name)));`);
  54. await db.exec(incremental);
  55. if(existsSync(indexPath))await db.exec(readFileSync(indexPath,'utf8'));
  56. });
  57. after(()=>db.close());
  58. test('validated projection index returns exact authoritative JSON and source versions',async()=>{
  59. assert.equal((await q("SELECT to_regprocedure('xs_payroll_source_entries(text)') IS NOT NULL AS available"))[0].available,true,'source entries helper installed');
  60. await equal();
  61. const [v]=await q("SELECT xs_payroll_source_version('co') original,xs_payroll_source_version_indexed('co') indexed");assert.equal(v.indexed,v.original);
  62. const entries=await q("SELECT * FROM xs_payroll_source_entries('co')");
  63. assert.equal(entries.length,4);assert.deepEqual(entries.map(x=>x.ordinal),[1,2,3,4]);
  64. for(const entry of entries)assert.deepEqual(JSON.parse(entry.row_text),entry.row_data);
  65. });
  66. test('current roots and dependency signatures reject missing, orphaned and stale projection entries',async()=>{
  67. await db.exec('BEGIN');
  68. try{
  69. const saved=(await q(`SELECT * FROM "PayrollSourceIndex" WHERE company='co' AND kind='fact' AND "objectId"='fact'`))[0];
  70. await db.exec(`UPDATE "CourseAppointment" SET "teacherPaySnapshot"=77 WHERE company='co';`);
  71. // Simulate an older concurrent transaction winning the best-effort projection write.
  72. await q(`UPDATE "PayrollSourceIndex" SET row_data=$1::jsonb,row_text=$2,input_signature=$3 WHERE company='co' AND kind='fact' AND "objectId"='fact'`,[JSON.stringify(saved.row_data),saved.row_text,saved.input_signature]);
  73. await db.exec(`DELETE FROM "PayrollSourceIndex" WHERE "objectId"='l-plain';
  74. INSERT INTO "PayrollSourceIndex"(kind,company,"objectId",row_data,row_text,input_signature,generation) SELECT kind,company,'orphan',row_data,row_text,input_signature,generation FROM "PayrollSourceIndex" WHERE "objectId"='fact';`);
  75. assert.equal((await q('SELECT xs_payroll_source_index_ready() ready'))[0].ready,true);
  76. await equal();assert.equal((await rows('xs_payroll_sources_indexed')).find(r=>r.objectId==='fact').teacherPaySnapshot,77);
  77. }finally{await db.exec('ROLLBACK');}
  78. });
  79. test('incremental dependencies include canonical suppression, mapping reselection, tenant moves and rollback',async()=>{
  80. const mutations=[
  81. `UPDATE "CommonModel" SET "generalId"=400 WHERE "objectId"='a-lesson'`,
  82. `DELETE FROM "CommonModel" WHERE "objectId"='a-lesson'`,
  83. `UPDATE "CommonModel" SET "itemId"=999 WHERE "objectId"='b-appt'`,
  84. `UPDATE "CourseAppointment" SET company='other',id=9 WHERE "objectId"='a'`,
  85. `UPDATE "LessonRecord" SET company='other' WHERE "objectId"='l'`,
  86. `DELETE FROM "LessonRecord" WHERE "objectId"='l'`,
  87. `UPDATE "LessonRecordFact" SET company='other',"legacyGeneralId"=300 WHERE "objectId"='fact'`
  88. ];
  89. for(const sql of mutations){await db.exec('BEGIN');try{await db.exec(sql);await equal();}finally{await db.exec('ROLLBACK');}await equal();}
  90. });
  91. test('best-effort upkeep updates only affected targets with signatures matching native rows',async()=>{
  92. await db.exec('BEGIN');try{
  93. const untouched=(await q(`SELECT ctid::text AS address FROM "PayrollSourceIndex" WHERE "objectId"='l-plain'`))[0].address;
  94. await db.exec(`UPDATE "CourseAppointment" SET "teacherPaySnapshot"=77 WHERE "objectId"='a';`);
  95. assert.equal((await q(`SELECT row_data->>'teacherPaySnapshot' AS amount FROM "PayrollSourceIndex" WHERE "objectId"='fact'`))[0].amount,'77');
  96. assert.equal((await q(`SELECT ctid::text AS address FROM "PayrollSourceIndex" WHERE "objectId"='l-plain'`))[0].address,untouched);
  97. const [mismatch]=await q(`SELECT count(*)::int AS n FROM "PayrollSourceIndex" i CROSS JOIN LATERAL xs_payroll_source_index_target(i.company,i.kind,i."objectId") current WHERE i.input_signature IS DISTINCT FROM current.input_signature`);
  98. assert.equal(mismatch.n,0);await equal();
  99. }finally{await db.exec('ROLLBACK');}
  100. });
  101. test('schema and trigger drift fail closed; truncate requires a new generation and full reseed',async()=>{
  102. await db.exec('BEGIN');try{
  103. await db.exec(`ALTER TABLE "LessonRecord" ADD COLUMN extension text DEFAULT 'new';`);
  104. assert.equal((await q('SELECT xs_payroll_source_index_ready() ready'))[0].ready,false);await equal();
  105. }finally{await db.exec('ROLLBACK');}
  106. await db.exec('BEGIN');try{
  107. await db.exec(`ALTER TABLE "CourseAppointment" DISABLE TRIGGER xs_payroll_source_index_row;UPDATE "CourseAppointment" SET "teacherPaySnapshot"=77;ALTER TABLE "CourseAppointment" ENABLE ALWAYS TRIGGER xs_payroll_source_index_row;`);
  108. assert.equal((await q('SELECT xs_payroll_source_index_ready() ready'))[0].ready,false);await equal();
  109. }finally{await db.exec('ROLLBACK');}
  110. await db.exec('BEGIN');try{
  111. const old=(await q('SELECT generation FROM "PayrollSourceIndexMeta"'))[0].generation;
  112. await db.exec('TRUNCATE "LessonRecord"');assert.equal((await q('SELECT xs_payroll_source_index_ready() ready'))[0].ready,false);await equal();
  113. await db.exec('SAVEPOINT invalid_reenable');
  114. await assert.rejects(db.exec('UPDATE "PayrollSourceIndexMeta" SET valid=true'),/reseed/);
  115. await db.exec('ROLLBACK TO SAVEPOINT invalid_reenable');
  116. await db.exec('SELECT xs_payroll_source_index_rebuild()');assert.equal((await q('SELECT xs_payroll_source_index_ready() ready'))[0].ready,true);
  117. assert.notEqual((await q('SELECT generation FROM "PayrollSourceIndexMeta"'))[0].generation,old);await equal();
  118. }finally{await db.exec('ROLLBACK');}
  119. });
  120. test('generation mismatches miss safely and changed financial authority bypasses the index',async()=>{
  121. await db.exec('BEGIN');try{
  122. await db.exec(`UPDATE "PayrollSourceIndex" SET generation='old-generation';`);await equal();
  123. await db.exec(`CREATE OR REPLACE FUNCTION xs_payroll_sources(co text) RETURNS SETOF jsonb LANGUAGE sql AS $$ SELECT jsonb_build_object('newAuthority',co) $$;
  124. CREATE OR REPLACE FUNCTION xs_payroll_source_version(co text) RETURNS text LANGUAGE sql AS $$ SELECT 'new-policy:'||co $$;`);
  125. assert.equal((await q('SELECT xs_payroll_source_index_ready() ready'))[0].ready,false);
  126. assert.deepEqual(await rows('xs_payroll_sources_indexed'),[{newAuthority:'co'}]);
  127. assert.equal((await q("SELECT xs_payroll_source_version_indexed('co') AS v"))[0].v,'new-policy:co');
  128. }finally{await db.exec('ROLLBACK');}
  129. });
  130. test('version still recomputes all rules and earned reward text exactly',async()=>{
  131. await db.exec('BEGIN');try{
  132. await db.exec(`INSERT INTO "TeachingCompensationRule" VALUES('r','co',40);
  133. INSERT INTO "CourseTeachingProfile" VALUES('p','co','word');INSERT INTO "CoachPayRate" VALUES('r','co',0);
  134. INSERT INTO "ReviewRewardIncome" VALUES('earned','co','earned',0.50),('pending','co','pending',99);`);
  135. const initial=(await q("SELECT xs_payroll_source_version_indexed('co') AS v"))[0].v;
  136. for(const mutation of [`UPDATE "TeachingCompensationRule" SET amount=41`,`UPDATE "CourseTeachingProfile" SET category='changed'`,`UPDATE "CoachPayRate" SET amount=1`,`UPDATE "ReviewRewardIncome" SET amount=1 WHERE status='earned'`]){
  137. await db.exec(mutation);const [v]=await q("SELECT xs_payroll_source_version('co') original,xs_payroll_source_version_indexed('co') indexed");assert.equal(v.indexed,v.original);assert.notEqual(v.indexed,initial);
  138. }
  139. }finally{await db.exec('ROLLBACK');}
  140. });
  141. test('version-only entries omit JSON while retaining exact text on hits and misses',async()=>{
  142. assert.equal((await q("SELECT to_regprocedure('xs_payroll_source_index_entries(text,boolean,boolean)') IS NOT NULL AS available"))[0].available,true);
  143. await db.exec('BEGIN');try{
  144. for(const clear of [false,true]){
  145. if(clear)await db.exec('DELETE FROM "PayrollSourceIndex"');
  146. const entries=await q("SELECT * FROM xs_payroll_source_index_entries('co',true,false)");
  147. assert(entries.every(e=>e.row_data===null));
  148. assert.deepEqual(entries.map(e=>JSON.parse(e.row_text)),await rows('xs_payroll_sources'));
  149. const [v]=await q("SELECT xs_payroll_source_version('co') original,xs_payroll_source_version_indexed('co') indexed");assert.equal(v.indexed,v.original);
  150. }
  151. }finally{await db.exec('ROLLBACK');}
  152. });
  153. test('unrelated schemas with matching trigger names do not invalidate the production generation',async()=>{
  154. await db.exec('BEGIN');try{
  155. await db.exec(`CREATE SCHEMA diagnostic;CREATE TABLE diagnostic.copy("objectId" text);
  156. CREATE TRIGGER xs_payroll_source_index_row AFTER INSERT ON diagnostic.copy FOR EACH ROW EXECUTE FUNCTION xs_payroll_source_index_row();`);
  157. assert.equal((await q('SELECT xs_payroll_source_index_ready() ready'))[0].ready,true);
  158. }finally{await db.exec('ROLLBACK');}
  159. });
  160. test('scoped entries keep all version text and scope metadata while hydrating only month or coach candidates',async()=>{
  161. assert.equal((await q("SELECT to_regprocedure('xs_payroll_source_entries_scoped(text,text,text,text[],boolean)') IS NOT NULL AS available"))[0].available,true);
  162. await db.exec('BEGIN');try{
  163. await db.exec(`UPDATE "LessonRecord" SET jsmz=CASE WHEN "objectId"='l' THEN '30' ELSE '31' END WHERE company='co';UPDATE "LessonRecordFact" SET jsmz='30' WHERE company='co';`);
  164. const original=await rows('xs_payroll_sources');
  165. for(const clear of [false,true]){
  166. if(clear)await db.exec('DELETE FROM "PayrollSourceIndex"');
  167. const selected=await q("SELECT * FROM xs_payroll_source_entries_scoped('co','2026-10',NULL,NULL,true) ORDER BY ordinal");
  168. assert.equal(selected.length,original.length);
  169. assert.deepEqual(selected.map(r=>JSON.parse(r.row_text)),original);
  170. assert.deepEqual(selected.map(r=>r.scope_data),original.map(r=>[r.coachId??null,r.jsmz??null,r.storeId??null,r.szmdid??null]));
  171. assert.equal(selected.find(r=>r.standard_key==='lesson:l-plain').row_data,null);
  172. assert.equal(selected.find(r=>r.standard_key==='legacy:200').row_data.objectId,'l');
  173. assert.equal(selected.find(r=>r.standard_key==='legacy:400').month_candidate_key,null,'unknown dates remain candidates');
  174. const teacher=await q("SELECT * FROM xs_payroll_source_entries_scoped('co',NULL,'30',ARRAY['100'],false) ORDER BY ordinal");
  175. assert(teacher.every(r=>r.row_text===null));
  176. assert.deepEqual(teacher.filter(r=>r.row_data!==null).map(r=>r.row_data),original.filter(r=>(r.coachId??r.jsmz)==='30'&&(r.appointmentGeneralId??r.yyds)==='100'));
  177. }
  178. }finally{await db.exec('ROLLBACK');}
  179. });
  180. test('hidden fact projections are maintained independently and root membership alone suppresses or restores them',async()=>{
  181. await db.exec('BEGIN');try{
  182. await db.exec("SELECT xs_payroll_source_index_put('co','fact','suppressed')");
  183. assert.equal((await q(`SELECT count(*)::int n FROM "PayrollSourceIndex" WHERE "objectId"='suppressed'`))[0].n,1);
  184. assert.equal((await rows('xs_payroll_sources_indexed')).some(r=>r.objectId==='suppressed'),false);
  185. const hidden=(await q(`SELECT ctid::text address FROM "PayrollSourceIndex" WHERE "objectId"='suppressed'`))[0].address;
  186. await db.exec(`DELETE FROM "LessonRecord" WHERE "objectId"='l'`);
  187. assert.equal((await q(`SELECT ctid::text address FROM "PayrollSourceIndex" WHERE "objectId"='suppressed'`))[0].address,hidden,'lesson membership changes do not rewrite fact projections');
  188. await equal();assert.equal((await rows('xs_payroll_sources_indexed')).some(r=>r.objectId==='suppressed'),true);
  189. }finally{await db.exec('ROLLBACK');}
  190. });