860 lines
31 KiB
Python
860 lines
31 KiB
Python
|
|
"""Phase E tests for the Unified Search plugin.
|
||
|
|
|
||
|
|
Covers provider capability flags, RRF multi-fusion, chunking, lifecycle,
|
||
|
|
API filters, AI tool, new providers, and sensitive-field exclusion.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import uuid
|
||
|
|
from datetime import UTC, datetime
|
||
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import pytest_asyncio
|
||
|
|
from httpx import ASGITransport, AsyncClient
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||
|
|
|
||
|
|
from app.core.db import close_engine, reset_engine_for_testing
|
||
|
|
from app.core.permission_registry import init_permission_registry
|
||
|
|
from app.core.service_container import get_container
|
||
|
|
from app.main import create_app
|
||
|
|
from app.plugins.builtins.unified_search import UnifiedSearchPlugin
|
||
|
|
from app.plugins.builtins.unified_search.provider_registry import (
|
||
|
|
SearchProviderRegistry,
|
||
|
|
get_search_registry,
|
||
|
|
)
|
||
|
|
from app.plugins.builtins.unified_search.search_engine import (
|
||
|
|
rrf_fusion,
|
||
|
|
rrf_fusion_multi,
|
||
|
|
)
|
||
|
|
from app.plugins.builtins.unified_search.chunking import chunk_text
|
||
|
|
from app.plugins.builtins.unified_search.lifecycle import (
|
||
|
|
handle_entity_delete,
|
||
|
|
handle_entity_restore,
|
||
|
|
rebuild_index,
|
||
|
|
remove_from_index,
|
||
|
|
)
|
||
|
|
from app.plugins.builtins.unified_search.ai_tool import (
|
||
|
|
TOOL_NAME,
|
||
|
|
TOOL_DESCRIPTION,
|
||
|
|
unified_search_tool,
|
||
|
|
unified_search_handler,
|
||
|
|
)
|
||
|
|
from app.plugins.builtins.unified_search.providers.agent_memory_provider import (
|
||
|
|
AgentMemorySearchProvider,
|
||
|
|
)
|
||
|
|
from app.plugins.builtins.unified_search.providers.ai_chat_provider import (
|
||
|
|
AIChatSearchProvider,
|
||
|
|
)
|
||
|
|
from app.plugins.builtins.unified_search.providers.workflow_provider import (
|
||
|
|
WorkflowSearchProvider,
|
||
|
|
)
|
||
|
|
from app.plugins.builtins.unified_search.providers.contact_provider import (
|
||
|
|
ContactSearchProvider,
|
||
|
|
)
|
||
|
|
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, login_client, seed_tenant_and_users
|
||
|
|
|
||
|
|
|
||
|
|
# ─── Unified Search Fixtures (same as test_unified_search.py) ───
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def search_app(engine: AsyncEngine, redis_client):
|
||
|
|
"""FastAPI app with UnifiedSearch plugin registered, installed, and activated."""
|
||
|
|
reset_engine_for_testing(engine)
|
||
|
|
app = create_app()
|
||
|
|
registry = reset_registry_for_testing()
|
||
|
|
registry.initialize(engine, app)
|
||
|
|
init_permission_registry(active_plugin_names={"unified_search"})
|
||
|
|
container = get_container()
|
||
|
|
await container.initialize()
|
||
|
|
registry.register_plugin(UnifiedSearchPlugin())
|
||
|
|
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, "unified_search")
|
||
|
|
await registry.activate(session, "unified_search")
|
||
|
|
await session.commit()
|
||
|
|
|
||
|
|
yield app
|
||
|
|
await close_engine()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def search_client(search_app) -> AsyncClient:
|
||
|
|
"""HTTP test client with unified search plugin active."""
|
||
|
|
transport = ASGITransport(app=search_app)
|
||
|
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||
|
|
yield c
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def search_authed_client(
|
||
|
|
search_client: AsyncClient, db_session: AsyncSession
|
||
|
|
) -> tuple[AsyncClient, dict]:
|
||
|
|
"""Authenticated admin client with seeded data and unified search plugin active."""
|
||
|
|
seed = await seed_tenant_and_users(db_session)
|
||
|
|
# Grant is_system_admin to admin user so search:read/search:admin permissions pass
|
||
|
|
from sqlalchemy import update
|
||
|
|
from app.models.user import User
|
||
|
|
await db_session.execute(
|
||
|
|
update(User)
|
||
|
|
.where(User.email == "admin@tenanta.com")
|
||
|
|
.values(is_system_admin=True)
|
||
|
|
)
|
||
|
|
await db_session.commit()
|
||
|
|
await login_client(search_client, "admin@tenanta.com")
|
||
|
|
return search_client, seed
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(autouse=True)
|
||
|
|
async def mock_external_calls():
|
||
|
|
"""Mock all external API calls (LiteLLM, job queue) for all tests."""
|
||
|
|
mock_resp = MagicMock()
|
||
|
|
mock_resp.choices = [MagicMock()]
|
||
|
|
mock_resp.choices[0].message.content = json.dumps({
|
||
|
|
"normalized_query": "test",
|
||
|
|
"entities": {},
|
||
|
|
"intent": "search",
|
||
|
|
"semantic_terms": [],
|
||
|
|
"suggested_filters": {},
|
||
|
|
})
|
||
|
|
|
||
|
|
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),
|
||
|
|
patch("app.core.jobs.enqueue_job", new_callable=AsyncMock, return_value="job-123"),
|
||
|
|
):
|
||
|
|
yield
|
||
|
|
|
||
|
|
|
||
|
|
# ─── 1. Provider Capability Flags ───
|
||
|
|
|
||
|
|
|
||
|
|
def test_all_providers_have_capability_attributes():
|
||
|
|
"""All registered providers expose supports_fts/vector/rag/graph attributes."""
|
||
|
|
registry = SearchProviderRegistry()
|
||
|
|
for provider_cls in [
|
||
|
|
ContactSearchProvider,
|
||
|
|
AgentMemorySearchProvider,
|
||
|
|
AIChatSearchProvider,
|
||
|
|
WorkflowSearchProvider,
|
||
|
|
]:
|
||
|
|
p = provider_cls()
|
||
|
|
registry.register(p)
|
||
|
|
|
||
|
|
for provider in registry.get_all():
|
||
|
|
assert hasattr(provider, "supports_fts")
|
||
|
|
assert hasattr(provider, "supports_vector")
|
||
|
|
assert hasattr(provider, "supports_rag")
|
||
|
|
assert hasattr(provider, "supports_graph")
|
||
|
|
assert isinstance(provider.supports_fts, bool)
|
||
|
|
assert isinstance(provider.supports_vector, bool)
|
||
|
|
assert isinstance(provider.supports_rag, bool)
|
||
|
|
assert isinstance(provider.supports_graph, bool)
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_providers_by_capability():
|
||
|
|
"""get_providers_by_capability returns only providers supporting the capability."""
|
||
|
|
registry = SearchProviderRegistry()
|
||
|
|
fts_only = MagicMock()
|
||
|
|
fts_only.entity_type = "fts_only"
|
||
|
|
fts_only.supports_fts = True
|
||
|
|
fts_only.supports_vector = False
|
||
|
|
fts_only.supports_rag = False
|
||
|
|
fts_only.supports_graph = False
|
||
|
|
|
||
|
|
vector_only = MagicMock()
|
||
|
|
vector_only.entity_type = "vector_only"
|
||
|
|
vector_only.supports_fts = False
|
||
|
|
vector_only.supports_vector = True
|
||
|
|
vector_only.supports_rag = False
|
||
|
|
vector_only.supports_graph = False
|
||
|
|
|
||
|
|
registry.register(fts_only)
|
||
|
|
registry.register(vector_only)
|
||
|
|
|
||
|
|
fts_providers = registry.get_providers_by_capability("fts")
|
||
|
|
assert len(fts_providers) == 1
|
||
|
|
assert fts_providers[0].entity_type == "fts_only"
|
||
|
|
|
||
|
|
vec_providers = registry.get_providers_by_capability("vector")
|
||
|
|
assert len(vec_providers) == 1
|
||
|
|
assert vec_providers[0].entity_type == "vector_only"
|
||
|
|
|
||
|
|
rag_providers = registry.get_providers_by_capability("rag")
|
||
|
|
assert rag_providers == []
|
||
|
|
|
||
|
|
graph_providers = registry.get_providers_by_capability("graph")
|
||
|
|
assert graph_providers == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_get_capabilities_returns_flags():
|
||
|
|
"""get_capabilities returns correct flags for each entity type."""
|
||
|
|
registry = SearchProviderRegistry()
|
||
|
|
contact = ContactSearchProvider()
|
||
|
|
ai_chat = AIChatSearchProvider()
|
||
|
|
registry.register(contact)
|
||
|
|
registry.register(ai_chat)
|
||
|
|
|
||
|
|
contact_caps = registry.get_capabilities("contact")
|
||
|
|
assert contact_caps == {"fts": True, "vector": True, "rag": False, "graph": False}
|
||
|
|
|
||
|
|
ai_chat_caps = registry.get_capabilities("ai_chat")
|
||
|
|
assert ai_chat_caps == {"fts": True, "vector": False, "rag": False, "graph": False}
|
||
|
|
|
||
|
|
# Unknown entity type returns all False
|
||
|
|
unknown_caps = registry.get_capabilities("unknown")
|
||
|
|
assert unknown_caps == {"fts": False, "vector": False, "rag": False, "graph": False}
|
||
|
|
|
||
|
|
|
||
|
|
# ─── 2. RRF Multi-Fusion ───
|
||
|
|
|
||
|
|
|
||
|
|
def test_rrf_fusion_multi_two_lists():
|
||
|
|
"""rrf_fusion_multi fuses two result lists."""
|
||
|
|
list_a = [{"id": "1", "title": "A"}, {"id": "2", "title": "B"}]
|
||
|
|
list_b = [{"id": "2", "title": "B"}, {"id": "3", "title": "C"}]
|
||
|
|
|
||
|
|
fused = rrf_fusion_multi([("a", list_a), ("b", list_b)])
|
||
|
|
ids = [str(r.get("id", "")) for r in fused]
|
||
|
|
assert "1" in ids
|
||
|
|
assert "2" in ids
|
||
|
|
assert "3" in ids
|
||
|
|
# Item 2 appears in both lists → highest score
|
||
|
|
assert str(fused[0].get("id", "")) == "2"
|
||
|
|
assert fused[0]["_score"] > fused[1]["_score"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_rrf_fusion_multi_three_lists():
|
||
|
|
"""rrf_fusion_multi fuses three result lists."""
|
||
|
|
list_a = [{"id": "1"}]
|
||
|
|
list_b = [{"id": "1"}, {"id": "2"}]
|
||
|
|
list_c = [{"id": "1"}, {"id": "2"}, {"id": "3"}]
|
||
|
|
|
||
|
|
fused = rrf_fusion_multi([("a", list_a), ("b", list_b), ("c", list_c)])
|
||
|
|
ids = [str(r.get("id", "")) for r in fused]
|
||
|
|
assert "1" in ids
|
||
|
|
assert "2" in ids
|
||
|
|
assert "3" in ids
|
||
|
|
# Item 1 appears in all 3 lists → highest score
|
||
|
|
assert str(fused[0].get("id", "")) == "1"
|
||
|
|
|
||
|
|
|
||
|
|
def test_rrf_fusion_multi_four_lists():
|
||
|
|
"""rrf_fusion_multi fuses four result lists."""
|
||
|
|
list_a = [{"id": "1"}]
|
||
|
|
list_b = [{"id": "1"}, {"id": "2"}]
|
||
|
|
list_c = [{"id": "1"}, {"id": "2"}, {"id": "3"}]
|
||
|
|
list_d = [{"id": "1"}, {"id": "2"}, {"id": "3"}, {"id": "4"}]
|
||
|
|
|
||
|
|
fused = rrf_fusion_multi([("a", list_a), ("b", list_b), ("c", list_c), ("d", list_d)])
|
||
|
|
ids = [str(r.get("id", "")) for r in fused]
|
||
|
|
assert "1" in ids
|
||
|
|
assert "2" in ids
|
||
|
|
assert "3" in ids
|
||
|
|
assert "4" in ids
|
||
|
|
assert str(fused[0].get("id", "")) == "1"
|
||
|
|
|
||
|
|
|
||
|
|
def test_rrf_fusion_multi_items_in_multiple_lists_score_higher():
|
||
|
|
"""Items appearing in multiple lists get higher scores."""
|
||
|
|
list_a = [{"id": "1"}, {"id": "2"}]
|
||
|
|
list_b = [{"id": "1"}]
|
||
|
|
|
||
|
|
fused = rrf_fusion_multi([("a", list_a), ("b", list_b)])
|
||
|
|
by_id = {str(r.get("id", "")): r["_score"] for r in fused}
|
||
|
|
# Item 1 appears in both lists → higher score than item 2 (only in list a)
|
||
|
|
assert by_id["1"] > by_id["2"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_rrf_fusion_backward_compatibility():
|
||
|
|
"""rrf_fusion remains backward compatible with the multi-fusion wrapper."""
|
||
|
|
fts_results = [{"id": "1", "title": "A"}, {"id": "2", "title": "B"}]
|
||
|
|
vec_results = [{"id": "2", "title": "B"}, {"id": "3", "title": "C"}]
|
||
|
|
|
||
|
|
fused = rrf_fusion(fts_results, vec_results, "contact")
|
||
|
|
ids = [str(r.get("id", "")) for r in fused]
|
||
|
|
assert "1" in ids
|
||
|
|
assert "2" in ids
|
||
|
|
assert "3" in ids
|
||
|
|
# Item 2 appears in both → highest score
|
||
|
|
assert str(fused[0].get("id", "")) == "2"
|
||
|
|
# _entity_type is set for backward compatibility
|
||
|
|
assert all(r.get("_entity_type") == "contact" for r in fused)
|
||
|
|
|
||
|
|
|
||
|
|
def test_rrf_fusion_multi_empty_inputs():
|
||
|
|
"""rrf_fusion_multi with empty lists returns empty list."""
|
||
|
|
assert rrf_fusion_multi([]) == []
|
||
|
|
assert rrf_fusion_multi([("a", []), ("b", [])]) == []
|
||
|
|
|
||
|
|
|
||
|
|
# ─── 3. Chunking ───
|
||
|
|
|
||
|
|
|
||
|
|
def test_chunk_text_empty():
|
||
|
|
"""chunk_text with empty text returns empty list."""
|
||
|
|
assert chunk_text("") == []
|
||
|
|
assert chunk_text(" ") == []
|
||
|
|
assert chunk_text(None) == []
|
||
|
|
|
||
|
|
|
||
|
|
def test_chunk_text_short():
|
||
|
|
"""chunk_text with text shorter than chunk_size returns a single chunk."""
|
||
|
|
chunks = chunk_text("Hello world", chunk_size=1000, overlap=200)
|
||
|
|
assert len(chunks) == 1
|
||
|
|
assert chunks[0]["chunk_index"] == 0
|
||
|
|
assert chunks[0]["chunk_text"] == "Hello world"
|
||
|
|
assert "chunk_hash" in chunks[0]
|
||
|
|
|
||
|
|
|
||
|
|
def test_chunk_text_long():
|
||
|
|
"""chunk_text splits long text into multiple overlapping chunks."""
|
||
|
|
text = "word " * 500 # ~2500 chars
|
||
|
|
chunks = chunk_text(text, chunk_size=1000, overlap=200)
|
||
|
|
assert len(chunks) > 1
|
||
|
|
# Chunks overlap: chunk 1 starts at chunk_size - overlap
|
||
|
|
assert chunks[1]["chunk_text"].startswith(chunks[0]["chunk_text"][-200:])
|
||
|
|
|
||
|
|
|
||
|
|
def test_chunk_text_exact_multiple():
|
||
|
|
"""chunk_text with text exactly matching chunk_size returns a single chunk."""
|
||
|
|
text = "a" * 1000
|
||
|
|
chunks = chunk_text(text, chunk_size=1000, overlap=200)
|
||
|
|
assert len(chunks) == 1
|
||
|
|
assert chunks[0]["chunk_text"] == text
|
||
|
|
|
||
|
|
|
||
|
|
def test_chunk_overlap_correct():
|
||
|
|
"""chunk overlap is correct between consecutive chunks."""
|
||
|
|
text = "word " * 1000 # ~5000 chars
|
||
|
|
chunks = chunk_text(text, chunk_size=1000, overlap=200)
|
||
|
|
assert len(chunks) > 1
|
||
|
|
# Verify overlap: the tail of chunk N equals the head of chunk N+1
|
||
|
|
for i in range(1, len(chunks)):
|
||
|
|
prev_tail = chunks[i - 1]["chunk_text"][-200:]
|
||
|
|
assert chunks[i]["chunk_text"].startswith(prev_tail)
|
||
|
|
|
||
|
|
|
||
|
|
def test_chunk_hash_deterministic():
|
||
|
|
"""chunk_hash is deterministic for the same input."""
|
||
|
|
text = "Some document text for chunking"
|
||
|
|
c1 = chunk_text(text, chunk_size=100, overlap=20)
|
||
|
|
c2 = chunk_text(text, chunk_size=100, overlap=20)
|
||
|
|
assert c1 == c2
|
||
|
|
assert c1[0]["chunk_hash"] == c2[0]["chunk_hash"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_chunk_text_normalizes_whitespace():
|
||
|
|
"""chunk_text normalizes whitespace to avoid degenerate chunks."""
|
||
|
|
chunks = chunk_text("Hello world\n\n test", chunk_size=1000, overlap=200)
|
||
|
|
assert len(chunks) == 1
|
||
|
|
assert chunks[0]["chunk_text"] == "Hello world test"
|
||
|
|
|
||
|
|
|
||
|
|
# ─── 4. Lifecycle ───
|
||
|
|
|
||
|
|
|
||
|
|
async def _create_contact_with_embedding(db_session: AsyncSession) -> tuple[uuid.UUID, uuid.UUID]:
|
||
|
|
"""Create a tenant, user, and contact with an embedding set."""
|
||
|
|
from app.models.contact import Contact
|
||
|
|
from app.models.tenant import Tenant
|
||
|
|
from app.models.user import User, UserTenant
|
||
|
|
from app.core.auth import hash_password
|
||
|
|
|
||
|
|
tenant = Tenant(name="Lifecycle Tenant", slug="lifecycle-tenant")
|
||
|
|
db_session.add(tenant)
|
||
|
|
await db_session.flush()
|
||
|
|
user = User(
|
||
|
|
email="lifecycle@example.com",
|
||
|
|
name="Lifecycle",
|
||
|
|
password_hash=hash_password("TestPass123!"),
|
||
|
|
is_active=True,
|
||
|
|
preferences={},
|
||
|
|
)
|
||
|
|
db_session.add(user)
|
||
|
|
await db_session.flush()
|
||
|
|
db_session.add(UserTenant(user_id=user.id, tenant_id=tenant.id, is_default=True, role="admin"))
|
||
|
|
await db_session.flush()
|
||
|
|
contact = Contact(
|
||
|
|
tenant_id=tenant.id,
|
||
|
|
firstname="Lifecycle",
|
||
|
|
surname="Test",
|
||
|
|
email_1="lifecycle@example.com",
|
||
|
|
created_by=user.id,
|
||
|
|
updated_by=user.id,
|
||
|
|
)
|
||
|
|
db_session.add(contact)
|
||
|
|
await db_session.flush()
|
||
|
|
# Set embedding + search_tsv so we can verify removal.
|
||
|
|
# The test DB uses create_all (no Alembic migrations), so indexed_at
|
||
|
|
# (added by migration 0005) may be missing — add it if needed.
|
||
|
|
from sqlalchemy import text as sql_text
|
||
|
|
await db_session.execute(
|
||
|
|
sql_text(
|
||
|
|
"ALTER TABLE contacts ADD COLUMN IF NOT EXISTS indexed_at TIMESTAMPTZ"
|
||
|
|
)
|
||
|
|
)
|
||
|
|
# The test DB (create_all) defines search_tsv as a generated column, but
|
||
|
|
# production (migration 0001) uses a plain column maintained by a trigger.
|
||
|
|
# The lifecycle code sets search_tsv = NULL, which requires a plain column.
|
||
|
|
# Convert it to match production schema (PG 13+ DROP EXPRESSION) and drop
|
||
|
|
# the recompute trigger so the NULL set by remove_from_index is preserved.
|
||
|
|
# The DO block makes this idempotent across test runs (schema persists).
|
||
|
|
await db_session.execute(
|
||
|
|
sql_text(
|
||
|
|
"DO $$ BEGIN "
|
||
|
|
"IF EXISTS (SELECT 1 FROM pg_attribute a "
|
||
|
|
" WHERE a.attrelid = 'contacts'::regclass "
|
||
|
|
" AND a.attname = 'search_tsv' "
|
||
|
|
" AND a.attgenerated <> '') THEN "
|
||
|
|
" ALTER TABLE contacts ALTER COLUMN search_tsv DROP EXPRESSION; "
|
||
|
|
"END IF; "
|
||
|
|
"END $$;"
|
||
|
|
)
|
||
|
|
)
|
||
|
|
await db_session.execute(
|
||
|
|
sql_text("DROP TRIGGER IF EXISTS contacts_tsv_update ON contacts")
|
||
|
|
)
|
||
|
|
await db_session.execute(
|
||
|
|
sql_text(
|
||
|
|
"UPDATE contacts SET embedding = cast(:emb AS vector), "
|
||
|
|
"search_tsv = to_tsvector('pg_catalog.german', :tsv), indexed_at = now() "
|
||
|
|
"WHERE id = :eid"
|
||
|
|
),
|
||
|
|
{"emb": str([0.1] * 768), "tsv": "lifecycle test", "eid": contact.id},
|
||
|
|
)
|
||
|
|
await db_session.commit()
|
||
|
|
return tenant.id, contact.id
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_remove_from_index_sets_embedding_and_tsv_null(db_session: AsyncSession):
|
||
|
|
"""remove_from_index sets embedding and TSV to NULL."""
|
||
|
|
tenant_id, contact_id = await _create_contact_with_embedding(db_session)
|
||
|
|
|
||
|
|
await remove_from_index(db_session, "contact", contact_id, tenant_id)
|
||
|
|
|
||
|
|
from sqlalchemy import text as sql_text
|
||
|
|
result = await db_session.execute(
|
||
|
|
sql_text("SELECT embedding, search_tsv, indexed_at FROM contacts WHERE id = :eid"),
|
||
|
|
{"eid": contact_id},
|
||
|
|
)
|
||
|
|
row = result.mappings().first()
|
||
|
|
assert row is not None
|
||
|
|
assert row["embedding"] is None
|
||
|
|
assert row["search_tsv"] is None
|
||
|
|
assert row["indexed_at"] is None
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_remove_from_index_unknown_entity_type(db_session: AsyncSession):
|
||
|
|
"""remove_from_index with unknown entity type does nothing (no error)."""
|
||
|
|
await remove_from_index(db_session, "unknown_type", uuid.uuid4(), uuid.uuid4())
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=True)
|
||
|
|
async def test_rebuild_index_regenerates_embedding(mock_index_entity, db_session: AsyncSession):
|
||
|
|
"""rebuild_index regenerates the embedding via index_entity."""
|
||
|
|
tenant_id, contact_id = await _create_contact_with_embedding(db_session)
|
||
|
|
|
||
|
|
success = await rebuild_index(db_session, "contact", contact_id, tenant_id)
|
||
|
|
assert success is True
|
||
|
|
mock_index_entity.assert_called_once()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
@patch("app.plugins.builtins.unified_search.embedding.index_entity", new_callable=AsyncMock, return_value=False)
|
||
|
|
async def test_rebuild_index_failure_returns_false(mock_index_entity, db_session: AsyncSession):
|
||
|
|
"""rebuild_index returns False when index_entity fails."""
|
||
|
|
tenant_id, contact_id = await _create_contact_with_embedding(db_session)
|
||
|
|
|
||
|
|
success = await rebuild_index(db_session, "contact", contact_id, tenant_id)
|
||
|
|
assert success is False
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
@patch("app.plugins.builtins.unified_search.lifecycle.remove_from_index", new_callable=AsyncMock)
|
||
|
|
async def test_handle_entity_delete_calls_remove_from_index(mock_remove, db_session: AsyncSession):
|
||
|
|
"""handle_entity_delete calls remove_from_index."""
|
||
|
|
tenant_id = uuid.uuid4()
|
||
|
|
entity_id = uuid.uuid4()
|
||
|
|
await handle_entity_delete(db_session, "contact", entity_id, tenant_id)
|
||
|
|
mock_remove.assert_called_once_with(db_session, "contact", entity_id, tenant_id)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
@patch("app.plugins.builtins.unified_search.lifecycle.rebuild_index", new_callable=AsyncMock, return_value=True)
|
||
|
|
async def test_handle_entity_restore_calls_rebuild_index(mock_rebuild, db_session: AsyncSession):
|
||
|
|
"""handle_entity_restore calls rebuild_index."""
|
||
|
|
tenant_id = uuid.uuid4()
|
||
|
|
entity_id = uuid.uuid4()
|
||
|
|
await handle_entity_restore(db_session, "contact", entity_id, tenant_id)
|
||
|
|
mock_rebuild.assert_called_once_with(db_session, "contact", entity_id, tenant_id)
|
||
|
|
|
||
|
|
|
||
|
|
# ─── 5. API Filters ───
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
@patch("app.plugins.builtins.unified_search.routes.hybrid_search", new_callable=AsyncMock)
|
||
|
|
async def test_search_with_date_filters(mock_hybrid_search, search_authed_client: tuple[AsyncClient, dict]):
|
||
|
|
"""Search with date_from/date_to filters returns filtered results."""
|
||
|
|
client, _ = search_authed_client
|
||
|
|
mock_hybrid_search.return_value = [
|
||
|
|
{
|
||
|
|
"entity_type": "contact",
|
||
|
|
"entity_id": str(uuid.uuid4()),
|
||
|
|
"title": "Alpha",
|
||
|
|
"snippet": "",
|
||
|
|
"score": 0.9,
|
||
|
|
"data": {},
|
||
|
|
"_created_at": "2026-01-01T00:00:00+00:00",
|
||
|
|
"_updated_at": "2026-01-01T00:00:00+00:00",
|
||
|
|
"_tags": "",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"entity_type": "contact",
|
||
|
|
"entity_id": str(uuid.uuid4()),
|
||
|
|
"title": "Beta",
|
||
|
|
"snippet": "",
|
||
|
|
"score": 0.8,
|
||
|
|
"data": {},
|
||
|
|
"_created_at": "2026-06-01T00:00:00+00:00",
|
||
|
|
"_updated_at": "2026-06-01T00:00:00+00:00",
|
||
|
|
"_tags": "",
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
resp = await client.post(
|
||
|
|
"/api/v1/search",
|
||
|
|
json={"query": "test", "date_from": "2026-03-01", "date_to": "2026-12-31"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
data = resp.json()
|
||
|
|
results = data["results"]
|
||
|
|
assert len(results) == 1
|
||
|
|
assert results[0]["title"] == "Beta"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
@patch("app.plugins.builtins.unified_search.routes.hybrid_search", new_callable=AsyncMock)
|
||
|
|
async def test_search_with_tags_filter(mock_hybrid_search, search_authed_client: tuple[AsyncClient, dict]):
|
||
|
|
"""Search with tags filter returns only matching results."""
|
||
|
|
client, _ = search_authed_client
|
||
|
|
mock_hybrid_search.return_value = [
|
||
|
|
{
|
||
|
|
"entity_type": "contact",
|
||
|
|
"entity_id": str(uuid.uuid4()),
|
||
|
|
"title": "Alpha",
|
||
|
|
"snippet": "",
|
||
|
|
"score": 0.9,
|
||
|
|
"data": {},
|
||
|
|
"_created_at": None,
|
||
|
|
"_updated_at": None,
|
||
|
|
"_tags": "vip,partner",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"entity_type": "contact",
|
||
|
|
"entity_id": str(uuid.uuid4()),
|
||
|
|
"title": "Beta",
|
||
|
|
"snippet": "",
|
||
|
|
"score": 0.8,
|
||
|
|
"data": {},
|
||
|
|
"_created_at": None,
|
||
|
|
"_updated_at": None,
|
||
|
|
"_tags": "lead",
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
resp = await client.post(
|
||
|
|
"/api/v1/search",
|
||
|
|
json={"query": "test", "tags": ["vip"]},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
data = resp.json()
|
||
|
|
results = data["results"]
|
||
|
|
assert len(results) == 1
|
||
|
|
assert results[0]["title"] == "Alpha"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
@patch("app.plugins.builtins.unified_search.routes.hybrid_search", new_callable=AsyncMock)
|
||
|
|
async def test_search_with_sort_parameter(mock_hybrid_search, search_authed_client: tuple[AsyncClient, dict]):
|
||
|
|
"""Search with sort=name sorts results by title."""
|
||
|
|
client, _ = search_authed_client
|
||
|
|
mock_hybrid_search.return_value = [
|
||
|
|
{
|
||
|
|
"entity_type": "contact",
|
||
|
|
"entity_id": str(uuid.uuid4()),
|
||
|
|
"title": "Zeta",
|
||
|
|
"snippet": "",
|
||
|
|
"score": 0.9,
|
||
|
|
"data": {},
|
||
|
|
"_created_at": None,
|
||
|
|
"_updated_at": None,
|
||
|
|
"_tags": "",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
"entity_type": "contact",
|
||
|
|
"entity_id": str(uuid.uuid4()),
|
||
|
|
"title": "Alpha",
|
||
|
|
"snippet": "",
|
||
|
|
"score": 0.8,
|
||
|
|
"data": {},
|
||
|
|
"_created_at": None,
|
||
|
|
"_updated_at": None,
|
||
|
|
"_tags": "",
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
resp = await client.post(
|
||
|
|
"/api/v1/search",
|
||
|
|
json={"query": "test", "sort": "name"},
|
||
|
|
headers=ORIGIN_HEADER,
|
||
|
|
)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
data = resp.json()
|
||
|
|
results = data["results"]
|
||
|
|
assert len(results) == 2
|
||
|
|
assert results[0]["title"] == "Alpha"
|
||
|
|
assert results[1]["title"] == "Zeta"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_facets_endpoint_returns_correct_structure(search_authed_client: tuple[AsyncClient, dict]):
|
||
|
|
"""GET /api/v1/search/facets returns correct structure."""
|
||
|
|
client, _ = search_authed_client
|
||
|
|
resp = await client.get("/api/v1/search/facets", headers=ORIGIN_HEADER)
|
||
|
|
assert resp.status_code == 200
|
||
|
|
data = resp.json()
|
||
|
|
assert "entity_types" in data
|
||
|
|
assert "tags" in data
|
||
|
|
assert "date_ranges" in data
|
||
|
|
assert isinstance(data["entity_types"], list)
|
||
|
|
assert isinstance(data["tags"], list)
|
||
|
|
assert isinstance(data["date_ranges"], dict)
|
||
|
|
|
||
|
|
|
||
|
|
# ─── 6. AI Tool ───
|
||
|
|
|
||
|
|
|
||
|
|
def test_unified_search_tool_name_and_description():
|
||
|
|
"""unified_search_tool has correct name and description."""
|
||
|
|
assert unified_search_tool.name == "unified_search"
|
||
|
|
assert TOOL_NAME == "unified_search"
|
||
|
|
assert TOOL_DESCRIPTION == unified_search_tool.description
|
||
|
|
assert "Hybrid-Suche" in unified_search_tool.description
|
||
|
|
assert unified_search_tool.required_permission == "search:read"
|
||
|
|
assert unified_search_tool.category == "search"
|
||
|
|
|
||
|
|
|
||
|
|
def test_unified_search_tool_parameters():
|
||
|
|
"""unified_search_tool exposes query/entity_types/limit parameters."""
|
||
|
|
params = unified_search_tool.parameters
|
||
|
|
assert params["type"] == "object"
|
||
|
|
assert "query" in params["properties"]
|
||
|
|
assert "entity_types" in params["properties"]
|
||
|
|
assert "limit" in params["properties"]
|
||
|
|
assert params["required"] == ["query"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_unified_search_tool_openai_schema():
|
||
|
|
"""unified_search_tool to_openai_schema returns valid function schema."""
|
||
|
|
schema = unified_search_tool.to_openai_schema()
|
||
|
|
assert schema["type"] == "function"
|
||
|
|
assert schema["function"]["name"] == "unified_search"
|
||
|
|
assert "parameters" in schema["function"]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
@patch("app.plugins.builtins.unified_search.ai_tool.hybrid_search", new_callable=AsyncMock)
|
||
|
|
@patch("app.plugins.builtins.unified_search.ai_tool.llm_analyze_query", new_callable=AsyncMock)
|
||
|
|
async def test_unified_search_handler_returns_compact_results(
|
||
|
|
mock_llm, mock_hybrid, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""unified_search_handler returns compact results."""
|
||
|
|
mock_llm.return_value = {"normalized_query": "test", "semantic_terms": []}
|
||
|
|
mock_hybrid.return_value = [
|
||
|
|
{
|
||
|
|
"entity_type": "contact",
|
||
|
|
"entity_id": str(uuid.uuid4()),
|
||
|
|
"title": "John Doe",
|
||
|
|
"snippet": "john@example.com",
|
||
|
|
"score": 0.95,
|
||
|
|
}
|
||
|
|
]
|
||
|
|
|
||
|
|
# Patch the session factory to use the test session
|
||
|
|
sf = async_sessionmaker(bind=db_session.bind, expire_on_commit=False, class_=AsyncSession)
|
||
|
|
with patch("app.core.db.get_session_factory", return_value=sf):
|
||
|
|
result = await unified_search_handler(
|
||
|
|
{"query": "John", "limit": 5},
|
||
|
|
{"tenant_id": str(uuid.uuid4()), "user_id": str(uuid.uuid4()), "is_system_admin": True},
|
||
|
|
)
|
||
|
|
|
||
|
|
data = json.loads(result)
|
||
|
|
assert "count" in data
|
||
|
|
assert "results" in data
|
||
|
|
assert data["count"] == 1
|
||
|
|
assert data["results"][0]["entity_type"] == "contact"
|
||
|
|
assert data["results"][0]["title"] == "John Doe"
|
||
|
|
assert "score" in data["results"][0]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_unified_search_handler_missing_query():
|
||
|
|
"""unified_search_handler returns error for missing query."""
|
||
|
|
result = await unified_search_handler({}, {})
|
||
|
|
data = json.loads(result)
|
||
|
|
assert "error" in data
|
||
|
|
assert data["error"] == "query is required"
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_unified_search_handler_missing_tenant():
|
||
|
|
"""unified_search_handler returns error for missing tenant context."""
|
||
|
|
result = await unified_search_handler({"query": "test"}, {})
|
||
|
|
data = json.loads(result)
|
||
|
|
assert "error" in data
|
||
|
|
assert data["error"] == "missing tenant context"
|
||
|
|
|
||
|
|
|
||
|
|
# ─── 7. New Providers ───
|
||
|
|
|
||
|
|
|
||
|
|
def test_agent_memory_provider_imports_and_flags():
|
||
|
|
"""AgentMemorySearchProvider imports correctly with correct flags."""
|
||
|
|
provider = AgentMemorySearchProvider()
|
||
|
|
assert provider.entity_type == "agent_memory"
|
||
|
|
assert provider.supports_fts is True
|
||
|
|
assert provider.supports_vector is True
|
||
|
|
assert provider.supports_rag is False
|
||
|
|
assert provider.supports_graph is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_ai_chat_provider_imports_and_flags():
|
||
|
|
"""AIChatSearchProvider imports correctly with correct flags."""
|
||
|
|
provider = AIChatSearchProvider()
|
||
|
|
assert provider.entity_type == "ai_chat"
|
||
|
|
assert provider.supports_fts is True
|
||
|
|
assert provider.supports_vector is False
|
||
|
|
assert provider.supports_rag is False
|
||
|
|
assert provider.supports_graph is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_workflow_provider_imports_and_flags():
|
||
|
|
"""WorkflowSearchProvider imports correctly with correct flags."""
|
||
|
|
provider = WorkflowSearchProvider()
|
||
|
|
assert provider.entity_type == "workflow"
|
||
|
|
assert provider.supports_fts is True
|
||
|
|
assert provider.supports_vector is False
|
||
|
|
assert provider.supports_rag is False
|
||
|
|
assert provider.supports_graph is False
|
||
|
|
|
||
|
|
|
||
|
|
def test_new_providers_to_search_result():
|
||
|
|
"""New providers produce correct search result dicts."""
|
||
|
|
agent_memory = AgentMemorySearchProvider()
|
||
|
|
result = agent_memory.to_search_result({"id": "1", "content": "Remembered fact", "memory_type": "fact"})
|
||
|
|
assert result["entity_type"] == "agent_memory"
|
||
|
|
assert result["entity_id"] == "1"
|
||
|
|
assert result["title"] == "Remembered fact"
|
||
|
|
assert result["data"]["memory_type"] == "fact"
|
||
|
|
|
||
|
|
ai_chat = AIChatSearchProvider()
|
||
|
|
result = ai_chat.to_search_result({"id": "2", "content": "Chat message", "role": "user", "session_title": "Session"})
|
||
|
|
assert result["entity_type"] == "ai_chat"
|
||
|
|
assert result["entity_id"] == "2"
|
||
|
|
assert result["title"] == "Session"
|
||
|
|
assert result["data"]["role"] == "user"
|
||
|
|
|
||
|
|
workflow = WorkflowSearchProvider()
|
||
|
|
result = workflow.to_search_result({"id": "3", "name": "Workflow A", "description": "Desc", "trigger_event": "contact.created"})
|
||
|
|
assert result["entity_type"] == "workflow"
|
||
|
|
assert result["entity_id"] == "3"
|
||
|
|
assert result["title"] == "Workflow A"
|
||
|
|
assert result["data"]["trigger_event"] == "contact.created"
|
||
|
|
|
||
|
|
|
||
|
|
# ─── 8. Sensitive Fields Exclusion ───
|
||
|
|
|
||
|
|
|
||
|
|
def test_sensitive_fields_not_in_search_tsv():
|
||
|
|
"""Sensitive fields are excluded from search_tsv via filter_for_search."""
|
||
|
|
from app.core.sensitive_data import filter_for_search, get_sensitive_fields
|
||
|
|
|
||
|
|
sensitive = get_sensitive_fields("contact")
|
||
|
|
assert "password_hash" in sensitive
|
||
|
|
assert "smtp_password" in sensitive
|
||
|
|
assert "api_key" in sensitive
|
||
|
|
|
||
|
|
data = {
|
||
|
|
"displayname": "John Doe",
|
||
|
|
"email_1": "john@example.com",
|
||
|
|
"password_hash": "secret-hash",
|
||
|
|
"smtp_password": "secret-pw",
|
||
|
|
"api_key": "secret-key",
|
||
|
|
}
|
||
|
|
filtered = filter_for_search(data, "contact")
|
||
|
|
assert "displayname" in filtered
|
||
|
|
assert "email_1" in filtered
|
||
|
|
assert "password_hash" not in filtered
|
||
|
|
assert "smtp_password" not in filtered
|
||
|
|
assert "api_key" not in filtered
|
||
|
|
|
||
|
|
|
||
|
|
def test_sensitive_fields_not_in_embedding_text():
|
||
|
|
"""Sensitive fields are excluded from embedding text via filter_for_embeddings."""
|
||
|
|
from app.core.sensitive_data import filter_for_embeddings
|
||
|
|
|
||
|
|
data = {
|
||
|
|
"displayname": "John Doe",
|
||
|
|
"email_1": "john@example.com",
|
||
|
|
"password_hash": "secret-hash",
|
||
|
|
"smtp_password": "secret-pw",
|
||
|
|
"api_key": "secret-key",
|
||
|
|
}
|
||
|
|
filtered = filter_for_embeddings(data, "contact")
|
||
|
|
assert "displayname" in filtered
|
||
|
|
assert "email_1" in filtered
|
||
|
|
assert "password_hash" not in filtered
|
||
|
|
assert "smtp_password" not in filtered
|
||
|
|
assert "api_key" not in filtered
|
||
|
|
|
||
|
|
|
||
|
|
def test_contact_provider_embedding_text_excludes_sensitive():
|
||
|
|
"""ContactSearchProvider.get_embedding_text selects only non-sensitive columns."""
|
||
|
|
provider = ContactSearchProvider()
|
||
|
|
# The SQL selects only safe columns — verify no sensitive column names appear
|
||
|
|
import inspect
|
||
|
|
source = inspect.getsource(provider.get_embedding_text)
|
||
|
|
assert "password_hash" not in source
|
||
|
|
assert "smtp_password" not in source
|
||
|
|
assert "api_key" not in source
|
||
|
|
assert "oauth_token" not in source
|
||
|
|
|
||
|
|
|
||
|
|
def test_sensitive_fields_redacted_in_index_entity():
|
||
|
|
"""index_entity redacts sensitive fields from embedding text."""
|
||
|
|
from app.core.sensitive_data import get_sensitive_fields
|
||
|
|
from app.plugins.builtins.unified_search.embedding import index_entity
|
||
|
|
|
||
|
|
# Verify the sensitive-data guard is present in index_entity source
|
||
|
|
import inspect
|
||
|
|
source = inspect.getsource(index_entity)
|
||
|
|
assert "get_sensitive_fields" in source
|
||
|
|
assert "REDACTED" in source
|