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