voice-server/tests/test_api.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

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 isinstance(data["voice_model"], str) # uses server 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 isinstance(data["default_voice"], str) # uses server config
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