refactor: composition root in lifespan, break core↔services cycle, shared data layer
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>
This commit is contained in:
227
tests/test_structure.py
Normal file
227
tests/test_structure.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
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() == []
|
||||
Reference in New Issue
Block a user