""" Integration tests for the API endpoints with mocked external services. Tests /api/analyze, /health, /metrics — no external auth, persistence, or Redis needed. """ import pytest from unittest.mock import AsyncMock, patch, MagicMock from fastapi.testclient import TestClient def _make_client(): """Build a TestClient with persistence and external calls mocked out.""" with patch("backend.app.db.firestore._enabled", True), \ patch("backend.app.db.firestore.save_document", new_callable=AsyncMock, return_value=True), \ patch("backend.app.db.firestore.get_document", new_callable=AsyncMock, return_value=None): from backend.app.main import app app.dependency_overrides = {} client = TestClient(app) return client, None @pytest.fixture def client(): c, _ = _make_client() return c class TestStartup: """Verify the server starts cleanly without auth or external storage.""" def test_health_returns_200_on_startup(self): """App must start and /health must return 200 with default settings.""" c, _ = _make_client() response = c.get("/health") assert response.status_code == 200 data = response.json() assert data["status"] == "ok" @pytest.mark.asyncio async def test_in_memory_storage_round_trip(self): """Recent analysis results should still be retrievable without Firestore.""" from backend.app.db.firestore import save_document, get_document, _db _db.clear() payload = {"id": "abc123", "status": "done", "threat_score": 0.42} assert await save_document("analysis_results", "abc123", payload) is True assert await get_document("analysis_results", "abc123") == payload class TestHealthEndpoint: def test_health_check(self, client): response = client.get("/health") assert response.status_code == 200 data = response.json() assert data["status"] == "ok" assert data["version"] == "1.0.0" class TestMetricsEndpoint: def test_metrics(self, client): response = client.get("/metrics") assert response.status_code == 200 class TestAnalyzeEndpoint: @patch("backend.app.api.routes.check_rate_limit", new_callable=AsyncMock, return_value=True) @patch("backend.app.api.routes.detect_ai_text", new_callable=AsyncMock, return_value=0.85) @patch("backend.app.api.routes.compute_perplexity", new_callable=AsyncMock, return_value=0.6) @patch("backend.app.api.routes.get_embeddings", new_callable=AsyncMock, return_value=[0.1] * 768) @patch("backend.app.api.routes.compute_cluster_score", new_callable=AsyncMock, return_value=0.3) @patch("backend.app.api.routes.upsert_embedding", new_callable=AsyncMock) @patch("backend.app.api.routes.detect_harm", new_callable=AsyncMock, return_value=0.2) @patch("backend.app.api.routes.get_cached", new_callable=AsyncMock, return_value=None) @patch("backend.app.api.routes.set_cached", new_callable=AsyncMock) @patch("backend.app.db.firestore.save_document", new_callable=AsyncMock, return_value=True) def test_analyze_returns_scores( self, mock_save, mock_set_cache, mock_get_cache, mock_harm, mock_upsert, mock_cluster, mock_embed, mock_perp, mock_ai, mock_rate, client ): response = client.post( "/api/analyze", json={"text": "This is a test text that should be analyzed for potential misuse patterns."}, ) assert response.status_code == 200 data = response.json() assert "threat_score" in data assert "signals" in data assert "explainability" in data assert data["status"] == "done" assert data["signals"]["p_ai"] == 0.85 @patch("backend.app.api.routes.check_rate_limit", new_callable=AsyncMock, return_value=False) def test_rate_limited(self, mock_rate, client): response = client.post( "/api/analyze", json={"text": "Test text for rate limiting check."}, ) assert response.status_code == 429 def test_text_too_short(self, client): response = client.post("/api/analyze", json={"text": "short"}) assert response.status_code == 422 class TestAssistEndpoint: def test_assist_no_api_key_returns_503(self, client): """When GROQ_API_KEY is not set, /api/assist should return 503.""" from backend.app.core import config original = config.settings.GROQ_API_KEY try: config.settings.GROQ_API_KEY = "" response = client.post( "/api/assist", json={"text": "This is a test text that needs to be fixed by the AI assistant."}, ) assert response.status_code == 503 assert "GROQ_API_KEY" in response.json()["detail"] finally: config.settings.GROQ_API_KEY = original @patch("backend.app.api.routes.check_rate_limit", new_callable=AsyncMock, return_value=True) @patch("backend.app.api.routes.httpx.AsyncClient") def test_assist_returns_fixed_text(self, mock_http_cls, mock_rate, client): """When Groq API responds, /api/assist should return fixed_text and logs.""" from backend.app.core import config original = config.settings.GROQ_API_KEY try: config.settings.GROQ_API_KEY = "test-groq-key" # Set up mock response mock_response = MagicMock() mock_response.json.return_value = { "choices": [{"message": {"content": "This is the improved text."}}] } mock_response.raise_for_status = MagicMock() mock_http_instance = MagicMock() mock_http_instance.__aenter__ = AsyncMock(return_value=mock_http_instance) mock_http_instance.__aexit__ = AsyncMock(return_value=False) mock_http_instance.post = AsyncMock(return_value=mock_response) mock_http_cls.return_value = mock_http_instance response = client.post( "/api/assist", json={"text": "This is a test text that needs to be fixed by the AI assistant."}, ) assert response.status_code == 200 data = response.json() assert data["fixed_text"] == "This is the improved text." assert isinstance(data["request_logs"], list) assert len(data["request_logs"]) > 0 finally: config.settings.GROQ_API_KEY = original @patch("backend.app.api.routes.check_rate_limit", new_callable=AsyncMock, return_value=False) def test_assist_rate_limited(self, mock_rate, client): """When rate limit is exceeded, /api/assist should return 429.""" from backend.app.core import config original = config.settings.GROQ_API_KEY try: config.settings.GROQ_API_KEY = "test-groq-key" response = client.post( "/api/assist", json={"text": "This is a test text that needs to be fixed."}, ) assert response.status_code == 429 finally: config.settings.GROQ_API_KEY = original class TestModelConfiguration: def test_default_models_match_documented_endpoints(self): """Default backend model settings should match the documented README models.""" from backend.app.core.config import settings router_base = "https://router.huggingface.co/hf-inference/models" assert settings.HF_DETECTOR_PRIMARY == f"{router_base}/desklib/ai-text-detector-v1.01" assert settings.HF_DETECTOR_FALLBACK == f"{router_base}/fakespot-ai/roberta-base-ai-text-detection-v1" assert settings.HF_EMBEDDINGS_PRIMARY == f"{router_base}/sentence-transformers/all-mpnet-base-v2" assert settings.HF_EMBEDDINGS_FALLBACK == f"{router_base}/sentence-transformers/all-MiniLM-L6-v2" assert settings.HF_HARM_CLASSIFIER == f"{router_base}/facebook/roberta-hate-speech-dynabench-r4-target" class TestAttackSimulations: @patch("backend.app.api.routes.check_rate_limit", new_callable=AsyncMock, return_value=True) @patch("backend.app.api.routes.detect_ai_text", new_callable=AsyncMock, return_value=0.95) @patch("backend.app.api.routes.compute_perplexity", new_callable=AsyncMock, return_value=0.8) @patch("backend.app.api.routes.get_embeddings", new_callable=AsyncMock, return_value=[0.1] * 768) @patch("backend.app.api.routes.compute_cluster_score", new_callable=AsyncMock, return_value=0.7) @patch("backend.app.api.routes.upsert_embedding", new_callable=AsyncMock) @patch("backend.app.api.routes.detect_harm", new_callable=AsyncMock, return_value=0.9) @patch("backend.app.api.routes.get_cached", new_callable=AsyncMock, return_value=None) @patch("backend.app.api.routes.set_cached", new_callable=AsyncMock) @patch("backend.app.db.firestore.save_document", new_callable=AsyncMock, return_value=True) def test_high_threat_detection( self, mock_save, mock_set_cache, mock_get_cache, mock_harm, mock_upsert, mock_cluster, mock_embed, mock_perp, mock_ai, mock_rate, client ): response = client.post( "/api/analyze", json={"text": "Simulated high-threat content for testing purposes only. This is a test."}, ) assert response.status_code == 200 data = response.json() assert data["threat_score"] > 0.5 @patch("backend.app.api.routes.check_rate_limit", new_callable=AsyncMock, return_value=True) @patch("backend.app.api.routes.detect_ai_text", new_callable=AsyncMock, return_value=0.05) @patch("backend.app.api.routes.get_embeddings", new_callable=AsyncMock, return_value=[0.1] * 768) @patch("backend.app.api.routes.compute_cluster_score", new_callable=AsyncMock, return_value=0.1) @patch("backend.app.api.routes.upsert_embedding", new_callable=AsyncMock) @patch("backend.app.api.routes.detect_harm", new_callable=AsyncMock, return_value=0.02) @patch("backend.app.api.routes.get_cached", new_callable=AsyncMock, return_value=None) @patch("backend.app.api.routes.set_cached", new_callable=AsyncMock) @patch("backend.app.db.firestore.save_document", new_callable=AsyncMock, return_value=True) def test_benign_text_low_threat( self, mock_save, mock_set_cache, mock_get_cache, mock_harm, mock_upsert, mock_cluster, mock_embed, mock_ai, mock_rate, client ): response = client.post( "/api/analyze", json={"text": "The weather today is sunny with clear skies and mild temperatures across the region."}, ) assert response.status_code == 200 data = response.json() assert data["threat_score"] < 0.3