feat: add caller connection and answer rate analytics
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
// 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';
|
||||
const db = new PrismaClient();
|
||||
const fixture = 'cra_20260831';
|
||||
const user = await db.user.findFirst({ where:{ status:'ENABLED', roles:{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) => {
|
||||
const response = await fetch(`http://127.0.0.1:3000/api/v2${path}`, {headers:authenticated ? {Authorization:`Bearer ${token}`} : {}});
|
||||
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 evidence={date:new Date().toISOString(),from,api:result,detail:detail.body,sip:JSON.parse(sip.stdout),tests:['real SIP six scenarios','real API counts and three rates','real DB states','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(); }
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Isolated UDP SIP UAC/UAS on test B. No PSTN routing or remote targets."""
|
||||
import json
|
||||
import re
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
events = []
|
||||
stop = threading.Event()
|
||||
|
||||
def header(message, name):
|
||||
match = re.search(r'^' + re.escape(name) + r':\s*(.+)$', message, re.I | re.M)
|
||||
return match.group(1).strip() if match else ''
|
||||
|
||||
def reply(message, code, reason):
|
||||
to = header(message, 'To')
|
||||
if ';tag=' not in to:
|
||||
to += ';tag=cra-uas'
|
||||
vias = re.findall(r'^Via:\s*(.+)$', message, re.I | re.M)
|
||||
routes = re.findall(r'^Record-Route:\s*(.+)$', message, re.I | re.M)
|
||||
lines = [f'SIP/2.0 {code} {reason}'] + [f'Via: {v.strip()}' for v in vias]
|
||||
lines += [f'From: {header(message,"From")}', f'To: {to}', f'Call-ID: {header(message,"Call-ID")}', f'CSeq: {header(message,"CSeq")}']
|
||||
lines += [f'Record-Route: {r.strip()}' for r in routes]
|
||||
lines += ['Contact: <sip:uas@127.0.0.1:50630>', 'Content-Length: 0', '', '']
|
||||
return '\r\n'.join(lines).encode()
|
||||
|
||||
def uas():
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.bind(('127.0.0.1',50630)); sock.settimeout(.2)
|
||||
while not stop.is_set():
|
||||
try: data, addr = sock.recvfrom(65535)
|
||||
except socket.timeout: continue
|
||||
msg = data.decode(); method = msg.split()[0]
|
||||
if method == 'INVITE':
|
||||
number = re.search(r'INVITE sip:([^@]+)',msg).group(1)
|
||||
cases = {'99101':[(180,'Ringing'),(486,'Busy Here')], '99102':[(183,'Session Progress'),(487,'Request Terminated')], '99103':[(486,'Busy Here')], '99104':[(200,'OK')], '99105':[(180,'Ringing'),(200,'OK')], '99106':[(100,'Trying'),(486,'Busy Here')]}
|
||||
for code, reason in cases[number]:
|
||||
sock.sendto(reply(msg,code,reason),addr)
|
||||
events.append({'callId':header(msg,'Call-ID'),'direction':'UAS','code':code,'time':time.time()})
|
||||
time.sleep(.15 if number!='99106' else 1.5)
|
||||
elif method == 'BYE': sock.sendto(reply(msg,200,'OK'),addr)
|
||||
|
||||
thread = threading.Thread(target=uas,daemon=True); thread.start(); time.sleep(.3)
|
||||
try:
|
||||
for number in ['99101','99102','99103','99104','99105','99106']:
|
||||
with socket.socket(socket.AF_INET,socket.SOCK_DGRAM) as sock:
|
||||
sock.bind(('127.0.0.1',0)); sock.settimeout(8); port=sock.getsockname()[1]
|
||||
callid=f'cra-{number}-{uuid.uuid4().hex}@loopback'; tag=uuid.uuid4().hex[:8]
|
||||
branch='z9hG4bK'+uuid.uuid4().hex
|
||||
base=[f'Via: SIP/2.0/UDP 127.0.0.1:{port};branch={branch};rport', f'From: <sip:99100001@127.0.0.1>;tag={tag}', f'To: <sip:{number}@127.0.0.1>', f'Call-ID: {callid}', f'Contact: <sip:99100001@127.0.0.1:{port}>', 'Max-Forwards: 70']
|
||||
invite='\r\n'.join([f'INVITE sip:{number}@127.0.0.1:15060 SIP/2.0',*base,'CSeq: 1 INVITE','Content-Length: 0','',''])
|
||||
sock.sendto(invite.encode(),('127.0.0.1',15060))
|
||||
while True:
|
||||
msg=sock.recv(65535).decode(); code=int(msg.split()[1]); events.append({'callId':callid,'direction':'UAC','code':code,'time':time.time()})
|
||||
if code<200: continue
|
||||
if code>=300:
|
||||
ack=invite.replace('INVITE sip:','ACK sip:',1).replace('CSeq: 1 INVITE','CSeq: 1 ACK').replace(base[2],f'To: {header(msg,"To")}')
|
||||
sock.sendto(ack.encode(),('127.0.0.1',15060)); break
|
||||
routes = list(reversed(re.findall(r'^Record-Route:\s*(.+)$',msg,re.I|re.M)))
|
||||
for method,cseq in [('ACK',1),('BYE',2)]:
|
||||
if method=='BYE': time.sleep(.3 if number=='99105' else 1.5)
|
||||
seq=[f'{method} sip:uas@127.0.0.1:50630 SIP/2.0',f'Via: SIP/2.0/UDP 127.0.0.1:{port};branch=z9hG4bK{uuid.uuid4().hex};rport',base[1],f'To: {header(msg,"To")}',base[3],*['Route: '+r.strip() for r in routes],f'CSeq: {cseq} {method}','Max-Forwards: 70','Content-Length: 0','','']
|
||||
sock.sendto('\r\n'.join(seq).encode(),('127.0.0.1',15060))
|
||||
if method=='BYE':
|
||||
final=sock.recv(65535).decode(); assert final.startswith('SIP/2.0 200'), final
|
||||
break
|
||||
time.sleep(.3)
|
||||
finally:
|
||||
stop.set(); thread.join(timeout=2)
|
||||
print(json.dumps(events))
|
||||
Reference in New Issue
Block a user