voice-server/tests/test_api.py
Cal Corum a34aec06f1 Initial commit: Voice server with Piper TTS
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>
2025-12-19 00:18:12 -06:00

325 lines
9.4 KiB
Python

"""
TDD Tests for API endpoints.
These tests verify the API contract for all voice-server endpoints:
- POST /notify: TTS request submission
- GET /health: Health check
- GET /voices: Voice model listing
Uses httpx.AsyncClient for async endpoint testing with FastAPI's TestClient.
"""
import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app
@pytest.fixture
async def client():
"""Create an async test client for the FastAPI app."""
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
yield client
class TestNotifyEndpoint:
"""Tests for POST /notify endpoint."""
async def test_valid_request_returns_202(self, client: AsyncClient):
"""
A valid request with just a message should return 202 Accepted.
"""
response = await client.post(
"/notify",
json={"message": "Hello, world!"},
)
assert response.status_code == 202
async def test_valid_request_returns_queued_status(self, client: AsyncClient):
"""
Response should include status='queued' for successful requests.
"""
response = await client.post(
"/notify",
json={"message": "Test message"},
)
data = response.json()
assert data["status"] == "queued"
async def test_response_includes_message_length(self, client: AsyncClient):
"""
Response should include the length of the submitted message.
"""
message = "This is a test message"
response = await client.post(
"/notify",
json={"message": message},
)
data = response.json()
assert data["message_length"] == len(message)
async def test_response_includes_queue_position(self, client: AsyncClient):
"""
Response should include the queue position.
"""
response = await client.post(
"/notify",
json={"message": "Test"},
)
data = response.json()
assert "queue_position" in data
assert isinstance(data["queue_position"], int)
assert data["queue_position"] >= 1
async def test_response_includes_voice_model(self, client: AsyncClient):
"""
Response should include the voice model being used.
"""
response = await client.post(
"/notify",
json={"message": "Test"},
)
data = response.json()
assert "voice_model" in data
assert data["voice_model"] == "en_US-lessac-medium" # default
async def test_custom_voice_is_preserved(self, client: AsyncClient):
"""
Custom voice selection should be reflected in response.
"""
response = await client.post(
"/notify",
json={"message": "Test", "voice": "en_US-libritts-high"},
)
data = response.json()
assert data["voice_model"] == "en_US-libritts-high"
async def test_missing_message_returns_422(self, client: AsyncClient):
"""
Request without message should return 422 Unprocessable Entity.
"""
response = await client.post(
"/notify",
json={},
)
assert response.status_code == 422
async def test_empty_message_returns_422(self, client: AsyncClient):
"""
Empty message string should return 422 Unprocessable Entity.
"""
response = await client.post(
"/notify",
json={"message": ""},
)
assert response.status_code == 422
async def test_message_too_long_returns_422(self, client: AsyncClient):
"""
Message over 10000 characters should return 422.
"""
response = await client.post(
"/notify",
json={"message": "a" * 10001},
)
assert response.status_code == 422
async def test_invalid_rate_returns_422(self, client: AsyncClient):
"""
Rate outside valid range should return 422.
"""
response = await client.post(
"/notify",
json={"message": "Test", "rate": 500},
)
assert response.status_code == 422
async def test_invalid_voice_pattern_returns_422(self, client: AsyncClient):
"""
Voice with invalid characters should return 422.
"""
response = await client.post(
"/notify",
json={"message": "Test", "voice": "invalid/voice"},
)
assert response.status_code == 422
async def test_malformed_json_returns_422(self, client: AsyncClient):
"""
Malformed JSON should return 422.
"""
response = await client.post(
"/notify",
content="not valid json",
headers={"Content-Type": "application/json"},
)
assert response.status_code == 422
async def test_whitespace_message_is_stripped(self, client: AsyncClient):
"""
Whitespace in message should be stripped.
"""
response = await client.post(
"/notify",
json={"message": " Hello "},
)
assert response.status_code == 202
data = response.json()
assert data["message_length"] == 5 # "Hello" without whitespace
class TestHealthEndpoint:
"""Tests for GET /health endpoint."""
async def test_health_returns_200(self, client: AsyncClient):
"""
Health endpoint should return 200 when healthy.
"""
response = await client.get("/health")
assert response.status_code == 200
async def test_health_returns_status(self, client: AsyncClient):
"""
Health response should include status field.
"""
response = await client.get("/health")
data = response.json()
assert "status" in data
assert data["status"] in ["healthy", "unhealthy"]
async def test_health_returns_uptime(self, client: AsyncClient):
"""
Health response should include uptime in seconds.
"""
response = await client.get("/health")
data = response.json()
assert "uptime_seconds" in data
assert isinstance(data["uptime_seconds"], int)
assert data["uptime_seconds"] >= 0
async def test_health_returns_queue_status(self, client: AsyncClient):
"""
Health response should include queue status.
"""
response = await client.get("/health")
data = response.json()
assert "queue" in data
assert "size" in data["queue"]
assert "capacity" in data["queue"]
assert "utilization" in data["queue"]
async def test_health_returns_tts_engine(self, client: AsyncClient):
"""
Health response should include TTS engine info.
"""
response = await client.get("/health")
data = response.json()
assert "tts_engine" in data
assert data["tts_engine"] == "piper"
async def test_health_returns_audio_output(self, client: AsyncClient):
"""
Health response should include audio output status.
"""
response = await client.get("/health")
data = response.json()
assert "audio_output" in data
class TestVoicesEndpoint:
"""Tests for GET /voices endpoint."""
async def test_voices_returns_200(self, client: AsyncClient):
"""
Voices endpoint should return 200.
"""
response = await client.get("/voices")
assert response.status_code == 200
async def test_voices_returns_list(self, client: AsyncClient):
"""
Voices response should include a list of voices.
"""
response = await client.get("/voices")
data = response.json()
assert "voices" in data
assert isinstance(data["voices"], list)
async def test_voices_returns_default_voice(self, client: AsyncClient):
"""
Voices response should include the default voice.
"""
response = await client.get("/voices")
data = response.json()
assert "default_voice" in data
assert data["default_voice"] == "en_US-lessac-medium"
class TestOpenAPIDocumentation:
"""Tests for API documentation endpoints."""
async def test_openapi_json_available(self, client: AsyncClient):
"""
OpenAPI JSON should be available at /openapi.json.
"""
response = await client.get("/openapi.json")
assert response.status_code == 200
data = response.json()
assert "openapi" in data
assert "paths" in data
async def test_docs_endpoint_available(self, client: AsyncClient):
"""
Swagger UI should be available at /docs.
"""
response = await client.get("/docs")
assert response.status_code == 200
assert "text/html" in response.headers.get("content-type", "")
class TestCORS:
"""Tests for CORS middleware."""
async def test_cors_headers_present(self, client: AsyncClient):
"""
CORS headers should be present in responses.
"""
response = await client.options(
"/notify",
headers={
"Origin": "http://localhost:3000",
"Access-Control-Request-Method": "POST",
},
)
# FastAPI returns 200 for OPTIONS with CORS
assert response.status_code == 200
assert "access-control-allow-origin" in response.headers