A local HTTP service that accepts text via POST and speaks it through system speakers using Piper TTS neural voice synthesis. Features: - POST /notify - Queue text for TTS playback - GET /health - Health check with TTS/audio/queue status - GET /voices - List installed voice models - Async queue processing (no overlapping audio) - Non-blocking audio via sounddevice - 73 tests covering API contract Tech stack: - FastAPI + Uvicorn - Piper TTS (neural voices, offline) - sounddevice (PortAudio) - Pydantic for validation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
389 lines
12 KiB
Python
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 == "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"
|