"""Tests for the AI Proactive plugin — Context API, Suggestions API, Dismiss API, Settings API, Stats API, and Proactive Engine unit tests. Uses pytest + pytest-asyncio, httpx.AsyncClient, login_client, seed_tenant_and_users, ORIGIN_HEADER from conftest.py. Mocks litellm.acompletion and litellm.aembedding to avoid real API calls. """ from __future__ import annotations import asyncio import json import uuid from unittest.mock import AsyncMock, MagicMock, patch import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker # Import models at module level so Base.metadata.create_all includes their tables from app.plugins.builtins.ai_proactive.models import ( # noqa: F401 ContextLog, ProactiveSettings, ProactiveSuggestion, ) from app.plugins.builtins.ai_assistant.models import ( # noqa: F401 AIProvider, AIModel, AIPreset, AIAgent, AIChatSession, AIChatMessage, AIChatFolder, AIChatAttachment, ) from app.plugins.builtins.unified_search.models import ( # noqa: F401 SearchProviderRegistry as SearchProviderRegistryModel, SearchIndexLog, ) from app.plugins.builtins.ai_proactive import AIProactivePlugin from app.plugins.builtins.ai_assistant import AIAssistantPlugin from app.plugins.builtins.unified_search import UnifiedSearchPlugin from app.core.db import close_engine, reset_engine_for_testing from app.core.service_container import get_container from app.main import create_app from app.plugins.registry import reset_registry_for_testing from app.services.plugin_service import reset_plugin_service_for_testing from tests.conftest import ORIGIN_HEADER, _get_sync_engine, login_client, seed_tenant_and_users # ─── AI Proactive Fixtures ─── @pytest_asyncio.fixture(autouse=True) async def clean_ai_proactive_tables(): """Truncate ai_proactive tables before each test (not in conftest TRUNCATE list).""" sync_eng = _get_sync_engine() with sync_eng.connect() as conn: conn.execute( text( "TRUNCATE TABLE ai_proactive_suggestions, " "ai_proactive_context_log, ai_proactive_settings CASCADE;" ) ) conn.commit() sync_eng.dispose() yield @pytest_asyncio.fixture async def ai_proactive_app(engine: AsyncEngine, redis_client): """FastAPI app with AI Proactive plugin registered, installed, and activated.""" reset_engine_for_testing(engine) app = create_app() registry = reset_registry_for_testing() registry.initialize(engine, app) container = get_container() await container.initialize() registry.register_plugin(AIAssistantPlugin()) registry.register_plugin(UnifiedSearchPlugin()) registry.register_plugin(AIProactivePlugin()) reset_plugin_service_for_testing(registry) sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession) async with sf() as session: # Install dependencies first, then ai_proactive await registry.install(session, "ai_assistant") await registry.activate(session, "ai_assistant") await registry.install(session, "unified_search") await registry.activate(session, "unified_search") await registry.install(session, "ai_proactive") await registry.activate(session, "ai_proactive") await session.commit() yield app await close_engine() @pytest_asyncio.fixture async def ai_proactive_client(ai_proactive_app) -> AsyncClient: """HTTP test client with ai_proactive plugin active.""" transport = ASGITransport(app=ai_proactive_app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c @pytest_asyncio.fixture async def ai_proactive_authed_client( ai_proactive_client: AsyncClient, db_session: AsyncSession ) -> tuple[AsyncClient, dict]: """Authenticated admin client with seeded data and ai_proactive plugin active.""" seed = await seed_tenant_and_users(db_session) # Grant is_system_admin so require_permission passes for ai_proactive:* permissions from sqlalchemy import update from app.models.user import User await db_session.execute( update(User).where(User.id == seed["admin_a"].id).values(is_system_admin=True) ) await db_session.commit() # Login and capture CSRF token login_resp = await ai_proactive_client.post( "/api/v1/auth/login", json={"email": "admin@tenanta.com", "password": "TestPass123!"}, headers=ORIGIN_HEADER, ) assert login_resp.status_code == 200, f"Login failed: {login_resp.text}" csrf_token = login_resp.json().get("csrf_token", "") # Store CSRF token on client for use in requests ai_proactive_client.headers["X-CSRF-Token"] = csrf_token return ai_proactive_client, seed @pytest.fixture(autouse=True) async def mock_litellm(): """Mock litellm.acompletion and litellm.aembedding for all tests.""" mock_resp = MagicMock() mock_resp.choices = [MagicMock()] mock_resp.choices[0].message.content = json.dumps({ "suggestion_type": "info", "title": "Test Suggestion", "content": "This is a test suggestion", "confidence": 0.8, "actions": [ { "method": "GET", "path": "/api/v1/contacts", "body": {}, "description": "View contacts", } ], }) mock_emb_resp = MagicMock() mock_emb_resp.data = [{"embedding": [0.1] * 768}] with ( patch("litellm.acompletion", new_callable=AsyncMock, return_value=mock_resp), patch("litellm.aembedding", new_callable=AsyncMock, return_value=mock_emb_resp), ): yield # ─── 1. Context API (POST /api/v1/ai-proactive/context) ─── @pytest.mark.asyncio async def test_context_report_accepted( ai_proactive_authed_client: tuple[AsyncClient, dict], ): """Context-Report wird akzeptiert (200).""" client, _ = ai_proactive_authed_client resp = await client.post( "/api/v1/ai-proactive/context", json={ "page": "/contacts", "entity_type": "contact", "entity_id": str(uuid.uuid4()), }, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["status"] == "ok" @pytest.mark.asyncio async def test_context_unauthorized(ai_proactive_client: AsyncClient): """Ohne Auth → 403 (CSRF middleware blocks before auth check on POST).""" resp = await ai_proactive_client.post( "/api/v1/ai-proactive/context", json={"page": "/contacts"}, headers=ORIGIN_HEADER, ) # Without a session cookie, CSRF middleware returns 403 before auth is checked assert resp.status_code in (401, 403) # ─── 2. Suggestions API (GET /api/v1/ai-proactive/suggestions) ─── @pytest.mark.asyncio async def test_get_suggestions( ai_proactive_authed_client: tuple[AsyncClient, dict], db_session: AsyncSession, ): """Gibt Suggestions für User zurück.""" client, seed = ai_proactive_authed_client suggestion = ProactiveSuggestion( tenant_id=seed["tenant_a"].id, user_id=seed["admin_a"].id, entity_type="contact", entity_id=uuid.uuid4(), suggestion_type="info", title="Test Suggestion", content="Test content", confidence=0.8, actions=[], context_snapshot={}, ) db_session.add(suggestion) await db_session.commit() resp = await client.get( "/api/v1/ai-proactive/suggestions", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert "items" in data assert data["total"] >= 1 titles = [item["title"] for item in data["items"]] assert "Test Suggestion" in titles @pytest.mark.asyncio async def test_suggestions_tenant_isolation( ai_proactive_authed_client: tuple[AsyncClient, dict], db_session: AsyncSession, ): """Nur eigene Tenant — Tenant B suggestions are not visible to Tenant A.""" client, seed = ai_proactive_authed_client # Suggestion for tenant B user suggestion_b = ProactiveSuggestion( tenant_id=seed["tenant_b"].id, user_id=seed["admin_b"].id, entity_type="contact", entity_id=uuid.uuid4(), suggestion_type="info", title="Tenant B Secret", content="Should not be visible to A", confidence=0.8, actions=[], context_snapshot={}, ) # Suggestion for tenant A user suggestion_a = ProactiveSuggestion( tenant_id=seed["tenant_a"].id, user_id=seed["admin_a"].id, entity_type="contact", entity_id=uuid.uuid4(), suggestion_type="info", title="Tenant A Visible", content="Should be visible", confidence=0.8, actions=[], context_snapshot={}, ) db_session.add_all([suggestion_b, suggestion_a]) await db_session.commit() resp = await client.get( "/api/v1/ai-proactive/suggestions", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() titles = [item["title"] for item in data["items"]] assert "Tenant A Visible" in titles assert "Tenant B Secret" not in titles # ─── 3. Dismiss API (POST /api/v1/ai-proactive/suggestions/{id}/dismiss) ─── @pytest.mark.asyncio async def test_dismiss_suggestion( ai_proactive_authed_client: tuple[AsyncClient, dict], db_session: AsyncSession, ): """Suggestion wird dismissed.""" client, seed = ai_proactive_authed_client suggestion = ProactiveSuggestion( tenant_id=seed["tenant_a"].id, user_id=seed["admin_a"].id, entity_type="contact", entity_id=uuid.uuid4(), suggestion_type="info", title="Dismiss Me", content="Please dismiss this", confidence=0.8, actions=[], context_snapshot={}, ) db_session.add(suggestion) await db_session.commit() resp = await client.post( f"/api/v1/ai-proactive/suggestions/{suggestion.id}/dismiss", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 assert resp.json()["status"] == "ok" # Verify it no longer appears in active suggestions resp2 = await client.get( "/api/v1/ai-proactive/suggestions", headers=ORIGIN_HEADER, ) data = resp2.json() ids = [item["id"] for item in data["items"]] assert str(suggestion.id) not in ids # ─── 4. Settings API (GET/PUT /api/v1/ai-proactive/settings) ─── @pytest.mark.asyncio async def test_get_settings( ai_proactive_authed_client: tuple[AsyncClient, dict], ): """Settings werden zurückgegeben.""" client, _ = ai_proactive_authed_client resp = await client.get( "/api/v1/ai-proactive/settings", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert "enabled" in data assert "suggestion_categories" in data assert "confidence_threshold" in data assert "rate_limit_seconds" in data assert "model" in data assert "available_models" in data @pytest.mark.asyncio async def test_update_settings( ai_proactive_authed_client: tuple[AsyncClient, dict], ): """Settings können aktualisiert werden.""" client, _ = ai_proactive_authed_client resp = await client.put( "/api/v1/ai-proactive/settings", json={"enabled": False, "confidence_threshold": 0.7}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert data["enabled"] is False assert data["confidence_threshold"] == 0.7 @pytest.mark.asyncio async def test_update_model( ai_proactive_authed_client: tuple[AsyncClient, dict], ): """Model kann geändert werden.""" client, _ = ai_proactive_authed_client resp = await client.put( "/api/v1/ai-proactive/settings", json={"model": "ollama/llama3.2"}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert data["model"] == "ollama/llama3.2" # ─── 5. Stats API (GET /api/v1/ai-proactive/stats) ─── @pytest.mark.asyncio async def test_get_stats( ai_proactive_authed_client: tuple[AsyncClient, dict], ): """Stats werden zurückgegeben.""" client, _ = ai_proactive_authed_client resp = await client.get( "/api/v1/ai-proactive/stats", headers=ORIGIN_HEADER, ) assert resp.status_code == 200 data = resp.json() assert "total_suggestions" in data assert "dismissed" in data assert "acted_upon" in data assert "active" in data assert "dismiss_rate" in data assert "act_rate" in data # ─── 6. Proactive Engine (Unit Tests with mocks) ─── @pytest.mark.asyncio async def test_sse_push(): """Suggestion wird in Queue gepusht.""" from app.plugins.builtins.ai_proactive.services import get_sse_queue, push_suggestion user_id = f"test-sse-{uuid.uuid4()}" queue = get_sse_queue(user_id) # Ensure queue is empty while not queue.empty(): await queue.get() suggestion = {"id": "test-1", "title": "Test SSE Push"} await push_suggestion(user_id, suggestion) item = await asyncio.wait_for(queue.get(), timeout=1.0) assert item["id"] == "test-1" assert item["title"] == "Test SSE Push" @pytest.mark.asyncio async def test_rate_limiting(redis_client): """Rate-Limit funktioniert.""" from app.plugins.builtins.ai_proactive.services import is_rate_limited tenant_id = uuid.uuid4() user_id = uuid.uuid4() rate_limit_seconds = 10 with patch( "app.plugins.builtins.ai_proactive.services.get_cache", return_value=redis_client, ): # First call should not be rate limited limited = await is_rate_limited(tenant_id, user_id, rate_limit_seconds) assert limited is False # Second call should be rate limited limited = await is_rate_limited(tenant_id, user_id, rate_limit_seconds) assert limited is True