""" TDD Tests for Pydantic request/response models. These tests define the API contract for the voice server's request and response models. Tests are written BEFORE implementation to drive the design. Test Coverage: - NotifyRequest: Validates incoming TTS requests with message, voice, rate, voice_enabled - NotifyResponse: Validates successful queue responses - HealthResponse: Validates health check responses - ErrorResponse: Validates error response format - VoiceInfo/VoicesResponse: Validates voice listing responses """ import pytest from datetime import datetime from pydantic import ValidationError class TestNotifyRequest: """Tests for the NotifyRequest model - validates incoming TTS requests.""" def test_valid_request_with_message_only(self): """ A minimal valid request should only require the message field. All other fields should use sensible defaults. """ from app.models import NotifyRequest request = NotifyRequest(message="Hello, world!") assert request.message == "Hello, world!" assert request.voice == "en_US-lessac-medium" # default voice assert request.rate == 170 # default rate assert request.voice_enabled is True # default enabled def test_valid_request_with_all_fields(self): """ A request with all fields specified should preserve those values. """ from app.models import NotifyRequest request = NotifyRequest( message="Test message", voice="en_US-libritts-high", rate=200, voice_enabled=False, ) assert request.message == "Test message" assert request.voice == "en_US-libritts-high" assert request.rate == 200 assert request.voice_enabled is False def test_message_is_required(self): """ The message field is required - omitting it should raise ValidationError. """ from app.models import NotifyRequest with pytest.raises(ValidationError) as exc_info: NotifyRequest() errors = exc_info.value.errors() assert any(e["loc"] == ("message",) and e["type"] == "missing" for e in errors) def test_message_cannot_be_empty(self): """ An empty message string should be rejected. """ from app.models import NotifyRequest with pytest.raises(ValidationError) as exc_info: NotifyRequest(message="") errors = exc_info.value.errors() assert any("message" in str(e["loc"]) for e in errors) def test_message_minimum_length_is_1(self): """ A single character message should be valid. """ from app.models import NotifyRequest request = NotifyRequest(message="X") assert request.message == "X" def test_message_maximum_length_is_10000(self): """ Messages up to 10,000 characters should be accepted. """ from app.models import NotifyRequest long_message = "a" * 10000 request = NotifyRequest(message=long_message) assert len(request.message) == 10000 def test_message_over_10000_characters_rejected(self): """ Messages over 10,000 characters should be rejected. """ from app.models import NotifyRequest too_long = "a" * 10001 with pytest.raises(ValidationError) as exc_info: NotifyRequest(message=too_long) errors = exc_info.value.errors() assert any("message" in str(e["loc"]) for e in errors) def test_message_whitespace_is_stripped(self): """ Leading and trailing whitespace should be stripped from messages. """ from app.models import NotifyRequest request = NotifyRequest(message=" Hello, world! ") assert request.message == "Hello, world!" def test_rate_minimum_is_50(self): """ Rate below 50 should be rejected. """ from app.models import NotifyRequest with pytest.raises(ValidationError) as exc_info: NotifyRequest(message="Test", rate=49) errors = exc_info.value.errors() assert any("rate" in str(e["loc"]) for e in errors) def test_rate_maximum_is_400(self): """ Rate above 400 should be rejected. """ from app.models import NotifyRequest with pytest.raises(ValidationError) as exc_info: NotifyRequest(message="Test", rate=401) errors = exc_info.value.errors() assert any("rate" in str(e["loc"]) for e in errors) def test_rate_at_boundaries(self): """ Rate values at exact boundaries (50, 400) should be valid. """ from app.models import NotifyRequest request_min = NotifyRequest(message="Test", rate=50) assert request_min.rate == 50 request_max = NotifyRequest(message="Test", rate=400) assert request_max.rate == 400 def test_voice_pattern_validation(self): """ Voice names should match expected pattern (alphanumeric, underscores, hyphens). """ from app.models import NotifyRequest # Valid patterns request = NotifyRequest(message="Test", voice="en_US-lessac-medium") assert request.voice == "en_US-lessac-medium" request2 = NotifyRequest(message="Test", voice="voice_123") assert request2.voice == "voice_123" def test_invalid_voice_pattern_rejected(self): """ Voice names with invalid characters should be rejected. """ from app.models import NotifyRequest with pytest.raises(ValidationError): NotifyRequest(message="Test", voice="invalid/voice") with pytest.raises(ValidationError): NotifyRequest(message="Test", voice="invalid voice") class TestNotifyResponse: """Tests for the NotifyResponse model - returned when request is queued.""" def test_successful_response_structure(self): """ A successful response should contain status, message_length, queue_position. """ from app.models import NotifyResponse response = NotifyResponse( status="queued", message_length=42, queue_position=3, voice_model="en_US-lessac-medium", ) assert response.status == "queued" assert response.message_length == 42 assert response.queue_position == 3 assert response.voice_model == "en_US-lessac-medium" def test_estimated_duration_is_optional(self): """ Estimated duration can be omitted. """ from app.models import NotifyResponse response = NotifyResponse( status="queued", message_length=42, queue_position=1, voice_model="en_US-lessac-medium", ) assert response.estimated_duration is None def test_estimated_duration_when_provided(self): """ Estimated duration should be preserved when provided. """ from app.models import NotifyResponse response = NotifyResponse( status="queued", message_length=42, queue_position=1, voice_model="en_US-lessac-medium", estimated_duration=2.5, ) assert response.estimated_duration == 2.5 class TestHealthResponse: """Tests for the HealthResponse model - returned by /health endpoint.""" def test_healthy_response_structure(self): """ A healthy response should contain all required fields. """ from app.models import HealthResponse, QueueStatus queue_status = QueueStatus(size=2, capacity=50, utilization=4.0) response = HealthResponse( status="healthy", uptime_seconds=3600, queue=queue_status, tts_engine="piper", audio_output="available", ) assert response.status == "healthy" assert response.uptime_seconds == 3600 assert response.queue.size == 2 assert response.queue.capacity == 50 assert response.tts_engine == "piper" assert response.audio_output == "available" def test_unhealthy_response_with_errors(self): """ An unhealthy response can include error messages. """ from app.models import HealthResponse, QueueStatus queue_status = QueueStatus(size=0, capacity=50, utilization=0.0) response = HealthResponse( status="unhealthy", uptime_seconds=100, queue=queue_status, tts_engine="piper", audio_output="unavailable", errors=["Audio device not found", "TTS engine failed to initialize"], ) assert response.status == "unhealthy" assert len(response.errors) == 2 assert "Audio device not found" in response.errors def test_statistics_fields_are_optional(self): """ Statistics like total_requests and failed_requests are optional. """ from app.models import HealthResponse, QueueStatus queue_status = QueueStatus(size=0, capacity=50, utilization=0.0) response = HealthResponse( status="healthy", uptime_seconds=0, queue=queue_status, tts_engine="piper", audio_output="available", ) assert response.total_requests is None assert response.failed_requests is None class TestErrorResponse: """Tests for the ErrorResponse model - returned for error conditions.""" def test_error_response_structure(self): """ An error response should contain error type, detail, and timestamp. """ from app.models import ErrorResponse response = ErrorResponse( error="validation_error", detail="message field is required", ) assert response.error == "validation_error" assert response.detail == "message field is required" assert response.timestamp is not None def test_timestamp_auto_generated(self): """ Timestamp should be auto-generated if not provided. """ from app.models import ErrorResponse response = ErrorResponse( error="queue_full", detail="TTS queue is full", ) assert isinstance(response.timestamp, datetime) def test_queue_full_error_includes_queue_size(self): """ Queue full errors can include the current queue size. """ from app.models import ErrorResponse response = ErrorResponse( error="queue_full", detail="TTS queue is full, please retry later", queue_size=50, ) assert response.queue_size == 50 class TestVoiceModels: """Tests for voice-related models.""" def test_voice_info_structure(self): """ VoiceInfo should contain name, language, quality, and installation status. """ from app.models import VoiceInfo voice = VoiceInfo( name="en_US-lessac-medium", language="en_US", quality="medium", size_mb=63.5, installed=True, ) assert voice.name == "en_US-lessac-medium" assert voice.language == "en_US" assert voice.quality == "medium" assert voice.size_mb == 63.5 assert voice.installed is True def test_voices_response_structure(self): """ VoicesResponse should contain a list of voices and the default voice. """ from app.models import VoiceInfo, VoicesResponse voice = VoiceInfo( name="en_US-lessac-medium", language="en_US", quality="medium", size_mb=63.5, installed=True, ) response = VoicesResponse( voices=[voice], default_voice="en_US-lessac-medium", ) assert len(response.voices) == 1 assert response.default_voice == "en_US-lessac-medium"