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,'原账号反馈','','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,''); }); 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); });