voice-server/tests/test_models.py
Cal Corum 5f7dd68bf6 Add urgent flag for higher volume playback
Added optional 'urgent' boolean field to POST /notify requests.
When urgent=true, audio is played at 1.5x volume with clipping
protection for critical messages.

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

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

392 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
assert request.urgent is False # default not urgent
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,
urgent=True,
)
assert request.message == "Test message"
assert request.voice == "en_US-libritts-high"
assert request.rate == 200
assert request.voice_enabled is False
assert request.urgent is True
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"