""" 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