import test from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import crypto from 'node:crypto'; import { createApplication } from '../src/core.mjs'; import { attachMemberGroupHandlers, readMemberGroupBoard } from '../src/member-groups.mjs'; const data = JSON.parse(fs.readFileSync(new URL('../references/runtime-data.json',import.meta.url))); function fixture() { let rows={KsfRuntimeConfig:[{objectId:'runtime',key:'runtime',value:{signingSecret:'PRIVATE',wecomDirectory:{secret:'PRIVATE'}}}]},seq=0,queue=Promise.resolve(); const clone=structuredClone; const store={ async transaction(fn) { const exec=async()=>{const before=clone(rows);try{return await fn(store);}catch(e){rows=before;throw e;}};const result=queue.then(exec);queue=result.catch(()=>{});return result; }, async lock(){}, async list(name,{where={}}={}) {return clone((rows[name]||[]).filter((r)=>Object.entries(where).every(([k,v])=>r[k]===v)));}, async create(name,value){const row={...clone(value),objectId:'object'+ ++seq};(rows[name]||=[]).push(row);return clone(row);}, async update(name,id,changes){const row=rows[name].find((r)=>r.objectId===id);Object.assign(row,clone(changes));return clone(row);}, }; const board={session:[{objectId:'s1',studentName:'同名',groupId:'G1',meta:{participantKey:'participant:one'}},{objectId:'s2',studentName:'同名',groupId:'G2',meta:{participantKey:'participant:two'}}],artifact:[],project:[],opportunity:[]}; const app=createApplication({store,crypto,Parse:{},data,settings:{signingSecret:'test'},files:{}}); attachMemberGroupHandlers(app,{store,loadBoard:async()=>board}); const call=async(name,input={})=>(await app.dispatch(name,input,null)).data; const change=(revision,command,requestId=crypto.randomUUID())=>call('ksfManagePublicMemberGroups',{requestId,expectedRevision:revision,...command}); return {store,board,call,change}; } test('公开分组只读不会写库或泄露运行配置,首次沿用现有组号',async()=>{ const f=fixture(),r=await f.call('ksfGetPublicMemberGroups'); assert.equal(r.revision,0);assert.equal(r.groups.length,2);assert.equal(r.memberCount,2); assert.equal((await f.store.list('KsfRuntimeConfig')).length,1);assert.ok(!JSON.stringify(r).includes('PRIVATE')); }); test('共享分组创建/批量分配/重命名/删除持久化且不改业务会话',async()=>{ const f=fixture(),before=JSON.stringify(f.board); let r=await f.change(0,{action:'create',name:'研发组'});const id=r.groups.at(-1).id; r=await f.change(r.revision,{action:'assign',groupId:id,memberIds:['participant:one','participant:two']}); assert.equal(Object.values(r.assignments).filter((v)=>v===id).length,2); r=await f.change(r.revision,{action:'rename',groupId:id,name:'创新组'});assert.equal(r.groups.at(-1).name,'创新组'); r=await f.change(r.revision,{action:'delete',groupId:id});assert.deepEqual(r.assignments,{}); assert.equal(JSON.stringify(f.board),before);assert.equal((await f.call('ksfGetPublicMemberGroups')).revision,4); assert.equal((await f.store.list('KsfOperation')).length,4);assert.equal((await f.store.list('KsfRuntimeConfig')).find((x)=>x.key==='runtime').value.signingSecret,'PRIVATE'); }); test('同请求幂等、旧版本和并发更新不会覆盖其他人的更改',async()=>{ const f=fixture(),command={action:'create',name:'研发组'}; const first=await f.change(0,command,'same');assert.deepEqual(await f.change(0,command,'same'),first); await assert.rejects(()=>f.change(0,{...command,name:'另一组'},'same'),{code:'IDEMPOTENCY_CONFLICT'}); const results=await Promise.allSettled([f.change(1,{action:'create',name:'A组'}),f.change(1,{action:'create',name:'B组'})]); assert.equal(results.filter((r)=>r.status==='fulfilled').length,1);assert.equal(results.find((r)=>r.status==='rejected').reason.code,'VERSION_CONFLICT'); assert.equal((await f.store.list('KsfOperation')).length,2); }); test('隐藏成员、越权参数、重复名称和部分无效批次全部拒绝且不留半批结果',async()=>{ const f=fixture(); for(const command of [{action:'create',name:'G1'},{action:'create',name:'G1',groupId:'legacy-group:G1'}, {action:'assign',groupId:'legacy-group:G1',memberIds:['participant:one','private:unknown']}, {action:'assign',groupId:null,memberIds:['participant:one'],role:'admin'}]) await assert.rejects(()=>f.change(0,command),{code:'VALIDATION_FAILED'}); assert.equal((await f.call('ksfGetPublicMemberGroups')).revision,0);assert.equal((await f.store.list('KsfOperation')).length,0); }); test('公开成员读取完整分页且不带服务凭据,读取失败不当空成员',async()=>{ let count=0; const board=await readMemberGroupBoard({url:'/fixture',appId:'public',fetchImpl:async(url,options)=>{ count++;assert.deepEqual(Object.keys(options.headers).sort(),['Content-Type','X-Parse-Application-Id']); const cursor=JSON.parse(options.body).params.cursor; return {ok:true,json:async()=>({result:{ok:true,data:{session:[{objectId:cursor?'s2':'s1'}],project:[],artifact:[],opportunity:[],nextCursor:cursor?null:'next'}}})}; }});assert.equal(count,2);assert.equal(board.session.length,2); await assert.rejects(()=>readMemberGroupBoard({url:'/fixture',appId:'public',fetchImpl:async()=>({ok:false,json:async()=>({})})}),{code:'MEMBER_READ_FAILED'}); });