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>
325 lines
9.4 KiB
Python
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
|