Stage 4: honest health, explicit error policy, event-bus integrity
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>
This commit is contained in:
89
tests/test_health_policy.py
Normal file
89
tests/test_health_policy.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Engine-mode and event-bus-integrity tests.
|
||||
|
||||
The mock engine must be requested explicitly; a full subscriber queue
|
||||
drops its oldest event but never loses the subscription; history is
|
||||
replayable to late joiners.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from config import Settings
|
||||
from core.event_bus import EventBus
|
||||
from core.gateway import build_sip_engine
|
||||
from core.media_pipeline import MediaPipeline
|
||||
from core.sip_engine import MockSIPEngine
|
||||
from models.events import EventType, GatewayEvent
|
||||
|
||||
|
||||
def _noop(*args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class TestEngineMode:
|
||||
def _build(self, settings: Settings):
|
||||
return build_sip_engine(
|
||||
settings,
|
||||
MediaPipeline(sample_rate=16000),
|
||||
on_leg_state_change=_noop,
|
||||
on_device_registered=_noop,
|
||||
on_incoming_call=_noop,
|
||||
)
|
||||
|
||||
def test_mock_engine_only_when_asked(self):
|
||||
settings = Settings(use_mock_sip=True)
|
||||
assert isinstance(self._build(settings), MockSIPEngine)
|
||||
|
||||
def test_unconfigured_trunk_refuses_to_build(self):
|
||||
settings = Settings(use_mock_sip=False)
|
||||
settings.sip_trunk.host = "sip.yourprovider.com"
|
||||
with pytest.raises(RuntimeError, match="not configured"):
|
||||
self._build(settings)
|
||||
|
||||
|
||||
def _event(i: int) -> GatewayEvent:
|
||||
return GatewayEvent(
|
||||
type=EventType.CALL_INITIATED,
|
||||
call_id=f"call_{i}",
|
||||
data={},
|
||||
message=f"event {i}",
|
||||
)
|
||||
|
||||
|
||||
class TestEventBusIntegrity:
|
||||
async def test_overflow_drops_oldest_keeps_subscription(self):
|
||||
bus = EventBus()
|
||||
sub = bus.subscribe(max_size=3)
|
||||
|
||||
for i in range(5):
|
||||
await bus.publish(_event(i))
|
||||
|
||||
assert bus.subscriber_count == 1 # never evicted
|
||||
assert sub.dropped == 2
|
||||
received = [await asyncio.wait_for(sub.get(), 1.0) for _ in range(3)]
|
||||
assert [e.call_id for e in received] == ["call_2", "call_3", "call_4"]
|
||||
|
||||
async def test_replay_last_seeds_history(self):
|
||||
bus = EventBus()
|
||||
for i in range(10):
|
||||
await bus.publish(_event(i))
|
||||
|
||||
sub = bus.subscribe(replay_last=3)
|
||||
received = [await asyncio.wait_for(sub.get(), 1.0) for _ in range(3)]
|
||||
assert [e.call_id for e in received] == ["call_7", "call_8", "call_9"]
|
||||
|
||||
async def test_replay_respects_type_filter(self):
|
||||
bus = EventBus()
|
||||
await bus.publish(_event(1))
|
||||
await bus.publish(GatewayEvent(
|
||||
type=EventType.HUMAN_DETECTED, call_id="call_h", data={}, message="x"
|
||||
))
|
||||
|
||||
sub = bus.subscribe(
|
||||
event_types={EventType.HUMAN_DETECTED}, replay_last=5
|
||||
)
|
||||
event = await asyncio.wait_for(sub.get(), 1.0)
|
||||
assert event.call_id == "call_h"
|
||||
assert sub._queue.empty()
|
||||
Reference in New Issue
Block a user