voice-server/tests/test_models.py
Cal Corum 5e50df4dac Update tests to handle configurable default voice
Tests now check for valid values rather than hardcoded defaults,
allowing the default voice to be configured via .env without
breaking tests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 00:37:19 -06:00

389 lines
12 KiB
Python

"""
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 is None # None = use server default
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"