""" 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()