""" Pytest configuration and shared fixtures for Library Desk tests. TENANT SAFETY MODEL =================== There is no separate test infrastructure: integration tests run against the SHARED production services (Qdrant / Neo4j / Wiki.js / Redis / Wiki.js). Tenancy is the ONLY isolation wall, therefore: - The suite is pinned to the reserved test tenant ``llm_tester`` (env ``TEST_TENANT`` may only select a tenant inside the reserved ``llm_tester*`` namespace — anything else aborts the whole session). - The production tenant ``jpmschweitzer`` is NEVER written to. A session guard hard-fails immediately if the effective tenant is the production tenant or outside the reserved namespace. - Integration tests (marked ``integration``) only run when ``RUN_INTEGRATION_TESTS=1`` is set; otherwise they are skipped. Offline unit tests never contact the shared services. - A session-scoped teardown deletes ALL ``llm_tester`` artifacts created during the run (Qdrant ``*_llm_tester`` collections, Neo4j nodes under the ``User_Llm_Tester*`` labels, the ``users/llm_tester`` wiki subtree, and ``llm_tester``-prefixed Redis keys on the service DB), with a hard assertion on the tenant string before any delete. HOSTS ===== ``TEST_HOST`` selects where the shared services live. It defaults to ``localhost`` (safe: nothing listens there unless you forwarded the services yourself). For live runs against the shared stack set it explicitly, e.g. ``TEST_HOST=192.168.86.149``. The API under test is the LOCAL wakeup server (``./wakeup.sh``, port 8778), selected via ``LIBRARY_DESK_URL`` (default ``http://localhost:8778``). NEVER point tests at the production container (port 8089). """ import asyncio import logging import os import pytest from src.core.multi_tenancy import sanitize_user_id logger = logging.getLogger(__name__) pytest_plugins = ("pytest_asyncio",) # --------------------------------------------------------------------------- # Tenancy constants # --------------------------------------------------------------------------- #: The production tenant. No test may ever write under it. PRODUCTION_TENANT = "jpmschweitzer" #: The reserved test tenant namespace. The effective tenant must be #: exactly this or a sub-tenant of it (llm_tester_*). RESERVED_TEST_TENANT = "llm_tester" #: Effective tenant for the whole suite (guard-checked below). TEST_TENANT = os.environ.get("TEST_TENANT", RESERVED_TEST_TENANT) # --------------------------------------------------------------------------- # Hosts / URLs # --------------------------------------------------------------------------- #: Shared-services host. Default localhost — NOT the production host. TEST_HOST = os.environ.get("TEST_HOST", "localhost") #: Base URL of the local dev server under test (./wakeup.sh, port 8778). LIBRARY_DESK_URL = os.environ.get("LIBRARY_DESK_URL", "http://localhost:8778") RUN_INTEGRATION = os.environ.get("RUN_INTEGRATION_TESTS") == "1" def is_reserved_test_tenant(tenant: str) -> bool: """True if tenant is inside the reserved llm_tester namespace.""" sanitized = sanitize_user_id(tenant) return sanitized == RESERVED_TEST_TENANT or sanitized.startswith( RESERVED_TEST_TENANT + "_" ) def assert_safe_test_tenant(tenant: str) -> str: """ Hard assertion used before ANY destructive operation. Raises AssertionError unless the tenant is inside the reserved test namespace and is not the production tenant. """ sanitized = sanitize_user_id(tenant) assert sanitized != sanitize_user_id(PRODUCTION_TENANT), ( f"TENANT GUARD: refusing to touch production tenant {tenant!r}" ) assert is_reserved_test_tenant(tenant), ( f"TENANT GUARD: {tenant!r} is not in the reserved test namespace " f"({RESERVED_TEST_TENANT}*)" ) return sanitized # --------------------------------------------------------------------------- # Session guard + integration gating # --------------------------------------------------------------------------- def pytest_collection_modifyitems(config, items): """Skip integration-marked tests unless explicitly enabled AND safe.""" if RUN_INTEGRATION and is_reserved_test_tenant(TEST_TENANT): return reason = ( "integration tests disabled (set RUN_INTEGRATION_TESTS=1, TEST_HOST " "and TEST_TENANT inside the reserved llm_tester namespace to run " "against the shared services)" ) skip_marker = pytest.mark.skip(reason=reason) for item in items: if "integration" in item.keywords: item.add_marker(skip_marker) @pytest.fixture(scope="session", autouse=True) def tenant_guard(): """ Session guard: hard-fail the entire run if the effective tenant is the production tenant or outside the reserved test namespace. """ if sanitize_user_id(TEST_TENANT) == sanitize_user_id(PRODUCTION_TENANT): pytest.exit( f"TENANT GUARD: effective test tenant is the PRODUCTION tenant " f"({TEST_TENANT!r}) - aborting the whole session.", returncode=3, ) if not is_reserved_test_tenant(TEST_TENANT): pytest.exit( f"TENANT GUARD: effective test tenant {TEST_TENANT!r} is not in " f"the reserved test namespace ({RESERVED_TEST_TENANT}*) - " f"aborting the whole session.", returncode=3, ) yield # --------------------------------------------------------------------------- # Session teardown: purge ALL llm_tester artifacts created during the run # --------------------------------------------------------------------------- async def _purge_qdrant_test_artifacts() -> None: """Delete Qdrant collections belonging to the reserved test tenant.""" from qdrant_client import QdrantClient tenant = assert_safe_test_tenant(TEST_TENANT) client = QdrantClient(url=f"http://{TEST_HOST}:6333", timeout=10) try: for coll in client.get_collections().collections: name = coll.name # Only *_ style collections (library_desk_llm_tester, # volatile_llm_tester, memories_llm_tester, ...). if not (name.endswith(f"_{tenant}") or f"_{tenant}_" in name): continue assert PRODUCTION_TENANT not in name # hard guard assert tenant in name client.delete_collection(name) logger.info(f"[teardown] deleted Qdrant collection {name}") finally: client.close() async def _purge_neo4j_test_artifacts() -> None: """Delete Neo4j nodes under the reserved test tenant's labels.""" from src.clients.neo4j_client import Neo4jClient from src.config import get_settings from src.core.multi_tenancy import get_neo4j_user_base_label tenant = assert_safe_test_tenant(TEST_TENANT) label_prefix = get_neo4j_user_base_label(tenant) # e.g. User_Llm_Tester assert "Jpmschweitzer" not in label_prefix # hard guard assert "Llm_Tester" in label_prefix settings = get_settings() client = Neo4jClient( uri=f"bolt://{TEST_HOST}:7687", user=settings.neo4j_user, password=settings.neo4j_password, ) try: await client.connect() result = await client.execute_write( """ MATCH (n) WHERE any(l IN labels(n) WHERE l STARTS WITH $prefix) DETACH DELETE n RETURN count(n) AS deleted """, {"prefix": label_prefix}, ) deleted = result[0]["deleted"] if result else 0 if deleted: logger.info(f"[teardown] deleted {deleted} Neo4j {label_prefix}* nodes") finally: await client.close() async def _purge_wiki_test_artifacts() -> None: """Delete the reserved test tenant's wiki subtree (users/llm_tester).""" from src.clients.wikijs_client import WikiJSClient from src.config import get_settings tenant = assert_safe_test_tenant(TEST_TENANT) settings = get_settings() client = WikiJSClient( base_url=settings.wikijs_url, api_token=settings.wiki_graphql_api, ) try: for prefix in (f"users/{tenant}", f"users/{tenant.replace('_', '-')}"): assert PRODUCTION_TENANT not in prefix # hard guard pages = await client.list_all_pages(path_prefix=prefix) for page in pages: path = page.get("path", "") assert PRODUCTION_TENANT not in path # hard guard assert path.lstrip("/").startswith("users/llm") await client.delete_page(page["id"]) logger.info(f"[teardown] deleted wiki page {path} (id={page['id']})") finally: await client.close() async def _purge_redis_test_artifacts() -> None: """Delete llm_tester-prefixed keys on the Redis DB the service uses.""" import redis.asyncio as aioredis from src.config import get_settings tenant = assert_safe_test_tenant(TEST_TENANT) settings = get_settings() client = aioredis.from_url( f"redis://{TEST_HOST}:6379/{settings.redis_db}", encoding="utf-8", decode_responses=True, ) try: deleted = 0 for pattern in (f"*{tenant}*", f"*{tenant.replace('_', '-')}*"): async for key in client.scan_iter(match=pattern, count=200): assert PRODUCTION_TENANT not in key # hard guard assert "llm" in key await client.delete(key) deleted += 1 if deleted: logger.info(f"[teardown] deleted {deleted} Redis keys for {tenant}") finally: await client.aclose() @pytest.fixture(scope="session", autouse=True) def purge_test_tenant_artifacts(tenant_guard): """ Session teardown: after the run, delete ALL artifacts under the reserved test tenant on the shared services. Only active for integration runs (offline unit-test runs never touch the shared services and must not try to connect to them). """ yield if not RUN_INTEGRATION: return # Hard assertion before ANY delete. assert_safe_test_tenant(TEST_TENANT) async def _teardown(): for name, purge in ( ("qdrant", _purge_qdrant_test_artifacts), ("neo4j", _purge_neo4j_test_artifacts), ("wiki", _purge_wiki_test_artifacts), ("redis", _purge_redis_test_artifacts), ): try: await purge() except AssertionError: raise # tenant-guard violations must never be swallowed except Exception as e: logger.warning(f"[teardown] {name} purge failed: {e}") # Sync fixture + asyncio.run avoids the session/function loop-scope # mismatch (see CLAUDE.md). asyncio.run(_teardown()) # --------------------------------------------------------------------------- # Shared fixtures # --------------------------------------------------------------------------- @pytest.fixture def test_user() -> str: """The reserved test tenant. Pinned for the whole suite.""" return TEST_TENANT @pytest.fixture def library_desk_url() -> str: """Base URL of the LOCAL dev server under test (never production :8089).""" return LIBRARY_DESK_URL @pytest.fixture def neo4j_test_uri() -> str: """Test Neo4j URI.""" return f"bolt://{TEST_HOST}:7687" @pytest.fixture def neo4j_test_auth() -> tuple: """Test Neo4j authentication.""" from src.config import get_settings settings = get_settings() return ("neo4j", settings.neo4j_password) @pytest.fixture def qdrant_test_url() -> str: """Test Qdrant URL.""" return f"http://{TEST_HOST}:6333" @pytest.fixture def wikijs_test_config() -> dict: """Test Wiki.js configuration (URL from settings, host env-overridable).""" from src.config import get_settings settings = get_settings() return { "base_url": settings.wikijs_url, "api_token": settings.wiki_graphql_api } @pytest.fixture def searxng_test_url() -> str: """Test SearXNG URL.""" return f"http://{TEST_HOST}:8080" @pytest.fixture def ollama_test_config() -> dict: """Test Ollama configuration.""" from src.config import get_settings settings = get_settings() return { "base_url": f"http://{TEST_HOST}:11434", "model": settings.ollama_embedding_model } @pytest.fixture def redis_test_url() -> str: """Test Redis URL.""" from src.config import get_settings settings = get_settings() return f"redis://{TEST_HOST}:6379/{settings.redis_db}" @pytest.fixture def sample_document() -> dict: """Sample document for testing.""" return { "id": "test_doc_1", "title": "Test Document", "content": "This is a test document for unit testing.", "metadata": { "source": "test", "author": TEST_TENANT } } @pytest.fixture def sample_chunks() -> list: """Sample document chunks for testing.""" return [ { "content": "First chunk of text.", "metadata": {"chunk_index": 0} }, { "content": "Second chunk of text.", "metadata": {"chunk_index": 1} }, { "content": "Third chunk of text.", "metadata": {"chunk_index": 2} } ] @pytest.fixture def sample_embeddings() -> list: """Sample embeddings for testing (768-dimensional for nomic-embed-text).""" import random random.seed(42) # Reproducible embeddings # Generate 3 sample 768-dimensional embeddings return [ [random.random() for _ in range(768)], [random.random() for _ in range(768)], [random.random() for _ in range(768)] ]