test: erweitere Tests auf 103 (65 unified_search + 38 ai_proactive)

- Embedding Pipeline Tests (7)
- Query Understanding Tests (6)
- Search Engine Tests (4)
- Jobs Tests (5)
- Provider-spezifische Tests (12)
- API Tests (3)
- Context Tools Tests (7)
- Proactive Engine Tests (10)
- API Tests (6)
- Jobs Tests (1)
- Plugin Lifecycle Tests (3)

Alle 103 Tests bestanden.
This commit is contained in:
Agent Zero
2026-07-18 13:48:20 +02:00
parent 6f9253809a
commit 185d4cfe8d
2 changed files with 2061 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+749
View File
@@ -8,6 +8,7 @@ from __future__ import annotations
import json import json
import uuid import uuid
from datetime import UTC, datetime
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -29,6 +30,46 @@ from app.plugins.registry import reset_registry_for_testing
from app.services.plugin_service import reset_plugin_service_for_testing from app.services.plugin_service import reset_plugin_service_for_testing
from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users from tests.conftest import ORIGIN_HEADER, login_client, seed_tenant_and_users
from app.plugins.builtins.unified_search.embedding import (
generate_embedding,
generate_embeddings_batch,
index_entity,
)
from app.plugins.builtins.unified_search.query_understanding import (
llm_analyze_query,
llm_aggregate_results,
_fallback_query_analysis,
_fallback_aggregate,
)
from app.plugins.builtins.unified_search.search_engine import (
hybrid_search,
find_similar_all_types,
autocomplete,
)
from app.plugins.builtins.unified_search.jobs import (
index_mails,
index_contact,
index_company,
index_event,
reindex,
embedding_batch,
)
from app.plugins.builtins.unified_search.providers.contact_provider import (
ContactSearchProvider,
)
from app.plugins.builtins.unified_search.providers.company_provider import (
CompanySearchProvider,
)
from app.plugins.builtins.unified_search.providers.mail_provider import (
MailSearchProvider,
)
from app.plugins.builtins.unified_search.providers.file_provider import (
FileSearchProvider,
)
from app.plugins.builtins.unified_search.providers.event_provider import (
EventSearchProvider,
)
# ─── Unified Search Fixtures ─── # ─── Unified Search Fixtures ───
@@ -567,3 +608,711 @@ async def test_autocomplete_returns_titles(db_session: AsyncSession):
result = await autocomplete(db_session, "test", uuid.uuid4(), 10) result = await autocomplete(db_session, "test", uuid.uuid4(), 10)
assert isinstance(result, list) assert isinstance(result, list)
assert len(result) <= 10 assert len(result) <= 10
# ─── Extended: Embedding Pipeline ───
@pytest.mark.asyncio
async def test_generate_embedding_success():
"""generate_embedding returns a vector list."""
result = await generate_embedding("hello world")
assert isinstance(result, list)
assert len(result) > 0
assert all(isinstance(v, float) for v in result)
@pytest.mark.asyncio
async def test_generate_embedding_empty_text():
"""generate_embedding with empty text still returns a list (mocked)."""
result = await generate_embedding("")
assert isinstance(result, list)
@pytest.mark.asyncio
async def test_generate_embedding_api_failure():
"""generate_embedding returns [] on API failure."""
with patch("litellm.aembedding", new_callable=AsyncMock, side_effect=Exception("API error")):
result = await generate_embedding("test")
assert result == []
@pytest.mark.asyncio
async def test_generate_embeddings_batch_success():
"""generate_embeddings_batch returns a list of vectors."""
mock_batch_resp = MagicMock()
mock_batch_resp.data = [
{"embedding": [0.1] * 768},
{"embedding": [0.2] * 768},
]
with patch("litellm.aembedding", new_callable=AsyncMock, return_value=mock_batch_resp):
result = await generate_embeddings_batch(["hello", "world"])
assert isinstance(result, list)
assert len(result) == 2
assert all(isinstance(v, list) for v in result)
@pytest.mark.asyncio
async def test_generate_embeddings_batch_empty():
"""generate_embeddings_batch with empty list returns []."""
mock_empty_resp = MagicMock()
mock_empty_resp.data = []
with patch("litellm.aembedding", new_callable=AsyncMock, return_value=mock_empty_resp):
result = await generate_embeddings_batch([])
assert isinstance(result, list)
assert len(result) == 0
@pytest.mark.asyncio
async def test_index_entity_success(db_session: AsyncSession):
"""index_entity stores embedding in DB and returns True."""
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="Test Tenant", slug="test-tenant")
db_session.add(tenant)
await db_session.flush()
user = User(
tenant_id=tenant.id,
email="test@example.com",
name="Test",
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="John",
last_name="Doe",
email="john@example.com",
created_by=user.id,
updated_by=user.id,
)
db_session.add(contact)
await db_session.commit()
# Register provider so index_entity can find it
registry = get_search_registry()
registry.clear()
registry.register(ContactSearchProvider())
result = await index_entity("contact", contact.id, tenant.id, db_session)
assert result is True
registry.clear()
@pytest.mark.asyncio
async def test_index_entity_no_provider(db_session: AsyncSession):
"""index_entity returns False for unknown entity_type."""
registry = get_search_registry()
registry.clear()
result = await index_entity("unknown_type", uuid.uuid4(), uuid.uuid4(), db_session)
assert result is False
# ─── Extended: Query Understanding ───
@pytest.mark.asyncio
async def test_llm_analyze_query_success():
"""llm_analyze_query returns analyzed query dict."""
result = await llm_analyze_query("Finde alle Kontakte bei Company Alpha")
assert isinstance(result, dict)
assert "normalized_query" in result
assert "entities" in result
assert "intent" in result
assert "semantic_terms" in result
assert "suggested_filters" in result
@pytest.mark.asyncio
async def test_llm_analyze_query_fallback():
"""llm_analyze_query returns fallback on LLM failure."""
with patch("litellm.acompletion", new_callable=AsyncMock, side_effect=Exception("LLM error")):
result = await llm_analyze_query("test query")
assert result == _fallback_query_analysis("test query")
@pytest.mark.asyncio
async def test_llm_aggregate_results_success():
"""llm_aggregate_results returns summary + facets."""
mock_agg_resp = MagicMock()
mock_agg_resp.choices = [MagicMock()]
mock_agg_resp.choices[0].message.content = json.dumps({
"summary": "2 Ergebnisse gefunden",
"facets": {"types": {"company": 1, "contact": 1}},
"suggestions": ["Filter by company"],
})
with patch("litellm.acompletion", new_callable=AsyncMock, return_value=mock_agg_resp):
results = [
{"entity_type": "company", "title": "Company Alpha"},
{"entity_type": "contact", "title": "John Doe"},
]
result = await llm_aggregate_results(results, "test")
assert isinstance(result, dict)
assert "summary" in result
assert "facets" in result
assert "suggestions" in result
assert result["summary"] == "2 Ergebnisse gefunden"
@pytest.mark.asyncio
async def test_llm_aggregate_results_empty():
"""llm_aggregate_results with empty results returns fallback."""
result = await llm_aggregate_results([], "test")
assert result == _fallback_aggregate([], "test")
def test_fallback_query_analysis():
"""Fallback query analysis has correct structure."""
result = _fallback_query_analysis("my query")
assert result["normalized_query"] == "my query"
assert result["entities"] == {}
assert result["intent"] == "search"
assert result["semantic_terms"] == []
assert result["suggested_filters"] == {}
def test_fallback_aggregate():
"""Fallback aggregate has correct structure."""
result = _fallback_aggregate([{"title": "x"}], "test")
assert "summary" in result
assert "facets" in result
assert "suggestions" in result
assert "1 Ergebnisse gefunden" in result["summary"]
# ─── Extended: Search Engine ───
@pytest.mark.asyncio
async def test_hybrid_search_with_results(db_session: AsyncSession):
"""hybrid_search returns results when data exists."""
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="Test HS", slug="test-hs")
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.flush()
contact = Contact(
tenant_id=tenant.id,
first_name="Search",
last_name="Test",
email="searchtest@example.com",
created_by=user.id,
updated_by=user.id,
)
db_session.add(contact)
await db_session.commit()
registry = get_search_registry()
registry.clear()
registry.register(ContactSearchProvider())
query_analysis = {"normalized_query": "search", "semantic_terms": []}
results = await hybrid_search(db_session, query_analysis, tenant.id, limit=10)
assert isinstance(results, list)
registry.clear()
@pytest.mark.asyncio
async def test_hybrid_search_no_results(db_session: AsyncSession):
"""hybrid_search returns empty list on empty DB."""
registry = get_search_registry()
registry.clear()
registry.register(ContactSearchProvider())
query_analysis = {"normalized_query": "nonexistent", "semantic_terms": []}
results = await hybrid_search(db_session, query_analysis, uuid.uuid4(), limit=10)
assert isinstance(results, list)
assert len(results) == 0
registry.clear()
@pytest.mark.asyncio
async def test_find_similar_all_types(db_session: AsyncSession):
"""find_similar_all_types returns similar entities dict."""
result = await find_similar_all_types(db_session, "contact", uuid.uuid4(), uuid.uuid4(), limit=5)
assert isinstance(result, dict)
@pytest.mark.asyncio
async def test_find_similar_no_embedding(db_session: AsyncSession):
"""find_similar_all_types returns {} when entity has no embedding."""
result = await find_similar_all_types(db_session, "contact", uuid.uuid4(), uuid.uuid4(), limit=5)
assert result == {}
# ─── Extended: Jobs ───
@pytest.mark.asyncio
@patch("app.plugins.builtins.unified_search.jobs.get_session_factory")
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
async def test_index_mails(mock_index_entity, mock_factory, db_session: AsyncSession):
"""index_mails calls index_entity for each mail."""
from app.plugins.builtins.mail.models import Mail, MailAccount, MailFolder
from app.models.tenant import Tenant
from app.models.user import User
from app.core.auth import hash_password
tenant = Tenant(name="Mail Tenant", slug="mail-tenant")
db_session.add(tenant)
await db_session.flush()
user = User(
tenant_id=tenant.id,
email="mail@example.com",
name="Mail",
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="mail@example.com",
display_name="Mail Account",
imap_host="localhost",
imap_port=993,
imap_ssl=True,
smtp_host="localhost",
smtp_port=587,
smtp_tls=True,
username="mail@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-1",
thread_id="thread-1",
from_address="sender@example.com",
to_addresses="recipient@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),
)
db_session.add(mail)
await db_session.commit()
# Mock factory to return a session that uses the test engine
sf = async_sessionmaker(bind=db_session.bind, expire_on_commit=False, class_=AsyncSession)
mock_factory.return_value = sf
await index_mails({}, [str(mail.id)])
mock_index_entity.assert_called()
@pytest.mark.asyncio
@patch("app.plugins.builtins.unified_search.jobs.get_session_factory")
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
async def test_index_contact(mock_index_entity, mock_factory, db_session: AsyncSession):
"""index_contact calls index_entity."""
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="Contact Tenant", slug="contact-tenant")
db_session.add(tenant)
await db_session.flush()
user = User(
tenant_id=tenant.id,
email="contact@example.com",
name="Contact",
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="Index",
last_name="Contact",
email="indexcontact@example.com",
created_by=user.id,
updated_by=user.id,
)
db_session.add(contact)
await db_session.commit()
sf = async_sessionmaker(bind=db_session.bind, expire_on_commit=False, class_=AsyncSession)
mock_factory.return_value = sf
await index_contact({}, str(contact.id))
mock_index_entity.assert_called()
@pytest.mark.asyncio
@patch("app.plugins.builtins.unified_search.jobs.get_session_factory")
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
async def test_index_company(mock_index_entity, mock_factory, db_session: AsyncSession):
"""index_company calls index_entity."""
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="Company Tenant", slug="company-tenant")
db_session.add(tenant)
await db_session.flush()
user = User(
tenant_id=tenant.id,
email="company@example.com",
name="Company",
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="Index Company",
industry="IT",
created_by=user.id,
updated_by=user.id,
)
db_session.add(company)
await db_session.commit()
sf = async_sessionmaker(bind=db_session.bind, expire_on_commit=False, class_=AsyncSession)
mock_factory.return_value = sf
await index_company({}, str(company.id))
mock_index_entity.assert_called()
@pytest.mark.asyncio
@patch("app.plugins.builtins.unified_search.jobs.get_session_factory")
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
async def test_reindex(mock_index_entity, mock_factory, db_session: AsyncSession):
"""reindex iterates over all entities of a type."""
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="Reindex Tenant", slug="reindex-tenant")
db_session.add(tenant)
await db_session.flush()
user = User(
tenant_id=tenant.id,
email="reindex@example.com",
name="Reindex",
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="Reindex Co",
industry="IT",
created_by=user.id,
updated_by=user.id,
)
db_session.add(company)
await db_session.commit()
sf = async_sessionmaker(bind=db_session.bind, expire_on_commit=False, class_=AsyncSession)
mock_factory.return_value = sf
await reindex({}, "company")
mock_index_entity.assert_called()
@pytest.mark.asyncio
@patch("app.plugins.builtins.unified_search.jobs.get_session_factory")
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
async def test_embedding_batch(mock_index_entity, mock_factory, db_session: AsyncSession):
"""embedding_batch finds entities without embedding."""
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="Batch Tenant", slug="batch-tenant")
db_session.add(tenant)
await db_session.flush()
user = User(
tenant_id=tenant.id,
email="batch@example.com",
name="Batch",
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="Batch Co",
industry="IT",
created_by=user.id,
updated_by=user.id,
)
db_session.add(company)
await db_session.commit()
sf = async_sessionmaker(bind=db_session.bind, expire_on_commit=False, class_=AsyncSession)
mock_factory.return_value = sf
await embedding_batch({})
mock_index_entity.assert_called()
# ─── Extended: Provider-Specific Tests ───
@pytest.mark.asyncio
async def test_contact_provider_search_fts(db_session: AsyncSession):
"""ContactProvider FTS search returns results."""
provider = ContactSearchProvider()
result = await provider.search_fts(db_session, "test", uuid.uuid4(), 10)
assert isinstance(result, list)
@pytest.mark.asyncio
async def test_company_provider_search_fts(db_session: AsyncSession):
"""CompanyProvider FTS search returns results."""
provider = CompanySearchProvider()
result = await provider.search_fts(db_session, "test", uuid.uuid4(), 10)
assert isinstance(result, list)
@pytest.mark.asyncio
async def test_mail_provider_search_fts(db_session: AsyncSession):
"""MailProvider FTS search returns results."""
provider = MailSearchProvider()
result = await provider.search_fts(db_session, "test", uuid.uuid4(), 10)
assert isinstance(result, list)
@pytest.mark.asyncio
async def test_file_provider_search_fts(db_session: AsyncSession):
"""FileProvider FTS search returns results."""
provider = FileSearchProvider()
result = await provider.search_fts(db_session, "test", uuid.uuid4(), 10)
assert isinstance(result, list)
@pytest.mark.asyncio
async def test_event_provider_search_fts(db_session: AsyncSession):
"""EventProvider FTS search returns results."""
provider = EventSearchProvider()
result = await provider.search_fts(db_session, "test", uuid.uuid4(), 10)
assert isinstance(result, list)
def test_contact_provider_get_embedding_text():
"""ContactProvider get_embedding_text is a callable method."""
provider = ContactSearchProvider()
assert hasattr(provider, "get_embedding_text")
assert provider.entity_type == "contact"
def test_company_provider_get_embedding_text():
"""CompanyProvider get_embedding_text is a callable method."""
provider = CompanySearchProvider()
assert hasattr(provider, "get_embedding_text")
assert provider.entity_type == "company"
def test_mail_provider_get_embedding_text():
"""MailProvider get_embedding_text is a callable method."""
provider = MailSearchProvider()
assert hasattr(provider, "get_embedding_text")
assert provider.entity_type == "mail"
def test_file_provider_get_embedding_text():
"""FileProvider get_embedding_text is a callable method."""
provider = FileSearchProvider()
assert hasattr(provider, "get_embedding_text")
assert provider.entity_type == "file"
def test_event_provider_get_embedding_text():
"""EventProvider get_embedding_text is a callable method."""
provider = EventSearchProvider()
assert hasattr(provider, "get_embedding_text")
assert provider.entity_type == "event"
def test_contact_provider_to_search_result():
"""ContactProvider to_search_result returns correct dict."""
provider = ContactSearchProvider()
entity = {"id": "123", "first_name": "John", "last_name": "Doe", "email": "john@example.com"}
result = provider.to_search_result(entity)
assert result["entity_type"] == "contact"
assert result["entity_id"] == "123"
assert result["title"] == "John Doe"
assert result["snippet"] == "john@example.com"
assert result["score"] == 0.0
def test_company_provider_to_search_result():
"""CompanyProvider to_search_result returns correct dict."""
provider = CompanySearchProvider()
entity = {"id": "456", "name": "Acme Corp", "description": "IT company"}
result = provider.to_search_result(entity)
assert result["entity_type"] == "company"
assert result["entity_id"] == "456"
assert result["title"] == "Acme Corp"
assert "IT company" in result["snippet"]
def test_mail_provider_to_search_result():
"""MailProvider to_search_result returns correct dict."""
provider = MailSearchProvider()
entity = {"id": "789", "subject": "Test Subject", "body_text": "Body text here"}
result = provider.to_search_result(entity)
assert result["entity_type"] == "mail"
assert result["entity_id"] == "789"
assert result["title"] == "Test Subject"
assert "Body text" in result["snippet"]
def test_file_provider_to_search_result():
"""FileProvider to_search_result returns correct dict."""
provider = FileSearchProvider()
entity = {"id": "abc", "name": "document.pdf", "content_text": "File content"}
result = provider.to_search_result(entity)
assert result["entity_type"] == "file"
assert result["entity_id"] == "abc"
assert result["title"] == "document.pdf"
assert "File content" in result["snippet"]
def test_event_provider_to_search_result():
"""EventProvider to_search_result returns correct dict."""
provider = EventSearchProvider()
entity = {"id": "evt1", "title": "Meeting", "description": "Team meeting"}
result = provider.to_search_result(entity)
assert result["entity_type"] == "event"
assert result["entity_id"] == "evt1"
assert result["title"] == "Meeting"
assert "Team meeting" in result["snippet"]
# ─── Extended: API Tests ───
@pytest.mark.asyncio
async def test_ac13_stats_endpoint(
search_authed_client: tuple[AsyncClient, dict],
):
"""AC13: GET /api/v1/search/stats returns 200 + statistics."""
client, _ = search_authed_client
resp = await client.get(
"/api/v1/search/stats",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, dict)
# Stats should contain table keys or recent_logs
assert "recent_logs" in data
@pytest.mark.asyncio
async def test_ac14_toggle_provider(
search_authed_client: tuple[AsyncClient, dict],
db_session: AsyncSession,
):
"""AC14: POST /api/v1/search/providers/{type}/toggle as admin returns 200."""
client, seed = search_authed_client
# Pre-insert a provider row so the UPDATE path is taken (avoids INSERT without id)
from sqlalchemy import text as sql_text
from tests.conftest import _get_sync_engine
sync_eng = _get_sync_engine()
with sync_eng.connect() as conn:
conn.execute(
sql_text(
"INSERT INTO unified_search_providers (id, tenant_id, entity_type, plugin_name, is_active, config) "
"VALUES (:id, :tid, 'contact', 'unified_search', true, '{}'::jsonb) "
"ON CONFLICT DO NOTHING"
),
{"id": str(uuid.uuid4()), "tid": str(seed["tenant_a"].id)},
)
conn.commit()
sync_eng.dispose()
resp = await client.post(
"/api/v1/search/providers/contact/toggle",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200
data = resp.json()
assert "entity_type" in data
assert "is_active" in data
assert data["entity_type"] == "contact"
@pytest.mark.asyncio
async def test_ac15_toggle_provider_viewer_403(
search_client: AsyncClient, db_session: AsyncSession
):
"""AC15: POST /providers/{type}/toggle as viewer → 403."""
await seed_tenant_and_users(db_session)
await login_client(search_client, "viewer@tenanta.com")
resp = await search_client.post(
"/api/v1/search/providers/contact/toggle",
headers=ORIGIN_HEADER,
)
assert resp.status_code == 403