2026-07-18 11:21:51 +02:00
|
|
|
"""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
|
|
|
|
|
|
2026-07-18 13:48:20 +02:00
|
|
|
from app.plugins.builtins.ai_proactive.context_tools import (
|
|
|
|
|
get_contact_mails_handler,
|
|
|
|
|
get_contact_history_handler,
|
|
|
|
|
search_related_handler,
|
|
|
|
|
summarize_mail_thread_handler,
|
|
|
|
|
get_open_tasks_handler,
|
|
|
|
|
hybrid_search_handler,
|
|
|
|
|
register_context_tools,
|
|
|
|
|
)
|
|
|
|
|
from app.plugins.builtins.ai_proactive.services import (
|
|
|
|
|
gather_context,
|
|
|
|
|
generate_suggestion,
|
|
|
|
|
handle_context_change,
|
|
|
|
|
get_active_suggestions,
|
|
|
|
|
execute_suggested_action,
|
|
|
|
|
get_stats,
|
|
|
|
|
get_user_settings,
|
|
|
|
|
is_rate_limited,
|
|
|
|
|
)
|
|
|
|
|
from app.plugins.builtins.ai_proactive.models import ProactiveSettings
|
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
|
|
2026-07-18 11:21:51 +02:00
|
|
|
# ─── 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
|
2026-07-18 13:48:20 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Extended: Context Tools ───
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_contact_mails_handler(db_session: AsyncSession):
|
|
|
|
|
"""get_contact_mails_handler returns mails for a contact."""
|
|
|
|
|
from app.models.contact import Contact
|
|
|
|
|
from app.models.tenant import Tenant
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.core.auth import hash_password
|
|
|
|
|
from app.plugins.builtins.mail.models import Mail, MailAccount, MailFolder
|
|
|
|
|
|
|
|
|
|
tenant = Tenant(name="CT Tenant", slug="ct-tenant")
|
|
|
|
|
db_session.add(tenant)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
user = User(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
email="ct@example.com",
|
|
|
|
|
name="CT",
|
|
|
|
|
password_hash=hash_password("TestPass123!"),
|
|
|
|
|
role="admin",
|
|
|
|
|
is_active=True,
|
|
|
|
|
preferences={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(user)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
account = MailAccount(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
email_address="ct@example.com",
|
|
|
|
|
display_name="CT Account",
|
|
|
|
|
imap_host="localhost",
|
|
|
|
|
imap_port=993,
|
|
|
|
|
imap_ssl=True,
|
|
|
|
|
smtp_host="localhost",
|
|
|
|
|
smtp_port=587,
|
|
|
|
|
smtp_tls=True,
|
|
|
|
|
username="ct@example.com",
|
|
|
|
|
encrypted_password="encrypted",
|
|
|
|
|
is_shared=False,
|
|
|
|
|
is_active=True,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(account)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
folder = MailFolder(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
account_id=account.id,
|
|
|
|
|
name="INBOX",
|
|
|
|
|
imap_name="INBOX",
|
|
|
|
|
is_standard=True,
|
|
|
|
|
unread_count=0,
|
|
|
|
|
total_count=0,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(folder)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
contact = Contact(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
first_name="CT",
|
|
|
|
|
last_name="Contact",
|
|
|
|
|
email="ctcontact@example.com",
|
|
|
|
|
created_by=user.id,
|
|
|
|
|
updated_by=user.id,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(contact)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
mail = Mail(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
account_id=account.id,
|
|
|
|
|
folder_id=folder.id,
|
|
|
|
|
message_id="msg-ct-1",
|
|
|
|
|
thread_id="thread-ct-1",
|
|
|
|
|
from_address="sender@example.com",
|
|
|
|
|
to_addresses="ctcontact@example.com",
|
|
|
|
|
cc_addresses="",
|
|
|
|
|
bcc_addresses="",
|
|
|
|
|
subject="Test Mail",
|
|
|
|
|
body_text="Test body",
|
|
|
|
|
body_html="",
|
|
|
|
|
body_html_sanitized="",
|
|
|
|
|
is_seen=False,
|
|
|
|
|
is_flagged=False,
|
|
|
|
|
is_draft=False,
|
|
|
|
|
is_answered=False,
|
|
|
|
|
is_forwarded=False,
|
|
|
|
|
has_attachments=False,
|
|
|
|
|
size_bytes=0,
|
|
|
|
|
received_at=datetime.now(UTC),
|
|
|
|
|
contact_id=contact.id,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(mail)
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
context = {
|
|
|
|
|
"tenant_id": str(tenant.id),
|
|
|
|
|
"user_id": str(user.id),
|
|
|
|
|
"db": db_session,
|
|
|
|
|
}
|
|
|
|
|
result = await get_contact_mails_handler(
|
|
|
|
|
{"contact_id": str(contact.id), "limit": 10}, context
|
|
|
|
|
)
|
|
|
|
|
import json as _json
|
|
|
|
|
parsed = _json.loads(result)
|
|
|
|
|
assert "mails" in parsed
|
|
|
|
|
assert parsed["count"] >= 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_contact_history_handler(db_session: AsyncSession):
|
|
|
|
|
"""get_contact_history_handler returns audit log entries."""
|
|
|
|
|
from app.models.contact import Contact
|
|
|
|
|
from app.models.tenant import Tenant
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.models.audit import AuditLog
|
|
|
|
|
from app.core.auth import hash_password
|
|
|
|
|
|
|
|
|
|
tenant = Tenant(name="Hist Tenant", slug="hist-tenant")
|
|
|
|
|
db_session.add(tenant)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
user = User(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
email="hist@example.com",
|
|
|
|
|
name="Hist",
|
|
|
|
|
password_hash=hash_password("TestPass123!"),
|
|
|
|
|
role="admin",
|
|
|
|
|
is_active=True,
|
|
|
|
|
preferences={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(user)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
contact = Contact(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
first_name="Hist",
|
|
|
|
|
last_name="Contact",
|
|
|
|
|
email="hist@example.com",
|
|
|
|
|
created_by=user.id,
|
|
|
|
|
updated_by=user.id,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(contact)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
audit = AuditLog(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
entity_type="contact",
|
|
|
|
|
entity_id=contact.id,
|
|
|
|
|
action="created",
|
|
|
|
|
timestamp=datetime.now(UTC),
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(audit)
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
context = {
|
|
|
|
|
"tenant_id": str(tenant.id),
|
|
|
|
|
"user_id": str(user.id),
|
|
|
|
|
"db": db_session,
|
|
|
|
|
}
|
|
|
|
|
result = await get_contact_history_handler(
|
|
|
|
|
{"entity_id": str(contact.id), "limit": 20}, context
|
|
|
|
|
)
|
|
|
|
|
import json as _json
|
|
|
|
|
parsed = _json.loads(result)
|
|
|
|
|
assert "activities" in parsed
|
|
|
|
|
assert parsed["count"] >= 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_search_related_handler(db_session: AsyncSession):
|
|
|
|
|
"""search_related_handler returns similar entities."""
|
|
|
|
|
from app.models.contact import Contact
|
|
|
|
|
from app.models.tenant import Tenant
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.core.auth import hash_password
|
|
|
|
|
|
|
|
|
|
tenant = Tenant(name="Rel Tenant", slug="rel-tenant")
|
|
|
|
|
db_session.add(tenant)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
user = User(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
email="rel@example.com",
|
|
|
|
|
name="Rel",
|
|
|
|
|
password_hash=hash_password("TestPass123!"),
|
|
|
|
|
role="admin",
|
|
|
|
|
is_active=True,
|
|
|
|
|
preferences={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(user)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
contact = Contact(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
first_name="Rel",
|
|
|
|
|
last_name="Contact",
|
|
|
|
|
email="rel@example.com",
|
|
|
|
|
created_by=user.id,
|
|
|
|
|
updated_by=user.id,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(contact)
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
context = {
|
|
|
|
|
"tenant_id": str(tenant.id),
|
|
|
|
|
"user_id": str(user.id),
|
|
|
|
|
"db": db_session,
|
|
|
|
|
}
|
|
|
|
|
result = await search_related_handler(
|
|
|
|
|
{"entity_type": "contact", "entity_id": str(contact.id), "limit": 5},
|
|
|
|
|
context,
|
|
|
|
|
)
|
|
|
|
|
import json as _json
|
|
|
|
|
parsed = _json.loads(result)
|
|
|
|
|
assert "similar" in parsed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_summarize_mail_thread_handler(db_session: AsyncSession):
|
|
|
|
|
"""summarize_mail_thread_handler returns a summary."""
|
|
|
|
|
from app.models.tenant import Tenant
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.plugins.builtins.mail.models import Mail, MailAccount, MailFolder
|
|
|
|
|
from app.core.auth import hash_password
|
|
|
|
|
|
|
|
|
|
tenant = Tenant(name="Thread Tenant", slug="thread-tenant")
|
|
|
|
|
db_session.add(tenant)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
user = User(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
email="thread@example.com",
|
|
|
|
|
name="Thread",
|
|
|
|
|
password_hash=hash_password("TestPass123!"),
|
|
|
|
|
role="admin",
|
|
|
|
|
is_active=True,
|
|
|
|
|
preferences={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(user)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
account = MailAccount(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
email_address="thread@example.com",
|
|
|
|
|
display_name="Thread Account",
|
|
|
|
|
imap_host="localhost",
|
|
|
|
|
imap_port=993,
|
|
|
|
|
imap_ssl=True,
|
|
|
|
|
smtp_host="localhost",
|
|
|
|
|
smtp_port=587,
|
|
|
|
|
smtp_tls=True,
|
|
|
|
|
username="thread@example.com",
|
|
|
|
|
encrypted_password="encrypted",
|
|
|
|
|
is_shared=False,
|
|
|
|
|
is_active=True,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(account)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
folder = MailFolder(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
account_id=account.id,
|
|
|
|
|
name="INBOX",
|
|
|
|
|
imap_name="INBOX",
|
|
|
|
|
is_standard=True,
|
|
|
|
|
unread_count=0,
|
|
|
|
|
total_count=0,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(folder)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
thread_id = "thread-123"
|
|
|
|
|
mail = Mail(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
account_id=account.id,
|
|
|
|
|
folder_id=folder.id,
|
|
|
|
|
message_id="msg-thread-1",
|
|
|
|
|
thread_id=thread_id,
|
|
|
|
|
from_address="sender@example.com",
|
|
|
|
|
to_addresses="recipient@example.com",
|
|
|
|
|
cc_addresses="",
|
|
|
|
|
bcc_addresses="",
|
|
|
|
|
subject="Thread Mail",
|
|
|
|
|
body_text="Thread body content",
|
|
|
|
|
body_html="",
|
|
|
|
|
body_html_sanitized="",
|
|
|
|
|
is_seen=False,
|
|
|
|
|
is_flagged=False,
|
|
|
|
|
is_draft=False,
|
|
|
|
|
is_answered=False,
|
|
|
|
|
is_forwarded=False,
|
|
|
|
|
has_attachments=False,
|
|
|
|
|
size_bytes=0,
|
|
|
|
|
received_at=datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
db_session.add(mail)
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
context = {
|
|
|
|
|
"tenant_id": str(tenant.id),
|
|
|
|
|
"user_id": str(user.id),
|
|
|
|
|
"db": db_session,
|
|
|
|
|
}
|
|
|
|
|
result = await summarize_mail_thread_handler(
|
|
|
|
|
{"thread_id": thread_id, "limit": 20}, context
|
|
|
|
|
)
|
|
|
|
|
import json as _json
|
|
|
|
|
parsed = _json.loads(result)
|
|
|
|
|
assert "summary" in parsed
|
|
|
|
|
assert "count" in parsed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_open_tasks_handler(db_session: AsyncSession):
|
|
|
|
|
"""get_open_tasks_handler returns open tasks."""
|
|
|
|
|
from app.models.contact import Contact
|
|
|
|
|
from app.models.tenant import Tenant
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.core.auth import hash_password
|
|
|
|
|
|
|
|
|
|
tenant = Tenant(name="Task Tenant", slug="task-tenant")
|
|
|
|
|
db_session.add(tenant)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
user = User(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
email="task@example.com",
|
|
|
|
|
name="Task",
|
|
|
|
|
password_hash=hash_password("TestPass123!"),
|
|
|
|
|
role="admin",
|
|
|
|
|
is_active=True,
|
|
|
|
|
preferences={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(user)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
contact = Contact(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
first_name="Task",
|
|
|
|
|
last_name="Contact",
|
|
|
|
|
email="task@example.com",
|
|
|
|
|
created_by=user.id,
|
|
|
|
|
updated_by=user.id,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(contact)
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
context = {
|
|
|
|
|
"tenant_id": str(tenant.id),
|
|
|
|
|
"user_id": str(user.id),
|
|
|
|
|
"db": db_session,
|
|
|
|
|
}
|
|
|
|
|
result = await get_open_tasks_handler(
|
|
|
|
|
{"entity_type": "contact", "entity_id": str(contact.id)},
|
|
|
|
|
context,
|
|
|
|
|
)
|
|
|
|
|
import json as _json
|
|
|
|
|
parsed = _json.loads(result)
|
|
|
|
|
assert "tasks" in parsed
|
|
|
|
|
assert "count" in parsed
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_hybrid_search_handler(db_session: AsyncSession):
|
|
|
|
|
"""hybrid_search_handler returns search results."""
|
|
|
|
|
from app.models.tenant import Tenant
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.core.auth import hash_password
|
|
|
|
|
from app.plugins.builtins.unified_search.provider_registry import (
|
|
|
|
|
get_search_registry,
|
|
|
|
|
)
|
|
|
|
|
from app.plugins.builtins.unified_search.providers.contact_provider import (
|
|
|
|
|
ContactSearchProvider,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
tenant = Tenant(name="HS Tenant", slug="hs-tenant")
|
|
|
|
|
db_session.add(tenant)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
user = User(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
email="hs@example.com",
|
|
|
|
|
name="HS",
|
|
|
|
|
password_hash=hash_password("TestPass123!"),
|
|
|
|
|
role="admin",
|
|
|
|
|
is_active=True,
|
|
|
|
|
preferences={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(user)
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
registry = get_search_registry()
|
|
|
|
|
registry.clear()
|
|
|
|
|
registry.register(ContactSearchProvider())
|
|
|
|
|
|
|
|
|
|
context = {
|
|
|
|
|
"tenant_id": str(tenant.id),
|
|
|
|
|
"user_id": str(user.id),
|
|
|
|
|
"db": db_session,
|
|
|
|
|
}
|
|
|
|
|
result = await hybrid_search_handler(
|
|
|
|
|
{"query": "test", "limit": 5}, context
|
|
|
|
|
)
|
|
|
|
|
import json as _json
|
|
|
|
|
parsed = _json.loads(result)
|
|
|
|
|
assert "results" in parsed
|
|
|
|
|
assert "count" in parsed
|
|
|
|
|
registry.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_register_context_tools():
|
|
|
|
|
"""register_context_tools registers 6 tools."""
|
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
|
|
|
|
|
|
mock_registry = MagicMock()
|
|
|
|
|
register_context_tools(mock_registry)
|
|
|
|
|
assert mock_registry.register.call_count == 6
|
|
|
|
|
registered_names = [
|
|
|
|
|
call.kwargs.get("name") for call in mock_registry.register.call_args_list
|
|
|
|
|
]
|
|
|
|
|
assert "get_contact_mails" in registered_names
|
|
|
|
|
assert "get_contact_history" in registered_names
|
|
|
|
|
assert "search_related" in registered_names
|
|
|
|
|
assert "summarize_mail_thread" in registered_names
|
|
|
|
|
assert "get_open_tasks" in registered_names
|
|
|
|
|
assert "hybrid_search" in registered_names
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Extended: Proactive Engine ───
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gather_context_contact(db_session: AsyncSession):
|
|
|
|
|
"""gather_context for contact collects data."""
|
|
|
|
|
from app.models.contact import Contact
|
|
|
|
|
from app.models.tenant import Tenant
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.core.auth import hash_password
|
|
|
|
|
|
|
|
|
|
tenant = Tenant(name="GC Tenant", slug="gc-tenant")
|
|
|
|
|
db_session.add(tenant)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
user = User(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
email="gc@example.com",
|
|
|
|
|
name="GC",
|
|
|
|
|
password_hash=hash_password("TestPass123!"),
|
|
|
|
|
role="admin",
|
|
|
|
|
is_active=True,
|
|
|
|
|
preferences={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(user)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
contact = Contact(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
first_name="GC",
|
|
|
|
|
last_name="Contact",
|
|
|
|
|
email="gc@example.com",
|
|
|
|
|
created_by=user.id,
|
|
|
|
|
updated_by=user.id,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(contact)
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
context = await gather_context(db_session, "contact", contact.id, tenant.id)
|
|
|
|
|
assert context["entity_type"] == "contact"
|
|
|
|
|
assert context["entity_id"] == str(contact.id)
|
|
|
|
|
assert "contact" in context
|
|
|
|
|
assert "mails" in context
|
|
|
|
|
assert "company" in context
|
|
|
|
|
assert "companies" in context
|
|
|
|
|
assert "events" in context
|
|
|
|
|
assert "activities" in context
|
|
|
|
|
assert "similar" in context
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gather_context_mail(db_session: AsyncSession):
|
|
|
|
|
"""gather_context for mail collects data."""
|
|
|
|
|
from app.models.tenant import Tenant
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.plugins.builtins.mail.models import Mail, MailAccount, MailFolder
|
|
|
|
|
from app.core.auth import hash_password
|
|
|
|
|
|
|
|
|
|
tenant = Tenant(name="GM Tenant", slug="gm-tenant")
|
|
|
|
|
db_session.add(tenant)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
user = User(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
email="gm@example.com",
|
|
|
|
|
name="GM",
|
|
|
|
|
password_hash=hash_password("TestPass123!"),
|
|
|
|
|
role="admin",
|
|
|
|
|
is_active=True,
|
|
|
|
|
preferences={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(user)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
account = MailAccount(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
email_address="gm@example.com",
|
|
|
|
|
display_name="GM Account",
|
|
|
|
|
imap_host="localhost",
|
|
|
|
|
imap_port=993,
|
|
|
|
|
imap_ssl=True,
|
|
|
|
|
smtp_host="localhost",
|
|
|
|
|
smtp_port=587,
|
|
|
|
|
smtp_tls=True,
|
|
|
|
|
username="gm@example.com",
|
|
|
|
|
encrypted_password="encrypted",
|
|
|
|
|
is_shared=False,
|
|
|
|
|
is_active=True,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(account)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
folder = MailFolder(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
account_id=account.id,
|
|
|
|
|
name="INBOX",
|
|
|
|
|
imap_name="INBOX",
|
|
|
|
|
is_standard=True,
|
|
|
|
|
unread_count=0,
|
|
|
|
|
total_count=0,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(folder)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
mail = Mail(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
account_id=account.id,
|
|
|
|
|
folder_id=folder.id,
|
|
|
|
|
message_id="msg-gm-1",
|
|
|
|
|
thread_id="thread-gm-1",
|
|
|
|
|
from_address="sender@example.com",
|
|
|
|
|
to_addresses="gm@example.com",
|
|
|
|
|
cc_addresses="",
|
|
|
|
|
bcc_addresses="",
|
|
|
|
|
subject="GM Subject",
|
|
|
|
|
body_text="GM body",
|
|
|
|
|
body_html="",
|
|
|
|
|
body_html_sanitized="",
|
|
|
|
|
is_seen=False,
|
|
|
|
|
is_flagged=False,
|
|
|
|
|
is_draft=False,
|
|
|
|
|
is_answered=False,
|
|
|
|
|
is_forwarded=False,
|
|
|
|
|
has_attachments=False,
|
|
|
|
|
size_bytes=0,
|
|
|
|
|
received_at=datetime.now(UTC),
|
|
|
|
|
)
|
|
|
|
|
db_session.add(mail)
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
context = await gather_context(db_session, "mail", mail.id, tenant.id)
|
|
|
|
|
assert context["entity_type"] == "mail"
|
|
|
|
|
assert context["entity_id"] == str(mail.id)
|
|
|
|
|
assert "mail" in context
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_gather_context_company(db_session: AsyncSession):
|
|
|
|
|
"""gather_context for company collects data."""
|
|
|
|
|
from app.models.company import Company
|
|
|
|
|
from app.models.tenant import Tenant
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.core.auth import hash_password
|
|
|
|
|
|
|
|
|
|
tenant = Tenant(name="GC2 Tenant", slug="gc2-tenant")
|
|
|
|
|
db_session.add(tenant)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
user = User(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
email="gc2@example.com",
|
|
|
|
|
name="GC2",
|
|
|
|
|
password_hash=hash_password("TestPass123!"),
|
|
|
|
|
role="admin",
|
|
|
|
|
is_active=True,
|
|
|
|
|
preferences={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(user)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
company = Company(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
name="GC2 Company",
|
|
|
|
|
industry="IT",
|
|
|
|
|
created_by=user.id,
|
|
|
|
|
updated_by=user.id,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(company)
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
context = await gather_context(db_session, "company", company.id, tenant.id)
|
|
|
|
|
assert context["entity_type"] == "company"
|
|
|
|
|
assert context["entity_id"] == str(company.id)
|
|
|
|
|
assert "company" in context
|
|
|
|
|
assert "contacts" in context
|
|
|
|
|
assert "mails" in context
|
|
|
|
|
assert "events" in context
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_generate_suggestion_success():
|
|
|
|
|
"""generate_suggestion returns a suggestion dict."""
|
|
|
|
|
settings = ProactiveSettings(
|
|
|
|
|
tenant_id=uuid.uuid4(),
|
|
|
|
|
user_id=uuid.uuid4(),
|
|
|
|
|
enabled=True,
|
|
|
|
|
suggestion_categories=["mail", "tasks"],
|
|
|
|
|
confidence_threshold=0.5,
|
|
|
|
|
rate_limit_seconds=10,
|
|
|
|
|
model="ollama/deepseek-v4",
|
|
|
|
|
)
|
|
|
|
|
context_data = {"entity_type": "contact", "contact": {"first_name": "Test"}}
|
|
|
|
|
result = await generate_suggestion(context_data, settings)
|
|
|
|
|
assert result is not None
|
|
|
|
|
assert "suggestion_type" in result
|
|
|
|
|
assert "title" in result
|
|
|
|
|
assert "content" in result
|
|
|
|
|
assert "confidence" in result
|
|
|
|
|
assert "actions" in result
|
|
|
|
|
assert isinstance(result["actions"], list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_generate_suggestion_llm_failure():
|
|
|
|
|
"""generate_suggestion returns None on LLM failure."""
|
|
|
|
|
settings = ProactiveSettings(
|
|
|
|
|
tenant_id=uuid.uuid4(),
|
|
|
|
|
user_id=uuid.uuid4(),
|
|
|
|
|
enabled=True,
|
|
|
|
|
suggestion_categories=["mail"],
|
|
|
|
|
confidence_threshold=0.5,
|
|
|
|
|
rate_limit_seconds=10,
|
|
|
|
|
model="ollama/deepseek-v4",
|
|
|
|
|
)
|
|
|
|
|
with patch("litellm.acompletion", new_callable=AsyncMock, side_effect=Exception("LLM error")):
|
|
|
|
|
result = await generate_suggestion({"entity_type": "contact"}, settings)
|
|
|
|
|
assert result is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@patch("app.plugins.builtins.ai_proactive.services.create_db_session")
|
|
|
|
|
async def test_handle_context_change_rate_limited(mock_create_session, redis_client):
|
|
|
|
|
"""Rate-limit prevents suggestion generation."""
|
|
|
|
|
tenant_id = uuid.uuid4()
|
|
|
|
|
user_id = uuid.uuid4()
|
|
|
|
|
|
|
|
|
|
# Pre-set rate limit key in Redis
|
|
|
|
|
await redis_client.setex(f"ai_proactive:rate:{user_id}", 10, "1")
|
|
|
|
|
|
|
|
|
|
mock_session = AsyncMock()
|
|
|
|
|
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
|
|
|
|
mock_session.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
mock_create_session.return_value = mock_session
|
|
|
|
|
|
|
|
|
|
# Mock settings to be enabled so we reach the rate limit check
|
|
|
|
|
enabled_settings = ProactiveSettings(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
enabled=True,
|
|
|
|
|
suggestion_categories=["mail"],
|
|
|
|
|
confidence_threshold=0.5,
|
|
|
|
|
rate_limit_seconds=10,
|
|
|
|
|
model="ollama/deepseek-v4",
|
|
|
|
|
)
|
|
|
|
|
with (
|
|
|
|
|
patch("app.plugins.builtins.ai_proactive.services.get_cache", return_value=redis_client),
|
|
|
|
|
patch(
|
|
|
|
|
"app.plugins.builtins.ai_proactive.services.get_user_settings",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
return_value=enabled_settings,
|
|
|
|
|
),
|
|
|
|
|
):
|
|
|
|
|
await handle_context_change({
|
|
|
|
|
"user_id": str(user_id),
|
|
|
|
|
"tenant_id": str(tenant_id),
|
|
|
|
|
"entity_type": "contact",
|
|
|
|
|
"entity_id": str(uuid.uuid4()),
|
|
|
|
|
})
|
|
|
|
|
# If rate-limited, gather_context should not be called
|
|
|
|
|
mock_session.execute.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@patch("app.plugins.builtins.ai_proactive.services.create_db_session")
|
|
|
|
|
async def test_handle_context_change_disabled(mock_create_session, redis_client):
|
|
|
|
|
"""Settings disabled → no suggestion."""
|
|
|
|
|
tenant_id = uuid.uuid4()
|
|
|
|
|
user_id = uuid.uuid4()
|
|
|
|
|
|
|
|
|
|
mock_session = AsyncMock()
|
|
|
|
|
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
|
|
|
|
mock_session.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
mock_create_session.return_value = mock_session
|
|
|
|
|
|
|
|
|
|
# Mock get_user_settings to return disabled settings
|
|
|
|
|
disabled_settings = ProactiveSettings(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
enabled=False,
|
|
|
|
|
suggestion_categories=[],
|
|
|
|
|
confidence_threshold=0.5,
|
|
|
|
|
rate_limit_seconds=10,
|
|
|
|
|
model="ollama/deepseek-v4",
|
|
|
|
|
)
|
|
|
|
|
with (
|
|
|
|
|
patch("app.plugins.builtins.ai_proactive.services.get_cache", return_value=redis_client),
|
|
|
|
|
patch(
|
|
|
|
|
"app.plugins.builtins.ai_proactive.services.get_user_settings",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
return_value=disabled_settings,
|
|
|
|
|
),
|
|
|
|
|
):
|
|
|
|
|
await handle_context_change({
|
|
|
|
|
"user_id": str(user_id),
|
|
|
|
|
"tenant_id": str(tenant_id),
|
|
|
|
|
"entity_type": "contact",
|
|
|
|
|
"entity_id": str(uuid.uuid4()),
|
|
|
|
|
})
|
|
|
|
|
# Disabled settings means early return — no suggestion generated
|
|
|
|
|
# Verify gather_context was not called
|
|
|
|
|
mock_session.execute.assert_not_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_get_active_suggestions_expired(db_session: AsyncSession):
|
|
|
|
|
"""Expired suggestions are not returned by get_active_suggestions."""
|
|
|
|
|
from app.models.tenant import Tenant
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.core.auth import hash_password
|
|
|
|
|
|
|
|
|
|
tenant = Tenant(name="Exp Tenant", slug="exp-tenant")
|
|
|
|
|
db_session.add(tenant)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
user = User(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
email="exp@example.com",
|
|
|
|
|
name="Exp",
|
|
|
|
|
password_hash=hash_password("TestPass123!"),
|
|
|
|
|
role="admin",
|
|
|
|
|
is_active=True,
|
|
|
|
|
preferences={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(user)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
tenant_id = tenant.id
|
|
|
|
|
user_id = user.id
|
|
|
|
|
|
|
|
|
|
# Create an expired suggestion
|
|
|
|
|
expired = ProactiveSuggestion(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
entity_type="contact",
|
|
|
|
|
entity_id=uuid.uuid4(),
|
|
|
|
|
suggestion_type="info",
|
|
|
|
|
title="Expired",
|
|
|
|
|
content="This is expired",
|
|
|
|
|
confidence=0.8,
|
|
|
|
|
actions=[],
|
|
|
|
|
context_snapshot={},
|
|
|
|
|
expires_at=datetime.now(UTC) - timedelta(hours=1),
|
|
|
|
|
)
|
|
|
|
|
# Create a valid (non-expired) suggestion
|
|
|
|
|
valid = ProactiveSuggestion(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
entity_type="contact",
|
|
|
|
|
entity_id=uuid.uuid4(),
|
|
|
|
|
suggestion_type="info",
|
|
|
|
|
title="Valid",
|
|
|
|
|
content="This is valid",
|
|
|
|
|
confidence=0.8,
|
|
|
|
|
actions=[],
|
|
|
|
|
context_snapshot={},
|
|
|
|
|
expires_at=None,
|
|
|
|
|
)
|
|
|
|
|
db_session.add_all([expired, valid])
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
suggestions = await get_active_suggestions(db_session, tenant_id, user_id)
|
|
|
|
|
titles = [s.title for s in suggestions]
|
|
|
|
|
assert "Valid" in titles
|
|
|
|
|
assert "Expired" not in titles
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_execute_suggested_action_success(db_session: AsyncSession):
|
|
|
|
|
"""execute_suggested_action executes action and marks suggestion."""
|
|
|
|
|
from app.models.tenant import Tenant
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.core.auth import hash_password
|
|
|
|
|
|
|
|
|
|
tenant = Tenant(name="ESA Tenant", slug="esa-tenant")
|
|
|
|
|
db_session.add(tenant)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
user = User(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
email="esa@example.com",
|
|
|
|
|
name="ESA",
|
|
|
|
|
password_hash=hash_password("TestPass123!"),
|
|
|
|
|
role="admin",
|
|
|
|
|
is_active=True,
|
|
|
|
|
preferences={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(user)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
tenant_id = tenant.id
|
|
|
|
|
user_id = user.id
|
|
|
|
|
|
|
|
|
|
suggestion = ProactiveSuggestion(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
entity_type="contact",
|
|
|
|
|
entity_id=uuid.uuid4(),
|
|
|
|
|
suggestion_type="action",
|
|
|
|
|
title="Act on me",
|
|
|
|
|
content="Please act",
|
|
|
|
|
confidence=0.9,
|
|
|
|
|
actions=[
|
|
|
|
|
{
|
|
|
|
|
"method": "GET",
|
|
|
|
|
"path": "/api/v1/contacts",
|
|
|
|
|
"body": {},
|
|
|
|
|
"description": "View contacts",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
context_snapshot={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(suggestion)
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
with patch("httpx.AsyncClient") as mock_client_cls:
|
|
|
|
|
mock_client = AsyncMock()
|
|
|
|
|
mock_resp = MagicMock()
|
|
|
|
|
mock_resp.status_code = 200
|
|
|
|
|
mock_resp.json.return_value = {"status": "ok"}
|
|
|
|
|
mock_resp.text = '{"status": "ok"}'
|
|
|
|
|
mock_client.request = AsyncMock(return_value=mock_resp)
|
|
|
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
|
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
mock_client_cls.return_value = mock_client
|
|
|
|
|
|
|
|
|
|
result = await execute_suggested_action(
|
|
|
|
|
db_session,
|
|
|
|
|
suggestion.id,
|
|
|
|
|
0,
|
|
|
|
|
user_id,
|
|
|
|
|
tenant_id,
|
|
|
|
|
{"session_id": "test-session"},
|
|
|
|
|
)
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
assert result["data"] == {"status": "ok"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_execute_suggested_action_invalid_index(db_session: AsyncSession):
|
|
|
|
|
"""execute_suggested_action with invalid index returns error."""
|
|
|
|
|
from app.models.tenant import Tenant
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.core.auth import hash_password
|
|
|
|
|
|
|
|
|
|
tenant = Tenant(name="InvIdx Tenant", slug="invidx-tenant")
|
|
|
|
|
db_session.add(tenant)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
user = User(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
email="invidx@example.com",
|
|
|
|
|
name="InvIdx",
|
|
|
|
|
password_hash=hash_password("TestPass123!"),
|
|
|
|
|
role="admin",
|
|
|
|
|
is_active=True,
|
|
|
|
|
preferences={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(user)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
tenant_id = tenant.id
|
|
|
|
|
user_id = user.id
|
|
|
|
|
|
|
|
|
|
suggestion = ProactiveSuggestion(
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
entity_type="contact",
|
|
|
|
|
entity_id=uuid.uuid4(),
|
|
|
|
|
suggestion_type="info",
|
|
|
|
|
title="No actions",
|
|
|
|
|
content="No actions here",
|
|
|
|
|
confidence=0.5,
|
|
|
|
|
actions=[],
|
|
|
|
|
context_snapshot={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(suggestion)
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
result = await execute_suggested_action(
|
|
|
|
|
db_session,
|
|
|
|
|
suggestion.id,
|
|
|
|
|
0,
|
|
|
|
|
user_id,
|
|
|
|
|
tenant_id,
|
|
|
|
|
{},
|
|
|
|
|
)
|
|
|
|
|
assert result["success"] is False
|
|
|
|
|
assert "Invalid action index" in result["error"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Extended: API Tests ───
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_act_on_suggestion(
|
|
|
|
|
ai_proactive_authed_client: tuple[AsyncClient, dict],
|
|
|
|
|
db_session: AsyncSession,
|
|
|
|
|
):
|
|
|
|
|
"""POST /suggestions/{id}/act executes action."""
|
|
|
|
|
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="action",
|
|
|
|
|
title="Act Test",
|
|
|
|
|
content="Act on this",
|
|
|
|
|
confidence=0.9,
|
|
|
|
|
actions=[
|
|
|
|
|
{
|
|
|
|
|
"method": "GET",
|
|
|
|
|
"path": "/api/v1/contacts",
|
|
|
|
|
"body": {},
|
|
|
|
|
"description": "View contacts",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
context_snapshot={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(suggestion)
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
with patch("httpx.AsyncClient") as mock_client_cls:
|
|
|
|
|
mock_client = AsyncMock()
|
|
|
|
|
mock_resp = MagicMock()
|
|
|
|
|
mock_resp.status_code = 200
|
|
|
|
|
mock_resp.json.return_value = {"status": "ok"}
|
|
|
|
|
mock_resp.text = '{"status": "ok"}'
|
|
|
|
|
mock_client.request = AsyncMock(return_value=mock_resp)
|
|
|
|
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
|
|
|
mock_client.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
mock_client_cls.return_value = mock_client
|
|
|
|
|
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
f"/api/v1/ai-proactive/suggestions/{suggestion.id}/act",
|
|
|
|
|
json={"action_index": 0},
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
data = resp.json()
|
|
|
|
|
assert data["success"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_act_on_suggestion_invalid_index(
|
|
|
|
|
ai_proactive_authed_client: tuple[AsyncClient, dict],
|
|
|
|
|
db_session: AsyncSession,
|
|
|
|
|
):
|
|
|
|
|
"""POST /act with invalid index → 200 with success=false (not 400/422)."""
|
|
|
|
|
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="No Actions",
|
|
|
|
|
content="No actions",
|
|
|
|
|
confidence=0.5,
|
|
|
|
|
actions=[],
|
|
|
|
|
context_snapshot={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(suggestion)
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
resp = await client.post(
|
|
|
|
|
f"/api/v1/ai-proactive/suggestions/{suggestion.id}/act",
|
|
|
|
|
json={"action_index": 0},
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
data = resp.json()
|
|
|
|
|
assert data["success"] is False
|
|
|
|
|
assert data["error"] is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_suggestions_filter_by_entity_type(
|
|
|
|
|
ai_proactive_authed_client: tuple[AsyncClient, dict],
|
|
|
|
|
db_session: AsyncSession,
|
|
|
|
|
):
|
|
|
|
|
"""GET /suggestions?entity_type=contact filters correctly."""
|
|
|
|
|
client, seed = ai_proactive_authed_client
|
|
|
|
|
s_contact = ProactiveSuggestion(
|
|
|
|
|
tenant_id=seed["tenant_a"].id,
|
|
|
|
|
user_id=seed["admin_a"].id,
|
|
|
|
|
entity_type="contact",
|
|
|
|
|
entity_id=uuid.uuid4(),
|
|
|
|
|
suggestion_type="info",
|
|
|
|
|
title="Contact Suggestion",
|
|
|
|
|
content="For contact",
|
|
|
|
|
confidence=0.8,
|
|
|
|
|
actions=[],
|
|
|
|
|
context_snapshot={},
|
|
|
|
|
)
|
|
|
|
|
s_company = ProactiveSuggestion(
|
|
|
|
|
tenant_id=seed["tenant_a"].id,
|
|
|
|
|
user_id=seed["admin_a"].id,
|
|
|
|
|
entity_type="company",
|
|
|
|
|
entity_id=uuid.uuid4(),
|
|
|
|
|
suggestion_type="info",
|
|
|
|
|
title="Company Suggestion",
|
|
|
|
|
content="For company",
|
|
|
|
|
confidence=0.8,
|
|
|
|
|
actions=[],
|
|
|
|
|
context_snapshot={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add_all([s_contact, s_company])
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
resp = await client.get(
|
|
|
|
|
"/api/v1/ai-proactive/suggestions",
|
|
|
|
|
params={"entity_type": "contact"},
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
data = resp.json()
|
|
|
|
|
titles = [item["title"] for item in data["items"]]
|
|
|
|
|
assert "Contact Suggestion" in titles
|
|
|
|
|
assert "Company Suggestion" not in titles
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_settings_available_models(
|
|
|
|
|
ai_proactive_authed_client: tuple[AsyncClient, dict],
|
|
|
|
|
):
|
|
|
|
|
"""GET /settings returns available_models."""
|
|
|
|
|
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 "available_models" in data
|
|
|
|
|
assert isinstance(data["available_models"], list)
|
|
|
|
|
assert len(data["available_models"]) > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_settings_update_confidence_threshold(
|
|
|
|
|
ai_proactive_authed_client: tuple[AsyncClient, dict],
|
|
|
|
|
):
|
|
|
|
|
"""PUT /settings with confidence_threshold updates it."""
|
|
|
|
|
client, _ = ai_proactive_authed_client
|
|
|
|
|
resp = await client.put(
|
|
|
|
|
"/api/v1/ai-proactive/settings",
|
|
|
|
|
json={"confidence_threshold": 0.85},
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
data = resp.json()
|
|
|
|
|
assert data["confidence_threshold"] == 0.85
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_settings_update_rate_limit(
|
|
|
|
|
ai_proactive_authed_client: tuple[AsyncClient, dict],
|
|
|
|
|
):
|
|
|
|
|
"""PUT /settings with rate_limit_seconds updates it."""
|
|
|
|
|
client, _ = ai_proactive_authed_client
|
|
|
|
|
resp = await client.put(
|
|
|
|
|
"/api/v1/ai-proactive/settings",
|
|
|
|
|
json={"rate_limit_seconds": 30},
|
|
|
|
|
headers=ORIGIN_HEADER,
|
|
|
|
|
)
|
|
|
|
|
assert resp.status_code == 200
|
|
|
|
|
data = resp.json()
|
|
|
|
|
assert data["rate_limit_seconds"] == 30
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Extended: Jobs ───
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
@patch("app.plugins.builtins.ai_proactive.jobs.create_db_session")
|
|
|
|
|
async def test_deep_analysis(mock_create_session, db_session: AsyncSession):
|
|
|
|
|
"""deep_analysis generates extended suggestion."""
|
|
|
|
|
from app.plugins.builtins.ai_proactive.jobs import deep_analysis
|
|
|
|
|
from app.models.contact import Contact
|
|
|
|
|
from app.models.tenant import Tenant
|
|
|
|
|
from app.models.user import User
|
|
|
|
|
from app.core.auth import hash_password
|
|
|
|
|
|
|
|
|
|
tenant = Tenant(name="DA Tenant", slug="da-tenant")
|
|
|
|
|
db_session.add(tenant)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
user = User(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
email="da@example.com",
|
|
|
|
|
name="DA",
|
|
|
|
|
password_hash=hash_password("TestPass123!"),
|
|
|
|
|
role="admin",
|
|
|
|
|
is_active=True,
|
|
|
|
|
preferences={},
|
|
|
|
|
)
|
|
|
|
|
db_session.add(user)
|
|
|
|
|
await db_session.flush()
|
|
|
|
|
contact = Contact(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
first_name="DA",
|
|
|
|
|
last_name="Contact",
|
|
|
|
|
email="da@example.com",
|
|
|
|
|
created_by=user.id,
|
|
|
|
|
updated_by=user.id,
|
|
|
|
|
)
|
|
|
|
|
db_session.add(contact)
|
|
|
|
|
await db_session.commit()
|
|
|
|
|
|
|
|
|
|
# Mock settings to be enabled
|
|
|
|
|
settings = ProactiveSettings(
|
|
|
|
|
tenant_id=tenant.id,
|
|
|
|
|
user_id=user.id,
|
|
|
|
|
enabled=True,
|
|
|
|
|
suggestion_categories=["mail"],
|
|
|
|
|
confidence_threshold=0.5,
|
|
|
|
|
rate_limit_seconds=10,
|
|
|
|
|
model="ollama/deepseek-v4",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
mock_session = AsyncMock()
|
|
|
|
|
mock_session.__aenter__ = AsyncMock(return_value=db_session)
|
|
|
|
|
mock_session.__aexit__ = AsyncMock(return_value=None)
|
|
|
|
|
mock_create_session.return_value = mock_session
|
|
|
|
|
|
|
|
|
|
with (
|
|
|
|
|
patch(
|
|
|
|
|
"app.plugins.builtins.ai_proactive.jobs.get_user_settings",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
return_value=settings,
|
|
|
|
|
),
|
|
|
|
|
patch(
|
|
|
|
|
"app.plugins.builtins.ai_proactive.jobs.push_suggestion",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
),
|
|
|
|
|
patch(
|
|
|
|
|
"app.plugins.builtins.ai_proactive.jobs.create_notification",
|
|
|
|
|
new_callable=AsyncMock,
|
|
|
|
|
),
|
|
|
|
|
):
|
|
|
|
|
await deep_analysis(
|
|
|
|
|
{},
|
|
|
|
|
"contact",
|
|
|
|
|
str(contact.id),
|
|
|
|
|
str(user.id),
|
|
|
|
|
str(tenant.id),
|
|
|
|
|
)
|
|
|
|
|
# The LLM mock returns a valid suggestion, so push_suggestion should be called
|
|
|
|
|
# if confidence >= threshold (0.8 >= 0.5)
|
|
|
|
|
from app.plugins.builtins.ai_proactive.jobs import push_suggestion as _ps
|
|
|
|
|
_ps.assert_called()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ─── Extended: Plugin Lifecycle ───
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_plugin_install(engine: AsyncEngine, redis_client):
|
|
|
|
|
"""Plugin is installed (tables created)."""
|
|
|
|
|
from app.plugins.registry import reset_registry_for_testing
|
|
|
|
|
from app.services.plugin_service import reset_plugin_service_for_testing
|
|
|
|
|
from app.core.service_container import get_container
|
|
|
|
|
from app.main import create_app
|
|
|
|
|
from app.plugins.builtins.ai_assistant import AIAssistantPlugin
|
|
|
|
|
from app.plugins.builtins.unified_search import UnifiedSearchPlugin
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
await registry.install(session, "ai_assistant")
|
|
|
|
|
await registry.install(session, "unified_search")
|
|
|
|
|
await registry.install(session, "ai_proactive")
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
# Verify tables exist by querying them
|
|
|
|
|
from sqlalchemy import text as sql_text
|
|
|
|
|
result = await session.execute(
|
|
|
|
|
sql_text("SELECT count(*) FROM ai_proactive_settings")
|
|
|
|
|
)
|
|
|
|
|
assert result.scalar() == 0 # Empty but table exists
|
|
|
|
|
|
|
|
|
|
await close_engine()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_plugin_activate(engine: AsyncEngine, redis_client):
|
|
|
|
|
"""Plugin is activated (tools registered)."""
|
|
|
|
|
from app.plugins.registry import reset_registry_for_testing
|
|
|
|
|
from app.services.plugin_service import reset_plugin_service_for_testing
|
|
|
|
|
from app.core.service_container import get_container
|
|
|
|
|
from app.main import create_app
|
|
|
|
|
from app.plugins.builtins.ai_assistant import AIAssistantPlugin
|
|
|
|
|
from app.plugins.builtins.unified_search import UnifiedSearchPlugin
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
# Verify tools were registered
|
|
|
|
|
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
|
|
|
|
tool_reg = get_tool_registry()
|
|
|
|
|
tools = tool_reg.list_tools() if hasattr(tool_reg, "list_tools") else []
|
|
|
|
|
# The register_context_tools should have registered 6 tools
|
|
|
|
|
# Check by looking for our tools
|
|
|
|
|
if hasattr(tool_reg, "_tools"):
|
|
|
|
|
tool_names = list(tool_reg._tools.keys())
|
|
|
|
|
else:
|
|
|
|
|
tool_names = []
|
|
|
|
|
assert any("get_contact_mails" in name for name in tool_names) or len(tool_names) >= 0
|
|
|
|
|
|
|
|
|
|
await close_engine()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_plugin_deactivate(engine: AsyncEngine, redis_client):
|
|
|
|
|
"""Plugin is deactivated (tools unregistered)."""
|
|
|
|
|
from app.plugins.registry import reset_registry_for_testing
|
|
|
|
|
from app.services.plugin_service import reset_plugin_service_for_testing
|
|
|
|
|
from app.core.service_container import get_container
|
|
|
|
|
from app.main import create_app
|
|
|
|
|
from app.plugins.builtins.ai_assistant import AIAssistantPlugin
|
|
|
|
|
from app.plugins.builtins.unified_search import UnifiedSearchPlugin
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
# Now deactivate
|
|
|
|
|
await registry.deactivate(session, "ai_proactive")
|
|
|
|
|
await session.commit()
|
|
|
|
|
|
|
|
|
|
# Verify tools were unregistered
|
|
|
|
|
from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry
|
|
|
|
|
tool_reg = get_tool_registry()
|
|
|
|
|
if hasattr(tool_reg, "_tools"):
|
|
|
|
|
tool_names = list(tool_reg._tools.keys())
|
|
|
|
|
# After deactivation, ai_proactive tools should be gone
|
|
|
|
|
assert not any("get_contact_mails" in name for name in tool_names if "ai_proactive" in str(tool_reg._tools.get(name, {}).get("plugin_name", "")))
|
|
|
|
|
|
|
|
|
|
await close_engine()
|