Engine mode is now explicit: USE_MOCK_SIP=true is the only way to get the mock engine; an unconfigured trunk fails startup with guidance instead of silently degrading. Root-caused why the engine always ran mock: nested pydantic-settings never read .env (no env_file on the sub-settings classes) — all 8 now declare it. /health stops lying: reports engine mode (sippy|mock), a live DB SELECT 1, trunk registration state with reason, and TTS/STT availability from their last real request; "healthy" now requires ready + db + sippy + registered trunk. Error policy: leaf services (tts/transcription/llm_client) raise and track availability; call-loop callers catch, publish EventType.ERROR naming the failed service, and apply an explicit fallback. Persistence writes get one bounded 3x exponential retry, then an ERROR log — no more silent data loss. Event bus: a full subscriber queue drops its oldest event (counted) instead of silently evicting the subscription; subscribe(replay_last=N) delivers the advertised history replay, used by /ws/events (25). Receptionist correctness: a matched TAKE_MESSAGE rule beats the LLM; voicemail polls for early hangup and stops/transcribes/hangs up in finally; RecordingSession finally keeps its leg_ids so taps detach. Dead code removed: models/contact.py + Contact table, dtmf_buffer, transcribe_stream stub, SMS stub in notification.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
169 lines
6.1 KiB
Python
169 lines
6.1 KiB
Python
"""
|
|
Thread-ownership and task-hygiene tests.
|
|
|
|
Covers the Sippy→loop event funnel (events posted from a foreign
|
|
thread mutate loop-owned state), the AudioTap thread-safe feed, the
|
|
off-loop classifier entry point, leg-state propagation into the
|
|
CallManager, and background-task cancellation on gateway stop.
|
|
"""
|
|
|
|
import asyncio
|
|
import threading
|
|
|
|
import numpy as np
|
|
|
|
from config import ClassifierSettings, Settings
|
|
from core.gateway import AIPSTNGateway
|
|
from core.media_pipeline import AudioTap
|
|
from core.sippy_engine import SippyEngine
|
|
from models.call import CallStatus
|
|
from services.audio_classifier import AudioClassifier
|
|
|
|
|
|
def _engine_on_loop() -> SippyEngine:
|
|
"""Engine wired to the running loop without starting the SIP stack."""
|
|
engine = SippyEngine()
|
|
engine._loop = asyncio.get_running_loop()
|
|
return engine
|
|
|
|
|
|
def _post_from_thread(engine: SippyEngine, kind: str, data: dict) -> None:
|
|
"""Post a funnel event from a foreign thread, like the Sippy ED thread."""
|
|
t = threading.Thread(target=engine._post_from_ed, args=(kind, data))
|
|
t.start()
|
|
t.join()
|
|
|
|
|
|
class TestEngineEventFunnel:
|
|
async def test_register_and_deregister_from_foreign_thread(self):
|
|
engine = _engine_on_loop()
|
|
|
|
_post_from_thread(engine, "register", {
|
|
"aor": "sip:alice@gw", "contact": "sip:alice@10.0.0.5", "expires": 3600,
|
|
})
|
|
await asyncio.sleep(0.05)
|
|
assert engine._registered_devices == [
|
|
{"aor": "sip:alice@gw", "contact": "sip:alice@10.0.0.5", "expires": 3600}
|
|
]
|
|
|
|
# Re-register updates in place instead of duplicating
|
|
_post_from_thread(engine, "register", {
|
|
"aor": "sip:alice@gw", "contact": "sip:alice@10.0.0.9", "expires": 60,
|
|
})
|
|
await asyncio.sleep(0.05)
|
|
assert len(engine._registered_devices) == 1
|
|
assert engine._registered_devices[0]["contact"] == "sip:alice@10.0.0.9"
|
|
|
|
_post_from_thread(engine, "deregister", {"aor": "sip:alice@gw"})
|
|
await asyncio.sleep(0.05)
|
|
assert engine._registered_devices == []
|
|
|
|
async def test_incoming_invite_auto_answers_without_callback(self):
|
|
engine = _engine_on_loop()
|
|
_post_from_thread(engine, "incoming_invite", {
|
|
"leg_id": "leg_test1",
|
|
"from_uri": "sip:caller@pstn",
|
|
"to_uri": "sip:+15551234567@gw",
|
|
"sdp": None,
|
|
})
|
|
await asyncio.sleep(0.05)
|
|
leg = engine._legs["leg_test1"]
|
|
assert leg.direction == "inbound"
|
|
assert leg.state == "connected"
|
|
|
|
async def test_incoming_call_callback_runs_as_tracked_task(self):
|
|
engine = _engine_on_loop()
|
|
seen = asyncio.Event()
|
|
|
|
async def on_incoming(from_uri, to_uri, leg_id):
|
|
seen.set()
|
|
|
|
engine._on_incoming_call = on_incoming
|
|
_post_from_thread(engine, "incoming_invite", {
|
|
"leg_id": "leg_test2", "from_uri": "a", "to_uri": "b", "sdp": None,
|
|
})
|
|
await asyncio.wait_for(seen.wait(), timeout=1.0)
|
|
assert engine._legs["leg_test2"].state == "init" # not auto-answered
|
|
|
|
async def test_bye_terminates_leg_and_notifies(self):
|
|
engine = _engine_on_loop()
|
|
states: list[tuple[str, str]] = []
|
|
engine._on_leg_state_change = lambda leg_id, state: states.append((leg_id, state))
|
|
|
|
_post_from_thread(engine, "incoming_invite", {
|
|
"leg_id": "leg_test3", "from_uri": "a", "to_uri": "b", "sdp": None,
|
|
})
|
|
await asyncio.sleep(0.05)
|
|
_post_from_thread(engine, "leg_state", {"leg_id": "leg_test3", "state": "terminated"})
|
|
await asyncio.sleep(0.05)
|
|
|
|
assert engine._legs["leg_test3"].state == "terminated"
|
|
assert ("leg_test3", "terminated") in states
|
|
|
|
async def test_dtmf_and_trunk_events(self):
|
|
engine = _engine_on_loop()
|
|
_post_from_thread(engine, "dtmf", {"leg_id": "leg_test4", "digit": "5"})
|
|
_post_from_thread(engine, "trunk_registered", {"registered": True})
|
|
await asyncio.sleep(0.05)
|
|
assert engine._trunk_registered is True
|
|
|
|
|
|
class TestAudioTapThreadSafety:
|
|
async def test_feed_from_foreign_thread_reaches_reader(self):
|
|
tap = AudioTap("leg_x")
|
|
frame = b"\x01\x02" * 320
|
|
|
|
t = threading.Thread(target=tap.feed, args=(frame,))
|
|
t.start()
|
|
t.join()
|
|
|
|
received = await tap.read_frame(timeout=1.0)
|
|
assert received == frame
|
|
|
|
|
|
class TestClassifierOffLoop:
|
|
async def test_classify_runs_and_records_history(self):
|
|
classifier = AudioClassifier(ClassifierSettings())
|
|
silence = np.zeros(16000, dtype=np.int16).tobytes()
|
|
|
|
result = await classifier.classify(silence)
|
|
|
|
assert result.audio_type.value == "silence"
|
|
assert classifier._classification_history == [result.audio_type]
|
|
|
|
|
|
class TestLegStatePropagation:
|
|
async def test_leg_lifecycle_drives_call_status(self):
|
|
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
|
call = await gateway.make_call("+15551234567")
|
|
assert call.status == CallStatus.RINGING
|
|
|
|
(leg_id,) = gateway.call_manager.legs_for_call(call.id)
|
|
|
|
await gateway._on_sip_leg_state(leg_id, "connected")
|
|
assert gateway.get_call(call.id).status == CallStatus.CONNECTED
|
|
|
|
await gateway._on_sip_leg_state(leg_id, "terminated")
|
|
assert gateway.get_call(call.id) is None
|
|
assert gateway.call_manager.legs_for_call(call.id) == []
|
|
|
|
async def test_late_signals_do_not_stomp_service_states(self):
|
|
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
|
call = await gateway.make_call("+15551234567")
|
|
(leg_id,) = gateway.call_manager.legs_for_call(call.id)
|
|
|
|
await gateway.call_manager.update_status(call.id, CallStatus.ON_HOLD)
|
|
await gateway._on_sip_leg_state(leg_id, "connected")
|
|
assert gateway.get_call(call.id).status == CallStatus.ON_HOLD
|
|
|
|
|
|
class TestTaskHygiene:
|
|
async def test_gateway_stop_cancels_spawned_tasks(self):
|
|
gateway = AIPSTNGateway(settings=Settings(max_concurrent_calls=4))
|
|
task = gateway.spawn(asyncio.sleep(60), name="test_sleeper")
|
|
|
|
await gateway.stop()
|
|
|
|
assert task.cancelled()
|
|
assert gateway._tasks == set()
|