|
@@ -0,0 +1,102 @@
|
|
|
|
|
+import {test,before,after} from 'node:test';
|
|
|
|
|
+import assert from 'node:assert/strict';
|
|
|
|
|
+import {readFileSync} from 'node:fs';
|
|
|
|
|
+import {PGlite} from '@electric-sql/pglite';
|
|
|
|
|
+const db=new PGlite();const sql=readFileSync(new URL('../sql/member-enrollment.sql',import.meta.url),'utf8');
|
|
|
|
|
+const query=async(s,p=[])=>(await db.query(s,p)).rows;
|
|
|
|
|
+const one=async(s,p=[])=>(await query(s,p))[0];
|
|
|
|
|
+before(async()=>{
|
|
|
|
|
+ await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,"company" text,"legacyUserId" double precision,"legacyGroupId" double precision,"identityType" text,"isDisabled" boolean,"legacyUserData" jsonb,"updatedAt" timestamptz);
|
|
|
|
|
+ CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"yhid" text,"kcid" text,"yxx" double precision,"cksl" double precision,"syjd" text);
|
|
|
|
|
+ CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"createTime" timestamptz,"sourceKey" text,"generalId" double precision,"itemId" double precision,"modelId" double precision,"nodeId" double precision,"tableName" text,"title" text,"subtitle" text,"status" double precision,"inputer" text);
|
|
|
|
|
+ CREATE TABLE "CourseAppointment" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"szyh" text,"kcid" text,"pl" text,"fxpl" text,"dszt" text,"dslx" text,"jffs" text,"jssj" text,"updatedAt" timestamptz,"creditCostsSnapshot" jsonb,"creditHoldStatus" text,"courseCategoryKey" text,"courseCategoryName" text,"teacherPaySnapshot" double precision);
|
|
|
|
|
+ CREATE TABLE "LessonRecord" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"xymz" text,"jsmz" text,"kcid" text,"kclx" text,"kcmc" text,"yyds" text,"jffs" text,"kzsj" text,"lessonAt" timestamptz);
|
|
|
|
|
+ CREATE TABLE "DailyStudyRecord" ("objectId" text);
|
|
|
|
|
+ CREATE TABLE "CourseTeachingProfile" ("company" text,"courseId" double precision,"categoryKey" text,"confirmed" boolean);
|
|
|
|
|
+ CREATE TABLE "CourseCreditReservation" ("objectId" text PRIMARY KEY,"company" text,"studentId" double precision,"appointmentId" text,"creditCosts" jsonb,"state" text,"consumedAt" timestamptz,"updatedAt" timestamptz);`);
|
|
|
|
|
+ for(const table of ['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'])await db.exec(`CREATE TABLE "${table}" ("objectId" text PRIMARY KEY,"company" text,"sourceKey" text UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,"userId" double precision,"score" double precision,"scoreBefore" double precision,"hisTime" timestamptz,"operator" double precision,"scoreType" double precision,"detail" text,"remark" text,"payMethod" text)`);
|
|
|
|
|
+ await db.exec(`INSERT INTO "_User" VALUES ('store','co',10,2,'store',false,'{}',now()),('student','co',20,1,'member',false,'{"ParentUserID":10}',now()),('empty','co',21,1,'member',false,'{}',now()),('foreign','other',22,1,'member',false,'{}',now());`);
|
|
|
|
|
+ await db.exec(`ALTER TABLE "CourseAppointment" ADD COLUMN "kssj" text,ADD COLUMN "plxm" text,ADD COLUMN "yykcid" text,ADD COLUMN "durationMinutes" numeric,ADD COLUMN "deliveryMode" text,ADD COLUMN "teachingRuleId" text,ADD COLUMN "bxrq" text,ADD COLUMN "sdsd" text,ADD COLUMN "yysj" text,ADD COLUMN "sourceKey" text,ADD COLUMN "createdAt" timestamptz;
|
|
|
|
|
+ ALTER TABLE "CourseCreditReservation" ADD COLUMN "sourceKey" text,ADD COLUMN "createdAt" timestamptz,ADD COLUMN "appointmentGeneralId" numeric,ADD COLUMN "ruleId" text,ADD COLUMN "reservedAt" timestamptz;`);
|
|
|
|
|
+ await db.exec(`CREATE TABLE "Node" ("company" text,"nodeId" numeric,"parentId" numeric,"nodeName" text,"zstatus" numeric);
|
|
|
|
|
+ CREATE TABLE "SysLog" ("objectId" text PRIMARY KEY,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text NOT NULL UNIQUE,"id" double precision,"cdate" timestamptz,"cname" text,"type1" text,"type2" text,"type3" text,"clevel" double precision,"cadminId" double precision,"detail" text,"content1" text,"content2" text);
|
|
|
|
|
+ INSERT INTO "Node" VALUES ('co',100,8,'词表',99),('co',101,8,'空词表',99);
|
|
|
|
|
+ INSERT INTO "CommonModel" ("objectId","company","nodeId","modelId","status") SELECT 'word-'||i,'co',100,52,99 FROM generate_series(1,200) i;`);
|
|
|
|
|
+ await db.exec(sql);
|
|
|
|
|
+ await db.exec('ALTER TABLE "_User" ADD COLUMN "realName" text, ADD COLUMN nickname text, ADD COLUMN username text');
|
|
|
|
|
+ await db.exec(readFileSync(new URL('../sql/member-credit-ledger.sql',import.meta.url),'utf8'));
|
|
|
|
|
+ await db.exec(`ALTER TABLE "_User" ADD COLUMN "adminRoleKey" text,ADD COLUMN roles jsonb,ADD COLUMN "isAdmin" boolean,ADD COLUMN "isDeleted" boolean;
|
|
|
|
|
+ INSERT INTO "_User" ("objectId",company,"legacyUserId","identityType","adminRoleKey") VALUES ('root','co',90,'admin','super-admin'),('manager','co',91,'admin','ops-manager'),('reader','co',92,'admin','ops-auditor'),('outsider','other',93,'admin','ops-manager');`);
|
|
|
|
|
+ await db.exec(readFileSync(new URL('../sql/store-credit.sql',import.meta.url),'utf8'));
|
|
|
|
|
+});
|
|
|
|
|
+after(()=>db.close());
|
|
|
|
|
+const balances=async(id='store')=>(await one('SELECT "legacyUserData" b FROM "_User" WHERE "objectId"=$1',[id])).b;
|
|
|
|
|
+const grant=async(user,enabled,actor='root')=>one("SELECT xs_store_credit_grant('co',$1,$2,$3,'充值权限核验') r",[actor,user,enabled]);
|
|
|
|
|
+const payload={direction:'add',payMethod:'运营记账',lines:[{account:'Purse',amount:10},{account:'SilverCoin',amount:5},{account:'UserExp',amount:8},{account:'UserPoint',amount:10.5}]};
|
|
|
|
|
+const plan=async(p=payload,actor='root',target='store')=>(await one("SELECT xs_store_credit_plan('co',$1,$2,$3::jsonb) r",[actor,target,JSON.stringify(p)])).r;
|
|
|
|
|
+const post=async(p,key,version,actor='root')=>(await one("SELECT xs_store_credit_post('co',$1,'store',$2::jsonb,'总部额度测试',$3,$4) r",[actor,JSON.stringify(p),key,version])).r;
|
|
|
|
|
+test('独立授权、撤销、跨帐套与审计角色不能充值',async()=>{
|
|
|
|
|
+ await assert.rejects(plan(payload,'manager'),/权限/);await assert.rejects(grant('manager',true,'manager'),/超级管理员/);
|
|
|
|
|
+ await grant('manager',true);assert.equal((await plan(payload,'manager')).lines.length,4);
|
|
|
|
|
+ await assert.rejects(grant('reader',true),/运营管理员/);await assert.rejects(grant('outsider',true),/本帐套/);
|
|
|
|
|
+ await assert.rejects(plan(payload,'reader'),/权限/);await assert.rejects(plan(payload,'root','foreign'),/门店/);
|
|
|
|
|
+ await grant('manager',false);await assert.rejects(plan(payload,'manager'),/权限/);
|
|
|
|
|
+ assert.equal(Number((await one('SELECT count(*) n FROM "StoreCreditGrantAudit"')).n),2);
|
|
|
|
|
+});
|
|
|
|
|
+test('四项同时入账、原单与流水对应、重复提交不重复、旧预览阻止',async()=>{
|
|
|
|
|
+ const preview=await plan(),result=await post(payload,'store-fund-0001',preview.version);
|
|
|
|
|
+ assert.deepEqual(await balances(),{Purse:10,SilverCoin:5,UserExp:8,UserPoint:10.5});
|
|
|
|
|
+ assert.deepEqual(await post(payload,'store-fund-0001',preview.version),result);
|
|
|
|
|
+ assert.equal(result.transactions.length,4);assert.equal(new Set((await query('SELECT "operationId" FROM "CreditTransaction"')).map(r=>r.operationId)).size,1);
|
|
|
|
|
+ assert.equal((await query('SELECT * FROM xs_member_ledger WHERE company=\'co\' AND "studentId"=10')).length,4);
|
|
|
|
|
+ await assert.rejects(post({...payload,lines:[{account:'Purse',amount:1}]},'store-fund-0001',preview.version),/请求编号/);
|
|
|
|
|
+ await assert.rejects(post(payload,'stale-store-01',preview.version),/预览/);
|
|
|
|
|
+});
|
|
|
|
|
+test('拒绝非法数值、重复账户、空项及失效门店',async()=>{
|
|
|
|
|
+ for(const amount of [0,-1,0.5,'NaN',1000000001])await assert.rejects(plan({...payload,lines:[{account:'Purse',amount}]}));
|
|
|
|
|
+ await assert.rejects(plan({...payload,lines:[]}));await assert.rejects(plan({...payload,lines:[payload.lines[0],payload.lines[0]]}),/重复/);
|
|
|
|
|
+ await assert.rejects(plan({...payload,lines:[{account:'UserPoint',amount:1.001}]}));
|
|
|
|
|
+ await db.exec('UPDATE "_User" SET "isDeleted"=true WHERE "objectId"=\'store\'');await assert.rejects(plan(),/停用/);await db.exec('UPDATE "_User" SET "isDeleted"=false WHERE "objectId"=\'store\'');
|
|
|
|
|
+});
|
|
|
|
|
+test('任一账户记账或审计失败,全部余额、凭据和流水回滚',async()=>{
|
|
|
|
|
+ const before=await balances(),n=await one('SELECT count(*) n FROM "CreditTransaction"');
|
|
|
|
|
+ await db.exec(`CREATE FUNCTION store_test_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.account='UserPoint' THEN RAISE EXCEPTION 'simulated receipt failure';END IF;RETURN NEW;END $$;CREATE TRIGGER store_test_fail BEFORE INSERT ON "CreditTransaction" FOR EACH ROW EXECUTE FUNCTION store_test_fail();`);
|
|
|
|
|
+ await assert.rejects(post(payload,'failed-store-01',(await plan()).version),/simulated/);
|
|
|
|
|
+ assert.deepEqual(await balances(),before);assert.deepEqual(await one('SELECT count(*) n FROM "CreditTransaction"'),n);assert.equal(Number((await one('SELECT count(*) n FROM "StoreCreditOperation" WHERE "idempotencyKey"=\'failed-store-01\'')).n),0);
|
|
|
|
|
+ await db.exec('DROP TRIGGER store_test_fail ON "CreditTransaction"');
|
|
|
|
|
+});
|
|
|
|
|
+test('关联原单纠错、累计不能超原单、已划拨与预留不能扣回',async()=>{
|
|
|
|
|
+ const src=await one('SELECT * FROM "CreditTransaction" WHERE kind=\'fund\' AND account=\'Purse\'');
|
|
|
|
|
+ const p={direction:'reverse',payMethod:'运营记账',sourceTransactionId:src.objectId,lines:[{account:'Purse',amount:3}]};
|
|
|
|
|
+ const preview=await plan(p),r=await post(p,'reverse-store-01',preview.version);assert.equal((await balances()).Purse,7);assert.equal(r.transactions[0].sourceTransactionId,src.objectId);assert.deepEqual(await post(p,'reverse-store-01',preview.version),r);
|
|
|
|
|
+ await assert.rejects(plan({...p,lines:[{account:'Purse',amount:8}]}),/原入账/);
|
|
|
|
|
+ await db.exec(`INSERT INTO "CourseCreditReservation"("objectId",company,"studentId","appointmentId","creditCosts",state) VALUES('held-store','co',10,'absent','[{"account":"Purse","amount":6}]','reserved')`);
|
|
|
|
|
+ await assert.rejects(plan(p),/预留/);await db.exec('DELETE FROM "CourseCreditReservation" WHERE "objectId"=\'held-store\'');
|
|
|
|
|
+ await one("SELECT xs_credit_post('co','root','transfer','student','Purse',6,'','测试划拨','运营记账','store-transfer-01')");
|
|
|
|
|
+ await assert.rejects(plan(p),/已划拨/);assert.equal((await balances()).Purse,1);
|
|
|
|
|
+});
|
|
|
|
|
+test('门店四类入账不改变学生充值配套与一次体验资格',async()=>{
|
|
|
|
|
+ const p={account:'SilverCoin',amount:2,direction:'add',payMethod:'运营记账'};
|
|
|
|
|
+ const v=(await one("SELECT xs_credit_plan('co','student',$1::jsonb) r",[JSON.stringify(p)])).r;
|
|
|
|
|
+ await one("SELECT xs_credit_operation('co','root','student',$1::jsonb,'学生正常充值','student-paired-01',$2)",[JSON.stringify(p),v.version]);
|
|
|
|
|
+ assert.equal((await balances('student')).UserPoint,2);assert.equal((await balances()).UserPoint,8.5);
|
|
|
|
|
+ await db.exec(`UPDATE "TrialEligibility" SET status='eligible' WHERE company='co' AND "studentId"=20`);
|
|
|
|
|
+ await one("SELECT xs_credit_post('co','root','transfer','student','UserExp',1,'','一次体验','运营记账','store-trial-01')");
|
|
|
|
|
+ await assert.rejects(one("SELECT xs_credit_post('co','root','transfer','student','UserExp',1,'','重复体验','运营记账','store-trial-02')"),/体验/);
|
|
|
|
|
+});
|
|
|
|
|
+test('旧版单账户请求跨发布重试复用原凭据,新版入口仍受独立授权约束',async()=>{
|
|
|
|
|
+ const {default:vm}=await import('node:vm');
|
|
|
|
|
+ const context={current:{id:'root'},isSuperAdmin:true},target={id:'store',get:key=>key==='company'?{id:'co'}:10};
|
|
|
|
|
+ const sandbox={Psql:{one,oneOrNone:async(s,p)=>(await query(s,p))[0]||null,query},number:(x,f=0)=>Number.isFinite(Number(x))?Number(x):f,text:x=>String(x??'').trim(),companyIdOf:()=> 'co',pointerId:x=>x.id,scopedStoreObject:async()=>target,reasonOf:input=>input.reason,enrollmentSql:one,fail:(status,message)=>{throw Object.assign(new Error(message),{status});}};
|
|
|
|
|
+ vm.createContext(sandbox);vm.runInContext(readFileSync(new URL('../cloud/store-credit.js',import.meta.url),'utf8'),sandbox);
|
|
|
|
|
+ const original=(await one("SELECT xs_credit_post('co','root','fund','store','Purse',1,'','旧版请求','运营记账','legacy-store-001') r")).r,before=await balances();
|
|
|
|
|
+ const result=await sandbox.storeCreditPost(context,{targetId:'store',reason:'旧版请求',idempotencyKey:'legacy-store-001'},{account:'Purse',amount:1,payMethod:'运营记账'});
|
|
|
|
|
+ assert.equal(result.objectId,original.objectId);assert.deepEqual(await balances(),before);
|
|
|
|
|
+ await assert.rejects(sandbox.storeCreditPost(context,{targetId:'store',reason:'旧版请求',idempotencyKey:'legacy-store-001'},{account:'Purse',amount:2,payMethod:'运营记账'}),/请求编号/);
|
|
|
|
|
+ await assert.rejects(sandbox.storeCreditPost({current:{id:'reader'}},{targetId:'store',reason:'未授权操作',idempotencyKey:'denied-0001'},payload),/权限/);
|
|
|
|
|
+});
|
|
|
|
|
+test('相同请求并发调用只记一笔业务',async()=>{
|
|
|
|
|
+ const preview=await plan(),before=await balances();const results=await Promise.all([post(payload,'parallel-store-01',preview.version),post(payload,'parallel-store-01',preview.version)]);
|
|
|
|
|
+ assert.deepEqual(results[0],results[1]);assert.equal((await balances()).Purse,before.Purse+10);
|
|
|
|
|
+ assert.equal(Number((await one('SELECT count(*) n FROM "StoreCreditOperation" WHERE "idempotencyKey"=\'parallel-store-01\'')).n),1);
|
|
|
|
|
+});
|