85 lines
7.9 KiB
JavaScript
85 lines
7.9 KiB
JavaScript
// 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((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]);
|
|
} 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 evidence={date:new Date().toISOString(),from,api:result,detail:detail.body,sip:JSON.parse(sip.stdout),bucketAudit:beforeReplay.body.summary,replayed:events.length,tests:['real SIP six scenarios','real API counts and three rates','real DB states and minute buckets','database replay idempotency','customer scope and 403 isolation','scoped filter options','unauthenticated 401','invalid query 400','positive subsecond duration']};
|
|
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(); }
|