"""Tests for AI provider fallback tracking and is_fallback signal.""" from __future__ import annotations import os import sys from pathlib import Path from unittest.mock import MagicMock, patch BACKEND_DIR = Path(__file__).resolve().parents[1] if str(BACKEND_DIR) not in sys.path: sys.path.insert(0, str(BACKEND_DIR)) os.environ.setdefault("AI_PROVIDER", "mock") os.environ.setdefault("AUTH_ENABLED", "false") from app.core.config import get_settings get_settings.cache_clear() from app.services.ai_provider import ( MockAIProvider, get_ai_provider, ) def test_mock_provider_not_fallback_when_configured_as_mock(): os.environ["AI_PROVIDER"] = "mock" get_settings.cache_clear() provider = get_ai_provider() assert isinstance(provider, MockAIProvider) assert provider.is_fallback is False assert provider.fallback_reason is None def test_mock_provider_is_fallback_when_openrouter_key_missing(): """When AI_PROVIDER=openrouter but key is absent, get_ai_provider must return a MockAIProvider with is_fallback=True. We mock get_settings so the .env file on disk (which may have a real key) does not interfere with the test. """ mock_settings = MagicMock() mock_settings.ai_provider = "openrouter" mock_settings.openrouter_api_key = None with patch("app.services.ai_provider.get_settings", return_value=mock_settings): provider = get_ai_provider() assert isinstance(provider, MockAIProvider) assert provider.is_fallback is True assert "not configured" in (provider.fallback_reason or "").lower() def test_mock_provider_model_name(): os.environ["AI_PROVIDER"] = "mock" get_settings.cache_clear() provider = get_ai_provider() assert "mock" in provider.model_name.lower() def test_health_ai_endpoint_requires_admin(client): """Provider diagnostics must not be exposed to anonymous callers.""" resp = client.get("/health/ai") assert resp.status_code == 403 def test_health_ai_endpoint_returns_provider_status_for_admin(client): """The admin-only diagnostic still reports the configured provider.""" from app.core.admin_auth import require_admin client.app.dependency_overrides[require_admin] = lambda: None try: resp = client.get("/health/ai") finally: client.app.dependency_overrides.pop(require_admin, None) assert resp.status_code == 200 data = resp.json() assert data["status"] in ("ready", "demo", "misconfigured") assert "provider" in data assert "can_generate" in data def test_ask_response_has_is_fallback(client): resp = client.post("/ask", json={"question": "What is photosynthesis?"}) assert resp.status_code == 200 data = resp.json() assert "is_fallback" in data assert isinstance(data["is_fallback"], bool) assert "model_used" in data def test_studio_notes_response_has_is_fallback(client): resp = client.post( "/generate/notes", json={"topic": "Photosynthesis", "language_preference": "English"}, ) assert resp.status_code == 200 data = resp.json() assert "is_fallback" in data assert isinstance(data["is_fallback"], bool) assert "model_used" in data def test_studio_exam_answer_response_has_is_fallback(client): resp = client.post( "/generate/exam-answer", json={"topic": "Photosynthesis", "language_preference": "English"}, ) assert resp.status_code == 200 data = resp.json() assert "is_fallback" in data assert isinstance(data["is_fallback"], bool) def test_mock_provider_generates_without_fallback(): os.environ["AI_PROVIDER"] = "mock" get_settings.cache_clear() provider = get_ai_provider() output = provider.generate_simple_explanation( context="Photosynthesis is the process by which plants make food using sunlight.", language="English", ) assert provider.is_fallback is False assert "simple_meaning" in output def test_unknown_provider_falls_back_to_mock(): mock_settings = MagicMock() mock_settings.ai_provider = "nonexistent-provider" mock_settings.sarvam_api_key = None mock_settings.openrouter_api_key = None mock_settings.gemini_api_key = None mock_settings.environment = "development" mock_settings.ai_fallback_to_mock = True with patch("app.services.ai_provider.get_settings", return_value=mock_settings): provider = get_ai_provider() assert isinstance(provider, MockAIProvider)