// Run on test B from a staged release, with api.env loaded. No public telephone destinations. import { PrismaClient } from '@prisma/client'; import { signAccessToken } from '../../packages/auth/dist/index.js'; import { spawnSync } from 'node:child_process'; import { writeFile } from 'node:fs/promises'; import assert from 'node:assert/strict'; import { CallerAnalyticsStore, COUNT_KEYS } from '../../packages/database/dist/index.js'; const db = new PrismaClient(); const fixture = 'cra_20260831'; const user = await db.user.findFirst({ where:{ status:'ENABLED', userRoles:{some:{roleId:'ROLE_SUPER_ADMIN'}} } }); assert(user, 'An existing test administrator is required'); const token = signAccessToken({sub:user.id,username:user.username,roles:['ROLE_SUPER_ADMIN'],typ:'access'}, {secret:process.env.AUTH_ACCESS_TOKEN_SECRET,issuer:process.env.AUTH_TOKEN_ISSUER||'lisglosips-api',audience:process.env.AUTH_TOKEN_AUDIENCE||'lisglosips-web',ttlSeconds:900}); const get = async (path, authenticated = true, authToken = token) => { const response = await fetch(`http://127.0.0.1:3000/api/v2${path}`, {headers:authenticated ? {Authorization:`Bearer ${authToken}`} : {}}); return {status:response.status,body:await response.json()}; }; try { if (process.argv.includes('--setup')) { assert.equal(await db.customerGateway.count({where:{sourceIp:'127.0.0.1',id:{not:fixture}}}),0,'Loopback gateway already in use'); await db.$transaction(async tx => { await tx.customer.upsert({where:{id:fixture},create:{id:fixture,name:'主叫分析受控测试客户',balance:100,creditLimit:100},update:{status:'ENABLED'}}); await tx.vendor.upsert({where:{id:fixture},create:{id:fixture,name:'主叫分析本机模拟供应商'},update:{status:'ENABLED'}}); await tx.vendorGateway.upsert({where:{id:fixture},create:{id:fixture,vendorId:fixture,name:'主叫分析本机模拟落地',authMode:'IP',host:'127.0.0.1',port:50630,cycleRate:0},update:{status:'ENABLED'}}); await tx.landingLineGroup.upsert({where:{id:fixture},create:{id:fixture,name:'主叫分析本机测试线路组'},update:{status:'ENABLED'}}); await tx.landingLineGroupItem.upsert({where:{id:fixture},create:{id:fixture,lineGroupId:fixture,vendorGatewayId:fixture,priority:1},update:{status:'ENABLED'}}); await tx.customerGateway.upsert({where:{id:fixture},create:{id:fixture,customerId:fixture,name:'主叫分析本机测试入口',authMode:'IP',sourceIp:'127.0.0.1',lineGroupId:fixture,cycleRate:0},update:{status:'ENABLED'}}); await tx.outboxEvent.create({data:{id:`cra_${Date.now()}`,aggregateType:'customer_gateway_config',aggregateId:fixture,eventType:'CONFIG_CHANGED',payload:{reason:'isolated caller analytics test fixture'}}}); }); console.log('Loopback-only zero-rate fixture prepared; waiting for normal config publisher.'); } else if (process.argv.includes('--disable')) { await db.customerGateway.update({where:{id:fixture},data:{status:'DISABLED'}}); await db.vendorGateway.update({where:{id:fixture},data:{status:'DISABLED'}}); await db.outboxEvent.create({data:{id:`cra_${Date.now()}`,aggregateType:'customer_gateway_config',aggregateId:fixture,eventType:'CONFIG_CHANGED',payload:{reason:'disable isolated test fixture'}}}); console.log('Isolated test ingress and egress disabled; evidence retained.'); } else { const from = new Date().toISOString(); const sip = spawnSync('python3',['tests/api/caller-analytics-sip.py'],{encoding:'utf8',timeout:90000}); assert.equal(sip.status,0,sip.stderr || sip.stdout); console.log(sip.stdout); let result; for (let i=0;i<12;i++) { result = await get(`/caller-analytics/overview?customerId=${fixture}&view=landing&from=${encodeURIComponent(from)}`); if (result.status===200 && result.body.summary.totalCalls===6 && result.body.summary.activeCalls===0) break; await new Promise(r=>setTimeout(r,1000)); } assert.equal(result.status,200,JSON.stringify(result.body)); assert.deepEqual(Object.fromEntries(['totalCalls','connectedCalls','answeredCalls','failedCalls'].map(k=>[k,result.body.summary[k]])),{totalCalls:6,connectedCalls:4,answeredCalls:2,failedCalls:2}); assert.equal(result.body.analysisRevision,'caller-analytics-r2'); assert.equal(result.body.coverageStatus,'CONTINUOUS',JSON.stringify(result.body.health)); assert.equal(result.body.summary.durationUnknownCalls,0); assert.equal((await get('/caller-analytics/overview',false)).status,401); assert.equal((await get('/caller-analytics/overview?view=invalid')).status,400); assert.equal(result.body.summary.connectedAnswerRate,50); const detail = await get(`/caller-analytics/calls?customerId=${fixture}&caller=99100001&view=landing&from=${encodeURIComponent(from)}`); assert.equal(detail.status,200); assert.equal(detail.body.rows.length,6); const short = detail.body.rows.find(r=>r.callee==='99105'); assert(short.counts.talkMs>0 && short.counts.talkMs<1000,'Subsecond duration must count as answer'); const auditFrom = new Date(Date.now()-86400000).toISOString(); const auditPath = `/caller-analytics/overview?customerId=${fixture}&view=landing&from=${encodeURIComponent(auditFrom)}`; const beforeReplay = await get(auditPath); assert.equal(beforeReplay.status,200); const stateRows = await db.$queryRaw`SELECT counts FROM caller_analysis_states WHERE customer_id=${fixture} AND view='landing' AND started_at>=${new Date(auditFrom)}`; for (const key of COUNT_KEYS) assert.equal(beforeReplay.body.summary[key],stateRows.reduce((sum,row)=>sum+row.counts[key],0),`Minute bucket reconciliation: ${key}`); const events = await db.$queryRaw`SELECT payload FROM caller_analysis_events WHERE call_key=${detail.body.rows[0].callKey}`; const store = new CallerAnalyticsStore(db); for (const event of events) assert.equal(await store.consume(event.payload),'duplicate'); assert.deepEqual((await get(auditPath)).body.summary,beforeReplay.body.summary,'Replay must not change counts'); const testUserId = `cra_rbac_${Date.now()}`; try { await db.role.create({data:{id:testUserId,name:testUserId,permissions:{create:{permissionId:'caller_analytics.view'}}}}); await db.user.create({data:{id:testUserId,username:testUserId,displayName:'临时主叫范围验收',userRoles:{create:{roleId:testUserId}}}}); const scopedToken=signAccessToken({sub:testUserId,username:testUserId,roles:[],typ:'access'}, {secret:process.env.AUTH_ACCESS_TOKEN_SECRET,issuer:process.env.AUTH_TOKEN_ISSUER||'lisglosips-api',audience:process.env.AUTH_TOKEN_AUDIENCE||'lisglosips-web',ttlSeconds:60}); assert.equal((await get(auditPath,true,scopedToken)).status,403,'No assigned customers must fail closed'); await db.$executeRaw`INSERT INTO caller_analysis_access(user_id,customer_id) VALUES (${testUserId},${fixture})`; const scoped = await get('/caller-analytics/overview?minutes=1440',true,scopedToken); assert.equal(scoped.status,200); assert(scoped.body.numbers.every(r=>r.customerId===fixture)); assert.equal((await get('/caller-analytics/overview?customerId=another_customer',true,scopedToken)).status,403); const options=await get('/caller-analytics/options',true,scopedToken); assert.equal(options.status,200); assert.deepEqual(options.body.customers.map(c=>c.id),[fixture]); assert.deepEqual(options.body.vendors.map(v=>v.id),[fixture]); } finally { await db.$executeRaw`DELETE FROM caller_analysis_access WHERE user_id=${testUserId}`; await db.user.deleteMany({where:{id:testUserId}}); await db.role.deleteMany({where:{id:testUserId}}); } const sipEvents=JSON.parse(sip.stdout); assert.equal(sipEvents.filter(e=>e.direction==='UAC'&&e.code===200&&e.sdp).length,2,'Two successful calls must negotiate SDP through RTPEngine'); const evidence={date:new Date().toISOString(),from,api:result,detail:detail.body,sip:sipEvents,bucketAudit:beforeReplay.body.summary,replayed:events.length,tests:['real SIP six scenarios','two SDP offer/answer negotiations through RTPEngine','ACK-based positive duration including subsecond call','real API counts, completeness and three rates','real DB states and minute buckets','database replay idempotency','customer scope and 403 isolation','scoped customer/vendor filter options','unauthenticated 401','invalid query 400']}; await writeFile(process.env.CRA_REPORT || '/tmp/caller-analytics-real-evidence.json',JSON.stringify(evidence,null,2)); console.log(JSON.stringify({passed:true,summary:result.body.summary,shortMs:short.counts.talkMs,quality:result.body.qualityStatus})); } } finally { await db.$disconnect(); }