diff --git a/src/tac/server/fastapi_server.py b/src/tac/server/fastapi_server.py index 5b86c50..bd6f71f 100644 --- a/src/tac/server/fastapi_server.py +++ b/src/tac/server/fastapi_server.py @@ -10,19 +10,17 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING, Any +from typing import Any from tac.channels.base import BaseChannel +from tac.channels.messaging import MessagingChannel +from tac.channels.voice import VoiceChannel from tac.channels.websocket_protocol import WebSocketDisconnectError from tac.core.logging import get_logger from tac.core.tac import TAC from tac.models.voice import TwiMLRequest from tac.server.config import TACServerConfig -if TYPE_CHECKING: - from tac.channels.messaging import MessagingChannel - from tac.channels.voice import VoiceChannel - try: import uvicorn from fastapi import Depends, FastAPI, Request, WebSocket, WebSocketDisconnect @@ -112,6 +110,8 @@ def __init__( self.voice_channel = voice_channel self.messaging_channels: list[MessagingChannel] = messaging_channels or [] + self._validate_channel_types() + if self.voice_channel is not None: self._validate_voice_url_config() @@ -124,6 +124,30 @@ def __init__( self.app: FastAPI = app if app is not None else FastAPI(title="TAC Server") self._register_routes(self.app) + def _validate_channel_types(self) -> None: + """Fail fast at server construction if a channel is the wrong type. + + A common mistake is passing ``None`` (e.g. a connector channel that + wasn't configured) or an otherwise wrong object into + ``messaging_channels`` / ``voice_channel``. Without this check that + surfaces much later as an opaque ``AttributeError`` (e.g. ``'NoneType' + object has no attribute 'get_channel_name'``) deep in webhook dispatch, + far from the actual misconfiguration. + """ + if self.voice_channel is not None and not isinstance(self.voice_channel, VoiceChannel): + raise TypeError( + "voice_channel must be a VoiceChannel or None, got " + f"{type(self.voice_channel).__name__}." + ) + for channel in self.messaging_channels: + if not isinstance(channel, MessagingChannel): + raise TypeError( + "messaging_channels must contain MessagingChannel instances " + "(SMSChannel, RCSChannel, WhatsAppChannel, ChatChannel), got " + f"{type(channel).__name__}. A None here usually means a channel " + "that wasn't configured was passed through — filter those out." + ) + def _validate_voice_url_config(self) -> None: """Fail fast at server construction if the voice channel can't build a WebSocket URL. diff --git a/tests/test_server.py b/tests/test_server.py index 7a95784..b1aab72 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -118,6 +118,57 @@ def test_passes_without_voice_channel(self) -> None: TACFastAPIServer(tac=tac) # no raise +class TestChannelTypeValidationAtStartup: + """TACFastAPIServer fails fast at construction if a channel is the wrong + type — e.g. a None (an unconfigured connector channel) slipping into + messaging_channels — instead of an opaque AttributeError during dispatch.""" + + def test_none_in_messaging_channels_raises(self) -> None: + from tac.channels import SMSChannel + from tac.server import TACFastAPIServer + + tac = TAC(get_test_config()) + with pytest.raises(TypeError, match="MessagingChannel"): + TACFastAPIServer(tac=tac, messaging_channels=[SMSChannel(tac), None]) + + def test_wrong_type_in_messaging_channels_raises(self) -> None: + from tac.server import TACFastAPIServer + + tac = TAC(get_test_config()) + with pytest.raises(TypeError, match="MessagingChannel"): + TACFastAPIServer(tac=tac, messaging_channels=["not a channel"]) + + def test_voice_channel_in_messaging_channels_raises(self) -> None: + """A VoiceChannel is not a MessagingChannel — passing it in the + messaging list is a mistake and is rejected.""" + from tac.channels.voice import VoiceChannel + from tac.server import TACFastAPIServer + + tac = TAC(get_test_config()) + with pytest.raises(TypeError, match="MessagingChannel"): + TACFastAPIServer(tac=tac, messaging_channels=[VoiceChannel(tac)]) + + def test_wrong_type_voice_channel_raises(self) -> None: + from tac.channels import SMSChannel + from tac.server import TACFastAPIServer + + tac = TAC(get_test_config()) + with pytest.raises(TypeError, match="VoiceChannel"): + TACFastAPIServer(tac=tac, voice_channel=SMSChannel(tac)) + + def test_valid_channels_pass(self) -> None: + from tac.channels import ChatChannel, SMSChannel + from tac.channels.voice import VoiceChannel + from tac.server import TACFastAPIServer + + tac = TAC(get_test_config()) + TACFastAPIServer( + tac=tac, + voice_channel=VoiceChannel(tac), + messaging_channels=[SMSChannel(tac), ChatChannel(tac)], + ) # no raise + + class TestWebSocketDisconnectError: """Test WebSocketDisconnectError."""