The gateway was the composition root, device registry, inbound-call policy, and call-operations service in one class, with core↔services circular imports papered over by function-local imports, wiring done by assigning private attributes, and MCP tools duplicating REST query logic against their own sessions. Composition: - main.py's lifespan now builds every service and wires them by constructor/registration. gateway.from_config() is gone; core/ no longer imports services/ anywhere — the cycle is dead. - Inbound-call policy moved to ReceptionistService.on_inbound_call (routing evaluation, reject/answer, screening dispatch); wired as the engine's on_incoming_call by the lifespan. Receptionist deps (tts/transcription/recording/routing) are constructor-injected — no more gateway._tts reach-through or importing hold_slayer's private _get_llm (now services.llm_client.get_llm, shared). - Hold-slayer launch goes through a mode-handler registry (register_mode_handler); the gateway no longer knows the service's type. CallManager takes on_call_ended in its constructor. - build_sip_engine() is a pure function taking explicit callbacks. - api/routing.py uses the routing service from app.state via a proper dependency instead of gateway._routing. Shared data layer: - db.session_scope() is the one session convention (get_db wraps it). - services/call_persistence.py gains the query/write functions and the single StoredCallFlow→CallFlow mapper; api/call_flows.py, api/call_history.py, and the six DB-touching MCP tools are thin wrappers over them — the two surfaces can't drift. - legs_for_call() replaces the three private _call_legs scans (gateway transfer/hangup, REST dtmf, MCP dtmf). 7 new tests (mode-handler launch, on_call_ended hook, receptionist inbound answer/reject, call-flow CRUD round-trip and history routes against real SQLite through the shared layer). aiosqlite added to dev deps for that. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
228 lines
7.7 KiB
Python
228 lines
7.7 KiB
Python
"""
|
|
Composition and API-surface tests.
|
|
|
|
Covers the gateway composed the way main.py's lifespan composes it
|
|
(mode handlers, on_call_ended hook, receptionist-owned inbound
|
|
policy) and the REST routes running against a real (SQLite) database
|
|
through the shared data layer in services/call_persistence.py.
|
|
"""
|
|
|
|
import httpx
|
|
import pytest
|
|
from pydantic import SecretStr
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
import main
|
|
from config import ReceptionistSettings, Settings, get_settings
|
|
from core.gateway import AIPSTNGateway
|
|
from db.database import Base, CallRecord, get_db
|
|
from models.call import CallMode, CallStatus
|
|
from models.routing import RoutingAction, RoutingActionType, RoutingDecision
|
|
from services.receptionist import ReceptionistService
|
|
|
|
# ================================================================
|
|
# Gateway composition
|
|
# ================================================================
|
|
|
|
class TestGatewayComposition:
|
|
async def test_mode_handler_launches_per_call(self):
|
|
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
|
launched: list[tuple] = []
|
|
gateway.register_mode_handler(
|
|
CallMode.HOLD_SLAYER,
|
|
lambda call, leg_id, flow_id: launched.append((call.id, leg_id, flow_id)),
|
|
)
|
|
|
|
call = await gateway.make_call("+15551234567", mode=CallMode.HOLD_SLAYER,
|
|
call_flow_id="acme-main")
|
|
|
|
assert launched == [(call.id, gateway.call_manager.legs_for_call(call.id)[0], "acme-main")]
|
|
|
|
async def test_direct_mode_needs_no_handler(self):
|
|
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
|
call = await gateway.make_call("+15551234567")
|
|
assert call.status == CallStatus.RINGING
|
|
|
|
async def test_on_call_ended_hook_from_constructor(self):
|
|
ended: list[tuple] = []
|
|
|
|
async def hook(call, status):
|
|
ended.append((call.id, status))
|
|
|
|
gateway = AIPSTNGateway(
|
|
settings=Settings(max_concurrent_calls=4), on_call_ended=hook
|
|
)
|
|
call = await gateway.make_call("+15551234567")
|
|
await gateway.hangup_call(call.id)
|
|
|
|
assert ended == [(call.id, CallStatus.COMPLETED)]
|
|
|
|
|
|
# ================================================================
|
|
# Receptionist-owned inbound policy
|
|
# ================================================================
|
|
|
|
class _StubRouting:
|
|
def __init__(self, decision):
|
|
self._decision = decision
|
|
|
|
async def evaluate(self, caller_number, dnis):
|
|
return self._decision
|
|
|
|
|
|
class TestInboundPolicy:
|
|
def _gateway(self) -> AIPSTNGateway:
|
|
settings = Settings(max_concurrent_calls=4)
|
|
settings.receptionist = ReceptionistSettings(enabled=False)
|
|
return AIPSTNGateway(settings=settings)
|
|
|
|
async def test_inbound_call_answered_and_tracked(self):
|
|
gateway = self._gateway()
|
|
receptionist = ReceptionistService(gateway)
|
|
|
|
await receptionist.on_inbound_call(
|
|
"sip:+16135550100@pstn", "sip:+15551234567@gw", "leg_in1"
|
|
)
|
|
|
|
calls = list(gateway.call_manager.active_calls.values())
|
|
assert len(calls) == 1
|
|
call = calls[0]
|
|
assert call.direction == "inbound"
|
|
assert call.remote_number == "+16135550100"
|
|
assert call.status == CallStatus.CONNECTED
|
|
assert gateway.call_manager.legs_for_call(call.id) == ["leg_in1"]
|
|
|
|
async def test_reject_rule_declines_before_answer(self):
|
|
gateway = self._gateway()
|
|
decision = RoutingDecision(
|
|
action=RoutingAction(type=RoutingActionType.REJECT),
|
|
matched_rule_id="rule_x",
|
|
matched_rule_name="block",
|
|
reason="matched rule 'block'",
|
|
)
|
|
receptionist = ReceptionistService(gateway, routing=_StubRouting(decision))
|
|
|
|
await receptionist.on_inbound_call(
|
|
"sip:+18005550100@pstn", "sip:+15551234567@gw", "leg_in2"
|
|
)
|
|
|
|
assert gateway.call_manager.active_calls == {}
|
|
|
|
|
|
# ================================================================
|
|
# REST routes on the shared data layer (real SQLite)
|
|
# ================================================================
|
|
|
|
@pytest.fixture
|
|
async def client(monkeypatch):
|
|
monkeypatch.setattr(get_settings(), "api_token", SecretStr(""))
|
|
|
|
engine = create_async_engine(
|
|
"sqlite+aiosqlite:///:memory:",
|
|
poolclass=StaticPool,
|
|
connect_args={"check_same_thread": False},
|
|
)
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
|
|
async def _get_db():
|
|
async with factory() as session:
|
|
try:
|
|
yield session
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
raise
|
|
|
|
main.app.dependency_overrides[get_db] = _get_db
|
|
transport = httpx.ASGITransport(app=main.app)
|
|
async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
|
|
c.db_factory = factory
|
|
yield c
|
|
main.app.dependency_overrides.pop(get_db, None)
|
|
await engine.dispose()
|
|
|
|
|
|
FLOW_PAYLOAD = {
|
|
"name": "Acme Main Line",
|
|
"phone_number": "+18005551234",
|
|
"description": "Main IVR",
|
|
"steps": [
|
|
{
|
|
"id": "step1",
|
|
"description": "Press 2 for billing",
|
|
"action": "dtmf",
|
|
"action_value": "2",
|
|
}
|
|
],
|
|
"tags": ["test"],
|
|
}
|
|
|
|
|
|
class TestCallFlowRoutes:
|
|
async def test_crud_round_trip(self, client):
|
|
resp = await client.post("/api/call-flows/", json=FLOW_PAYLOAD)
|
|
assert resp.status_code == 200, resp.text
|
|
flow_id = resp.json()["id"]
|
|
assert flow_id == "acme-main-line"
|
|
|
|
resp = await client.post("/api/call-flows/", json=FLOW_PAYLOAD)
|
|
assert resp.status_code == 409
|
|
|
|
resp = await client.get("/api/call-flows/")
|
|
assert [f["id"] for f in resp.json()] == [flow_id]
|
|
|
|
resp = await client.get(f"/api/call-flows/{flow_id}")
|
|
assert resp.json()["steps"][0]["action_value"] == "2"
|
|
|
|
resp = await client.get("/api/call-flows/by-number/+18005551234")
|
|
assert resp.json()["id"] == flow_id
|
|
|
|
resp = await client.put(
|
|
f"/api/call-flows/{flow_id}", json={"notes": "updated"}
|
|
)
|
|
assert resp.json()["notes"] == "updated"
|
|
|
|
resp = await client.delete(f"/api/call-flows/{flow_id}")
|
|
assert resp.json()["status"] == "deleted"
|
|
|
|
resp = await client.get(f"/api/call-flows/{flow_id}")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
class TestCallHistoryRoutes:
|
|
async def test_history_and_record(self, client):
|
|
resp = await client.get("/api/calls/history")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == []
|
|
|
|
async with client.db_factory() as session:
|
|
session.add(CallRecord(
|
|
id="call_hist1",
|
|
direction="outbound",
|
|
remote_number="+18005551234",
|
|
status="completed",
|
|
mode="hold_slayer",
|
|
intent="dispute charge",
|
|
duration=120,
|
|
hold_time=90,
|
|
))
|
|
await session.commit()
|
|
|
|
resp = await client.get("/api/calls/history")
|
|
assert [r["id"] for r in resp.json()] == ["call_hist1"]
|
|
|
|
resp = await client.get("/api/calls/history?number=%2B18005551234")
|
|
assert len(resp.json()) == 1
|
|
|
|
resp = await client.get("/api/calls/call_hist1/record")
|
|
assert resp.json()["intent"] == "dispute charge"
|
|
|
|
resp = await client.get("/api/calls/call_missing/record")
|
|
assert resp.status_code == 404
|
|
|
|
resp = await client.get("/api/calls/call_hist1/transcript")
|
|
assert resp.json() == []
|