| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109 |
- import {test,before,after} from 'node:test';
- import assert from 'node:assert/strict';
- import {readFileSync} from 'node:fs';
- import vm from 'node:vm';
- import {PGlite} from '@electric-sql/pglite';
- const db=new PGlite(), query=async(sql,params=[])=>(await db.query(sql,params)).rows;
- 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);}};
- vm.createContext(ctx);vm.runInContext(readFileSync(new URL('../cloud/support-requests.js',import.meta.url),'utf8'),ctx);
- 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()=>{
- 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);
- 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=[
- ['new','a',1,'【意见反馈】学生甲','希望调整课程','2026-10-14T16:00:00Z',0,0,22],
- ['leave','a',2,'【休假申请】老师乙','10月16日休假一天','2026-10-15T15:59:59Z',0,0,22],
- ['old','a',1,'学生甲 反馈的意见','旧表单的休假申请内容','2026-10-14T15:59:59Z',0,99,22],
- ['missing','a',9,'原账号反馈','<script>alert(1)</script>','2026-10-16T00:00:00Z',0,0,22],
- ['other','b',1,'别的公司','不可见','2026-10-15T00:00:00Z',0,0,22],
- ['reply','a',1,'回复','不应算新申请','2026-10-15T00:00:00Z',123,99,22],
- ['deleted','a',1,'已删除','不可见','2026-10-15T00:00:00Z',0,-2,22],
- ['community','a',1,'社区留言','其他栏目','2026-10-15T00:00:00Z',0,99,10],
- ];
- for(const r of rows)await query(`INSERT INTO "Guestbook" VALUES($1,$2,$3,$4,$5,$6,$6,$7,$8,$9,'legacy')`,r);
- });
- after(()=>db.close());
- test('includes pending and historical requests; excludes replies, recycle bin, other categories and tenants',async()=>{
- const d=await ctx.supportRequestsPage(admin,{});
- assert.equal(d.total,4);assert.equal(d.summary.feedback,1);assert.equal(d.summary.leave,1);assert.equal(d.summary.legacy,2);
- assert.equal(d.items.find(r=>r.id==='old').kind,'legacy');
- assert.equal(d.items.find(r=>r.id==='new').role,'student');
- assert.equal(d.items.find(r=>r.id==='leave').role,'teacher');
- assert.equal(d.items.find(r=>r.id==='missing').role,'other');
- assert.equal(d.items.find(r=>r.id==='missing').content,'<script>alert(1)</script>');
- });
- test('role, type, account, content and literal search filters',async()=>{
- assert.equal((await ctx.supportRequestsPage(admin,{role:'student'})).total,2);
- assert.equal((await ctx.supportRequestsPage(admin,{role:'teacher',kind:'leave',search:'老师乙'})).total,1);
- assert.equal((await ctx.supportRequestsPage(admin,{search:'旧表单'})).total,1);
- assert.equal((await ctx.supportRequestsPage(admin,{search:"%' OR 1=1--"})).total,0);
- assert.equal((await ctx.supportRequestsPage(admin,{search:'%'})).total,0);
- });
- test('Shanghai inclusive dates and stable pagination; counts survive empty pages',async()=>{
- const p=await ctx.supportRequestsPage(admin,{dateFrom:'2026-10-15',dateTo:'2026-10-15',pageSize:1});
- assert.equal(p.total,2);assert.equal(p.items[0].id,'leave');
- assert.equal((await ctx.supportRequestsPage(admin,{dateFrom:'2026-10-15',dateTo:'2026-10-15',pageSize:1,page:2})).items[0].id,'new');
- const empty=await ctx.supportRequestsPage(admin,{page:99});assert.equal(empty.total,4);assert.equal(empty.items.length,0);
- });
- test('rejects missing tenant, unauthorized roles and invalid filters',async()=>{
- await assert.rejects(ctx.supportRequestsPage({...admin,company:null},{}),/帐套/);
- await assert.rejects(ctx.supportRequestsPage({...admin,role:'student'},{}),/forbidden/);
- for(const input of [{kind:'approved'},{role:'admin'},{dateFrom:'2026-02-30'},{dateFrom:'2026-10-20',dateTo:'2026-10-15'}]) await assert.rejects(ctx.supportRequestsPage(admin,input));
- assert.equal((await ctx.supportRequestsPage({...admin,role:'ops-auditor'},{})).total,4);
- });
- test('same user ID in another tenant cannot change submitter identity',async()=>{
- assert.equal((await ctx.supportRequestsPage({...admin,company:'b'},{})).items[0].accountName,'其他帐套');
- assert.equal((await ctx.supportRequestsPage(admin,{search:'其他帐套'})).total,0);
- });
- test('existing mobile save function persists typed and legacy submissions for this board',async()=>{
- const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
- let id=0;
- const saveCtx={...ctx,parseObject:v=>JSON.parse(v),ownLegacyId:()=>2,firstValue:(...vs)=>vs.find(v=>v!==undefined&&v!==null&&v!==''),cleanText:(v,n)=>String(v??'').trim().slice(0,n),safe:v=>v.fields,isAdminUser:()=>false,
- Parse:{Object:class {fields={};set(k,v){this.fields[k]=v;}async save(){const f=this.fields;this.fields.objectId='saved'+ ++id;await query(`INSERT INTO "Guestbook" VALUES($1,$2,$3,$4,$5,$6,$6,$7,$8,$9,$10)`,[f.objectId,f.company,f.userid,f.title,f.tcontent,f.gdate.toISOString(),f.parentid,f.status,f.cateid,f.sourceKey]);}}}};
- vm.createContext(saveCtx);vm.runInContext(source.slice(source.indexOf('async function createGuestbook('),source.indexOf('async function authorizeRequestedUser(')),saveCtx);
- for(const title of ['【休假申请】老师乙','老师乙 反馈的意见'])await saveCtx.createGuestbook({model:JSON.stringify({UserID:2,Title:title,TContent:'保存链路验收',Cateid:22})},{get:()=> 'a'});
- const d=await ctx.supportRequestsPage(admin,{search:'保存链路验收'});assert.equal(d.total,2);
- 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);
- });
|