|
@@ -5,15 +5,18 @@ import vm from 'node:vm';
|
|
|
import {PGlite} from '@electric-sql/pglite';
|
|
import {PGlite} from '@electric-sql/pglite';
|
|
|
|
|
|
|
|
const db=new PGlite(), query=async(sql,params=[])=>(await db.query(sql,params)).rows;
|
|
const db=new PGlite(), query=async(sql,params=[])=>(await db.query(sql,params)).rows;
|
|
|
-const ctx={Psql:{one:async(...args)=>(await query(...args))[0]},text:v=>String(v??'').trim(),
|
|
|
|
|
- number:(v,d=0)=>Number.isFinite(Number(v))?Number(v):d,companyIdOf:c=>c.company,
|
|
|
|
|
|
|
+const ctx={Psql:{query,one:async(...args)=>(await query(...args))[0]},text:v=>String(v??'').trim(),
|
|
|
|
|
+ number:(v,d=0)=>Number.isFinite(Number(v))?Number(v):d,companyIdOf:c=>c.company,roleOf:c=>c.role,ownLegacyId:c=>c.uid,
|
|
|
requireRole:(c,roles)=>{if(!roles.includes(c.role))throw Error('forbidden');},fail:(_,m)=>{throw Error(m);}};
|
|
requireRole:(c,roles)=>{if(!roles.includes(c.role))throw Error('forbidden');},fail:(_,m)=>{throw Error(m);}};
|
|
|
vm.createContext(ctx);vm.runInContext(readFileSync(new URL('../cloud/support-requests.js',import.meta.url),'utf8'),ctx);
|
|
vm.createContext(ctx);vm.runInContext(readFileSync(new URL('../cloud/support-requests.js',import.meta.url),'utf8'),ctx);
|
|
|
-const admin={role:'ops-staff',company:'a'};
|
|
|
|
|
|
|
+vm.runInContext(readFileSync(new URL('../cloud/support-requests-app.js',import.meta.url),'utf8'),ctx);
|
|
|
|
|
+const admin={role:'ops-staff',company:'a',current:{id:'admin',get:()=> '管理员'}};
|
|
|
|
|
+const member=(uid,company='a')=>({uid,get:()=>({id:company})});
|
|
|
before(async()=>{
|
|
before(async()=>{
|
|
|
await db.exec(`CREATE TABLE "Guestbook"("objectId" text,company text,userid numeric,title text,tcontent text,gdate timestamptz,"createdAt" timestamptz,parentid numeric,status numeric,cateid numeric,"sourceKey" text);
|
|
await db.exec(`CREATE TABLE "Guestbook"("objectId" text,company text,userid numeric,title text,tcontent text,gdate timestamptz,"createdAt" timestamptz,parentid numeric,status numeric,cateid numeric,"sourceKey" text);
|
|
|
CREATE TABLE "_User"("objectId" text,company text,"legacyUserId" numeric,"legacyGroupId" numeric,"identityType" text,"legacyUserData" jsonb,"realName" text,nickname text,username text,"createdAt" timestamptz);
|
|
CREATE TABLE "_User"("objectId" text,company text,"legacyUserId" numeric,"legacyGroupId" numeric,"identityType" text,"legacyUserData" jsonb,"realName" text,nickname text,username text,"createdAt" timestamptz);
|
|
|
INSERT INTO "_User" VALUES('s','a',1,1,'member','{}','学生甲','','','2026-10-01'),('t','a',2,3,'coach','{}','老师乙','','','2026-10-01'),('other','b',1,3,'coach','{}','其他帐套','','','2026-10-01');`);
|
|
INSERT INTO "_User" VALUES('s','a',1,1,'member','{}','学生甲','','','2026-10-01'),('t','a',2,3,'coach','{}','老师乙','','','2026-10-01'),('other','b',1,3,'coach','{}','其他帐套','','','2026-10-01');`);
|
|
|
|
|
+ await db.exec(readFileSync(new URL('../sql/support-requests.sql',import.meta.url),'utf8'));
|
|
|
const rows=[
|
|
const rows=[
|
|
|
['new','a',1,'【意见反馈】学生甲','希望调整课程','2026-10-14T16:00:00Z',0,0,22],
|
|
['new','a',1,'【意见反馈】学生甲','希望调整课程','2026-10-14T16:00:00Z',0,0,22],
|
|
|
['leave','a',2,'【休假申请】老师乙','10月16日休假一天','2026-10-15T15:59:59Z',0,0,22],
|
|
['leave','a',2,'【休假申请】老师乙','10月16日休假一天','2026-10-15T15:59:59Z',0,0,22],
|
|
@@ -69,3 +72,38 @@ test('existing mobile save function persists typed and legacy submissions for th
|
|
|
const d=await ctx.supportRequestsPage(admin,{search:'保存链路验收'});assert.equal(d.total,2);
|
|
const d=await ctx.supportRequestsPage(admin,{search:'保存链路验收'});assert.equal(d.total,2);
|
|
|
assert.deepEqual(d.items.map(r=>r.kind).sort(),['leave','legacy']);
|
|
assert.deepEqual(d.items.map(r=>r.kind).sort(),['leave','legacy']);
|
|
|
});
|
|
});
|
|
|
|
|
+
|
|
|
|
|
+test('staff resolves feedback once; owner receives unread reply; cross-owner and cross-tenant cannot acknowledge',async()=>{
|
|
|
|
|
+ const row=(await ctx.supportRequestsPage(admin,{})).items.find(r=>r.id==='new');
|
|
|
|
|
+ const input={targetId:row.id,version:row.version,status:'processed',comment:'已调整提醒设置'};
|
|
|
|
|
+ await assert.rejects(ctx.supportRequestProcess({...admin,role:'ops-auditor'},input),/forbidden/);
|
|
|
|
|
+ await assert.rejects(ctx.supportRequestProcess({...admin,company:'b'},input),/不存在/);
|
|
|
|
|
+ await assert.rejects(ctx.supportRequestProcess(admin,{...input,version:'stale'}),/变化/);
|
|
|
|
|
+ await assert.rejects(ctx.supportRequestProcess(admin,{...input,status:'approved'}),/类型/);
|
|
|
|
|
+ await assert.rejects(ctx.supportRequestProcess(admin,{...input,comment:' '}),/处理意见/);
|
|
|
|
|
+ const done=await ctx.supportRequestProcess(admin,input);
|
|
|
|
|
+ assert.equal(done.status,'processed');assert.equal(done.actor,'admin');
|
|
|
|
|
+ const again=await ctx.supportRequestProcess(admin,input);assert.equal(again.handledAt,done.handledAt);
|
|
|
|
|
+ await assert.rejects(ctx.supportRequestProcess(admin,{...input,comment:'覆盖原意见'}),/已处理/);
|
|
|
|
|
+ let own=await ctx.appSupportRequests({uid:2},member(1));assert.equal(own.unread,1);assert.equal(own.items[0].response,input.comment);
|
|
|
|
|
+ assert.ok(own.items.every(r=>r.id!=='leave'));assert.equal((await ctx.appSupportRequests({},member(2))).unread,0);
|
|
|
|
|
+ await assert.rejects(ctx.appSupportRequests({action:'app_support_request_read',id:'new'},member(2)),/不属于/);
|
|
|
|
|
+ await assert.rejects(ctx.appSupportRequests({action:'app_support_request_read',id:'new'},member(1,'b')),/不属于/);
|
|
|
|
|
+ await ctx.appSupportRequests({action:'app_support_request_read',id:'new'},member(1));
|
|
|
|
|
+ await ctx.appSupportRequests({action:'app_support_request_read',id:'new'},member(1));
|
|
|
|
|
+ assert.equal((await ctx.appSupportRequests({},member(1))).unread,0);
|
|
|
|
|
+ assert.ok((await ctx.supportRequestsPage(admin,{status:'processed'})).items[0].readAt);
|
|
|
|
|
+ assert.equal((await ctx.supportRequestsPage({...admin,role:'ops-auditor'},{})).canProcess,false);
|
|
|
|
|
+});
|
|
|
|
|
+test('leave approval or refusal is atomic and cannot be marked as ordinary feedback',async()=>{
|
|
|
|
|
+ const row=(await ctx.supportRequestsPage(admin,{})).items.find(r=>r.id==='leave');
|
|
|
|
|
+ const input={targetId:row.id,version:row.version,status:'approved',comment:'同意,已安排代课'};
|
|
|
|
|
+ await assert.rejects(ctx.supportRequestProcess(admin,{...input,status:'processed'}),/类型/);
|
|
|
|
|
+ const results=await Promise.allSettled([ctx.supportRequestProcess(admin,input),ctx.supportRequestProcess(admin,{...input,status:'rejected',comment:'冲突操作'})]);
|
|
|
|
|
+ assert.equal(results.filter(r=>r.status==='fulfilled').length,1);
|
|
|
|
|
+ assert.equal((await ctx.appSupportRequests({},member(2))).items.find(r=>r.id==='leave').status,'approved');
|
|
|
|
|
+ const other=(await ctx.supportRequestsPage(admin,{search:'保存链路验收'})).items.find(r=>r.kind==='leave');
|
|
|
|
|
+ await ctx.supportRequestProcess(admin,{targetId:other.id,version:other.version,status:'rejected',comment:'请补充休假时间后重新申请'});
|
|
|
|
|
+ assert.equal((await ctx.supportRequestsPage(admin,{status:'rejected'})).total,1);
|
|
|
|
|
+ assert.equal((await ctx.appSupportRequests({},member(2))).unread,2);
|
|
|
|
|
+});
|