import logging import os import re import time import uuid from collections.abc import AsyncGenerator, Callable from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import jwt import pytest import pytest_asyncio from cashews.backends.interface import ControlMixin from cashews.picklers import PicklerType from fakeredis import FakeAsyncRedis from fastapi import Request from fastapi.responses import JSONResponse from fastapi.testclient import TestClient from nanoid import generate as generate_nanoid from sqlalchemy import create_engine, text from sqlalchemy.engine.url import URL, make_url from sqlalchemy.exc import OperationalError, ProgrammingError from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine, ) from sqlalchemy_utils import ( create_database, # pyright: ignore[reportUnknownVariableType] database_exists, # pyright: ignore[reportUnknownVariableType] ) from src import models from src.cache.client import cache from src.config import settings from src.db import Base from src.dependencies import get_db, get_read_db from src.exceptions import HonchoException from src.models import Peer, Workspace from src.security import JWTParams, create_admin_jwt, create_jwt # Disable Langfuse for the whole suite before importing src.main: @conditional_observe # binds to settings.LANGFUSE_PUBLIC_KEY at import time, so blanking it here keeps mocked # test calls from emitting traces to a configured Langfuse backend. Tests that exercise # Langfuse patch settings.LANGFUSE_PUBLIC_KEY themselves. settings.LANGFUSE_PUBLIC_KEY = None from src.main import app # noqa: E402 # Create a custom handler that doesn't get closed prematurely class TestHandler(logging.Handler): def __init__(self): super().__init__() self.records: list[logging.LogRecord] = [] def emit(self, record: logging.LogRecord): self.records.append(record) # Setup logging with our custom handler test_handler = TestHandler() logging.basicConfig( level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[test_handler], ) logger = logging.getLogger(__name__) logging.getLogger("sqlalchemy.engine.Engine").disabled = True # Test database URL # TODO use environment variable DB_URI = ( settings.DB.CONNECTION_URI or "postgresql+psycopg://postgres:postgres@localhost:5432/postgres" ) CONNECTION_URI = make_url(DB_URI) _RUNTIME_MOCK_TEST_BLOCKLIST_PREFIXES = ( # Benchmarks and migration tests have their own execution/runtime constraints. "tests/bench/", "tests/alembic/", "tests/unified/", "tests/live_llm/", # Pure llm unit tests should stay isolated from the broader app/runtime fixtures. "tests/llm/", # LLM transport tests mock providers directly and don't need database/runtime setup. "tests/utils/test_length_finish_reason.py", "tests/utils/test_clients.py", # Pure JWT scope tests — operate on src.security directly, no DB needed. "tests/test_security.py", "tests/test_generate_jwt_script.py", ) _LIVE_LLM_MARKER = "live_llm" _LIVE_LLM_SKIP_REASON = "live LLM tests are disabled; pass --live-llm to run them" def _requires_runtime_mocks(nodeid: str) -> bool: return not any( nodeid.startswith(prefix) for prefix in _RUNTIME_MOCK_TEST_BLOCKLIST_PREFIXES ) def _get_nodeid(request: pytest.FixtureRequest) -> str: node = getattr(request, "node", None) nodeid = getattr(node, "nodeid", "") return nodeid if isinstance(nodeid, str) else "" def pytest_addoption(parser: pytest.Parser) -> None: parser.addoption( "--live-llm", action="store_true", default=False, help="Run opt-in live LLM integration tests that call provider APIs.", ) def pytest_collection_modifyitems( config: pytest.Config, items: list[pytest.Item], ) -> None: if config.getoption("--live-llm"): return skip_live = pytest.mark.skip(reason=_LIVE_LLM_SKIP_REASON) for item in items: if _LIVE_LLM_MARKER in item.keywords: item.add_marker(skip_live) _RUN_ID_ENV_VAR = "HONCHO_TEST_RUN_ID" _RUN_ID_TIME_FORMAT = "%Y%m%d%H%M%S" # Only a database whose name carries a run-id timestamp this old is swept. Long # enough that no live suite is ever this stale, short enough that a leak from the # morning is gone by the afternoon. _STALE_DB_AGE_SECONDS = 2 * 60 * 60 # test_db_<14-digit timestamp>_<4 hex>[_gwN] -- only names this function minted. # A pinned HONCHO_TEST_RUN_ID deliberately won't match, so it's never swept. _SWEEPABLE_DB_NAME = re.compile(r"^test_db_(\d{14})_[0-9a-f]{4}(?:_gw\d+)?$") def pytest_configure(config: pytest.Config) -> None: # pyright: ignore[reportUnusedParameter] """Stamp this pytest run with an id so its databases can't collide with another run's. The xdist controller runs this first and its environment is inherited by the workers it spawns, so `setdefault` gives every worker in a run the same id while separate runs (concurrent worktrees, two agents, a local run alongside CI) each get their own. Set the env var yourself to pin a stable name. The id leads with a sortable local-time timestamp so leaked databases can be aged out (see `_sweep_stale_test_databases`); the random tail keeps two runs starting in the same second apart. """ os.environ.setdefault( _RUN_ID_ENV_VAR, f"{time.strftime(_RUN_ID_TIME_FORMAT)}_{uuid.uuid4().hex[:4]}", ) # Workers inherit the controller's env and would each redo this. if os.environ.get("PYTEST_XDIST_WORKER") is None: _sweep_stale_test_databases() def _get_test_db_url(worker_id: str) -> URL: """Get a worker-specific test database URL for pytest-xdist parallelism.""" run_id = os.environ.get(_RUN_ID_ENV_VAR, "local") suffix = "" if worker_id == "master" else f"_{worker_id}" return CONNECTION_URI.set(database=f"test_db_{run_id}{suffix}") def _drop_database(db_url: URL) -> None: """Drop a test database, evicting any connections still holding it open. WITH (FORCE) (pg13+) is what makes this reliable: a pooled connection that outlives engine disposal, or an xdist worker killed mid-query, otherwise leaves the drop failing with "database is being accessed by other users". """ name = db_url.database if not name: return # Maintenance connection: you cannot drop the database you're connected to. engine = create_engine( db_url.set(database="postgres"), isolation_level="AUTOCOMMIT" ) try: with engine.connect() as conn: conn.exec_driver_sql(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)') finally: engine.dispose() def _sweep_stale_test_databases() -> None: """Reclaim test databases left behind by runs that died before teardown. A run killed by SIGKILL, an IDE stop button, an OOM'd worker or `-x` on a hang never reaches the `db_engine` teardown, and since every run mints its own database name nothing later reuses (and thus cleans) it. Two guards keep this from touching a suite that is currently running, which is the whole point of per-run names: - the run-id timestamp in the name must be older than `_STALE_DB_AGE_SECONDS` - the database must have no backends connected to it right now Each covers the other's blind spot: the age check is immune to the race where a database has been created but its first worker hasn't connected yet, and the connection check catches a genuinely long-running suite. Failure to sweep is logged and ignored -- it must never fail a test session. """ cutoff = time.strftime( _RUN_ID_TIME_FORMAT, time.localtime(time.time() - _STALE_DB_AGE_SECONDS) ) try: engine = create_engine( CONNECTION_URI.set(database="postgres"), isolation_level="AUTOCOMMIT" ) try: with engine.connect() as conn: names = [ row[0] for row in conn.exec_driver_sql( "SELECT datname FROM pg_database d " + "WHERE NOT EXISTS (" + " SELECT 1 FROM pg_stat_activity WHERE datname = d.datname" + ")" ) ] finally: engine.dispose() for name in names: match = _SWEEPABLE_DB_NAME.match(name) if match is None or match.group(1) >= cutoff: continue logger.info(f"Dropping stale test database: {name}") _drop_database(CONNECTION_URI.set(database=name)) except Exception as e: logger.warning(f"Could not sweep stale test databases: {e}") # Test API authorization - no longer needed as module-level constants # We'll use settings.AUTH directly where needed def create_test_database(db_url: URL): """Helper function create a database if it does not already exist uses the `sqlalchemy_utils` library to create the database and takes a DB URL as the input Args: db_url (str): Database URL """ try: logger.debug(f"Checking if database exists: {db_url.database}") if not database_exists(db_url): logger.info(f"Creating test database: {db_url.database}") create_database(db_url) logger.info(f"Test database created successfully: {db_url.database}") else: logger.info(f"Database already exists: {db_url.database}") except Exception as e: logger.error(f"Error creating database: {e}") raise async def setup_test_database(db_url: URL): """Helper function to setup the test database takes a DB URL as input and returns a SQLAlchemy engine Args: db_url (str): Database URL Returns: engine: SQLAlchemy engine """ engine = create_async_engine(str(db_url), echo=False) async with engine.connect() as conn: try: logger.info("Attempting to create pgvector extension...") await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) await conn.commit() logger.info("pgvector extension created successfully.") except ProgrammingError as e: logger.error(f"ProgrammingError: {e}") raise RuntimeError( "Failed to create pgvector extension. Make sure it's installed on the PostgreSQL server." ) from e except OperationalError as e: logger.error(f"OperationalError: {e}") raise RuntimeError( "Failed to connect to the database. Check your connection settings." ) from e except Exception as e: logger.error(f"Unexpected error: {e}") raise return engine async def _clear_all_tables(engine: AsyncEngine) -> None: """Remove all data from every mapped table between tests. Uses DELETE rather than TRUNCATE: TRUNCATE rewrites the relfilenode of every table and index it touches, so it costs a flat ~33ms for this schema's 11 tables / 41 indexes no matter how few rows a test actually wrote. DELETE of the same (near-empty) tables, batched into one round trip, is ~3ms. Tables go in reverse dependency order so foreign keys are satisfied without CASCADE. This does not reset identity sequences, so tests must not assert on absolute generated id values -- compare against the ids the test itself created. """ table_names: list[str] = [] for table in reversed(Base.metadata.sorted_tables): if table.schema: table_names.append(f'"{table.schema}"."{table.name}"') else: table_names.append(f'"{table.name}"') if not table_names: return statement = "; ".join(f"DELETE FROM {name}" for name in table_names) async with engine.begin() as conn: await conn.exec_driver_sql(statement) @pytest_asyncio.fixture(scope="session") async def db_engine(worker_id: str): test_db_url = _get_test_db_url(worker_id) create_test_database(test_db_url) engine = await setup_test_database(test_db_url) # Force the schema to 'public' for tests # Save the original schema to restore later original_schema = Base.metadata.schema Base.metadata.schema = "public" # Update all table schemas to public for table in Base.metadata.tables.values(): table.schema = "public" # Drop all tables first to ensure clean state async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) # Then create all tables with current models await conn.run_sync(Base.metadata.create_all) try: yield engine finally: await engine.dispose() # Restore original schema Base.metadata.schema = original_schema for table in Base.metadata.tables.values(): table.schema = original_schema _drop_database(test_db_url) @pytest_asyncio.fixture(scope="function") async def db_session(db_engine: AsyncEngine): """Create a database session for the scope of a single test function""" Session = async_sessionmaker(bind=db_engine, expire_on_commit=False) try: async with Session() as session: try: yield session finally: await session.rollback() finally: await _clear_all_tables(db_engine) @pytest_asyncio.fixture(scope="session") async def fake_cache_session(): """Set up fakeredis for caching once per test session.""" # Store original settings original_enabled = settings.CACHE.ENABLED original_url = settings.CACHE.URL # Create a fake redis instance that persists for the session fake_redis = FakeAsyncRedis(decode_responses=True) # Patch redis creation to use fakeredis # Cashews uses redis.asyncio.from_url to create connections def fake_redis_from_url(*_args: Any, **_kwargs: Any): return fake_redis # Patch the cashews backend's _disable property to avoid ContextVar issues # This works around cashews' ContextVar not being properly initialized in TestClient context original_disable_property = ControlMixin._disable # pyright: ignore[reportPrivateUsage] @property # type: ignore def patched_disable_property(self): # pyright: ignore try: return original_disable_property.fget(self) # pyright: ignore[reportOptionalCall] except LookupError: # Return empty set as default if ContextVar not set in current context return set() # pyright: ignore # Start patching redis_patch = patch("redis.asyncio.from_url", fake_redis_from_url) redis_patch.start() ControlMixin._disable = patched_disable_property # pyright: ignore[reportPrivateUsage, reportAttributeAccessIssue] try: # Enable caching and set URL for tests settings.CACHE.ENABLED = True settings.CACHE.URL = "redis://fake-redis:6379/0" # Setup cache for tests that don't use TestClient (direct CRUD tests) # For TestClient tests, the app's lifespan handler will also call cache.setup() # The ContextVar patch above handles any context issues cache.setup( "redis://fake-redis:6379/0", pickle_type=PicklerType.SQLALCHEMY, enable=True ) yield fake_redis finally: # Stop the patches redis_patch.stop() ControlMixin._disable = original_disable_property # pyright: ignore[reportPrivateUsage, reportAttributeAccessIssue] # Restore original settings settings.CACHE.ENABLED = original_enabled settings.CACHE.URL = original_url @pytest_asyncio.fixture(scope="function", autouse=True) async def fake_cache(fake_cache_session: FakeAsyncRedis): """Clear cache between tests.""" # Clear cache before each test await fake_cache_session.flushall() # pyright: ignore[reportUnknownMemberType] yield cache # Clear cache after each test await fake_cache_session.flushall() # pyright: ignore[reportUnknownMemberType] @pytest.fixture(scope="function") async def client( db_session: AsyncSession, fake_cache_session: FakeAsyncRedis, # pyright: ignore[reportUnusedParameter] monkeypatch: pytest.MonkeyPatch, ) -> AsyncGenerator[TestClient, Any]: """Create a FastAPI TestClient for the scope of a single test function""" # Register exception handlers for tests @app.exception_handler(HonchoException) async def test_exception_handler( # pyright: ignore _: Request, exc: HonchoException ): return JSONResponse( status_code=exc.status_code, content={"detail": exc.detail}, ) async def override_get_db(): yield db_session app.dependency_overrides[get_db] = override_get_db # Read-only routes use get_read_db (AUTOCOMMIT engine) in production; in # tests they must see the same per-test database/session as writes, both # for isolation and so data written by a test is visible to its reads. app.dependency_overrides[get_read_db] = override_get_db # No-op the startup embedding-schema validator inside the lifespan. The # global `engine` it would inspect points to a DB that isn't migrated in # CI (per-worker test DBs are migrated separately by db_engine), and we # don't want the validator to dispose the test engine via the lifespan # finally block either. The validator has its own dedicated coverage in # tests/startup/test_embedding_validator.py against db_engine directly. async def _skip_validate(_engine: object) -> None: return None monkeypatch.setattr("src.main.validate_embedding_schema", _skip_validate) with TestClient(app) as c: if settings.AUTH.USE_AUTH: # give the test client the admin JWT c.headers["Authorization"] = f"Bearer {create_admin_jwt()}" yield c def create_invalid_jwt() -> str: return jwt.encode({"ad": "invalid"}, "this is not the secret", algorithm="HS256") class AuthClient(TestClient): auth_type: str | None = None @pytest.fixture( params=[ ("none", None), # No auth ("invalid", create_invalid_jwt), # Invalid JWT ("empty", lambda: create_jwt(JWTParams())), # Empty JWT ("admin", create_admin_jwt), # Admin JWT ] ) def auth_client( client: AuthClient, request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch, ): """ Fixture that provides a client with different authentication states. Always ensures USE_AUTH is set to True. """ # Ensure USE_AUTH is always True for this fixture monkeypatch.setattr(settings.AUTH, "USE_AUTH", True) monkeypatch.setattr(settings.AUTH, "JWT_SECRET", "test-secret") # Clear any existing Authorization header client.headers.pop("Authorization", None) auth_type, token_func = request.param client.auth_type = auth_type if token_func is not None: token = token_func() client.headers["Authorization"] = f"Bearer {token}" return client @pytest_asyncio.fixture(scope="function") async def sample_data( db_session: AsyncSession, ) -> AsyncGenerator[tuple[Workspace, Peer], Any]: """Helper function to create test data""" # Create test app test_workspace = models.Workspace(name=str(generate_nanoid())) db_session.add(test_workspace) # Create test user test_peer = models.Peer( name=str(generate_nanoid()), workspace_name=test_workspace.name ) db_session.add(test_peer) # Commit so data is visible to independent tracked_db sessions. # _clear_all_tables handles cleanup between tests. await db_session.commit() yield test_workspace, test_peer @pytest.fixture(autouse=True) def mock_langfuse(): """Mock Langfuse decorator and context during tests""" with ( patch("langfuse.observe") as mock_observe, ): # Mock the decorator to just return the function def return_value(func: Callable[..., Any]): return func mock_observe.return_value = return_value # Disable httpx logging during tests logging.getLogger("httpx").setLevel(logging.WARNING) yield # Clean up logging handlers for handler in logging.getLogger().handlers[:]: if isinstance(handler, TestHandler): handler.close() logging.getLogger().removeHandler(handler) def _content_to_embedding(content: str) -> list[float]: """Generate a deterministic embedding from content hash. This ensures different content produces different embeddings, which is critical for deduplication logic to work correctly in tests. """ import hashlib # Hash the content to get a deterministic seed content_hash = hashlib.sha256(content.encode()).digest() vector_dimensions = settings.EMBEDDING.VECTOR_DIMENSIONS # Use hash bytes to generate deterministic floats between -1 and 1 embedding: list[float] = [] for i in range(vector_dimensions): # Use different bytes from hash (cycling through) byte_val = content_hash[i % len(content_hash)] # Normalize to [-1, 1] range embedding.append((byte_val / 255.0) * 2 - 1) return embedding @pytest.fixture(autouse=True) def mock_openai_embeddings(request: pytest.FixtureRequest): """Mock OpenAI embeddings API calls for testing""" if not _requires_runtime_mocks(_get_nodeid(request)): yield return with ( patch("src.embedding_client.embedding_client.embed") as mock_embed, patch( "src.embedding_client.embedding_client.simple_batch_embed" ) as mock_simple_batch_embed, patch( "src.embedding_client.embedding_client.prepare_chunks" ) as mock_prepare_chunks, patch("src.embedding_client.embedding_client.batch_embed") as mock_batch_embed, ): # Mock the embed method to return content-dependent embedding def embed_side_effect(content: str) -> list[float]: return _content_to_embedding(content) mock_embed.side_effect = embed_side_effect async def mock_simple_batch_embed_func(texts: list[str]) -> list[list[float]]: return [_content_to_embedding(text) for text in texts] mock_simple_batch_embed.side_effect = mock_simple_batch_embed_func def mock_prepare_chunks_func( id_resource_dict: dict[str, str], ) -> dict[str, list[str]]: # No real tokenizer in mocks: treat each input as a single chunk. return {text_id: [text] for text_id, text in id_resource_dict.items()} mock_prepare_chunks.side_effect = mock_prepare_chunks_func # Mock the batch_embed method to return content-dependent embeddings async def mock_batch_embed_func( id_resource_dict: dict[str, str], ) -> dict[str, list[list[float]]]: return { text_id: [_content_to_embedding(content)] for text_id, content in id_resource_dict.items() } mock_batch_embed.side_effect = mock_batch_embed_func yield { "embed": mock_embed, "simple_batch_embed": mock_simple_batch_embed, "prepare_chunks": mock_prepare_chunks, "batch_embed": mock_batch_embed, } @pytest.fixture(autouse=True) def mock_vector_store(request: pytest.FixtureRequest): """Mock vector store operations for testing""" if not _requires_runtime_mocks(_get_nodeid(request)): yield return from unittest.mock import AsyncMock, MagicMock from src.vector_store import ( VectorQueryResult, VectorRecord, _hash_namespace_components, ) # Create a mock vector store that stores vectors in memory vector_storage: dict[str, dict[str, tuple[list[float], dict[str, Any]]]] = {} async def mock_upsert_many(namespace: str, vectors: list[VectorRecord]) -> None: if namespace not in vector_storage: vector_storage[namespace] = {} for vector in vectors: vector_storage[namespace][vector.id] = (vector.embedding, vector.metadata) return async def mock_query( namespace: str, embedding: list[float], **kwargs: Any ) -> list[VectorQueryResult]: _ = embedding # unused in mock if namespace not in vector_storage: return [] # Simple mock: return all vectors in the namespace as results results: list[VectorQueryResult] = [] for vec_id, (_vec_embedding, metadata) in vector_storage[namespace].items(): results.append( VectorQueryResult( id=vec_id, score=0.1, # Mock score metadata=metadata, ) ) top_k: int = kwargs.get("top_k", 10) return results[:top_k] async def mock_delete_many(namespace: str, ids: list[str]) -> None: if namespace in vector_storage: for vec_id in ids: vector_storage[namespace].pop(vec_id, None) async def mock_delete_namespace(namespace: str) -> None: vector_storage.pop(namespace, None) # Clear the cache on get_external_vector_store before patching from src.vector_store import get_external_vector_store get_external_vector_store.cache_clear() # type: ignore # Create the mock vector store mock_vs = MagicMock() mock_vs.upsert_many = AsyncMock(side_effect=mock_upsert_many) mock_vs.query = AsyncMock(side_effect=mock_query) mock_vs.delete_many = AsyncMock(side_effect=mock_delete_many) mock_vs.delete_namespace = AsyncMock(side_effect=mock_delete_namespace) def mock_get_vector_namespace( namespace_type: str, workspace_name: str, observer: str | None = None, observed: str | None = None, ) -> str: # Uses real hash function for consistency with production if namespace_type == "document": if observer is None or observed is None: raise ValueError( "observer and observed are required for document namespaces" ) return f"honcho2345.doc.{_hash_namespace_components(workspace_name, observer, observed)}" if namespace_type == "message": return f"honcho2345.msg.{_hash_namespace_components(workspace_name)}" raise ValueError(f"Unknown namespace type: {namespace_type}") mock_vs.get_vector_namespace = mock_get_vector_namespace with ( patch("src.crud.document.get_external_vector_store", return_value=mock_vs), patch("src.crud.workspace.get_external_vector_store", return_value=mock_vs), patch("src.crud.session.get_external_vector_store", return_value=mock_vs), patch("src.crud.message.get_external_vector_store", return_value=mock_vs), patch( "src.reconciler.sync_vectors.get_external_vector_store", return_value=mock_vs, ), patch("src.utils.search.get_external_vector_store", return_value=mock_vs), ): yield mock_vs # Clear cache after test as well for cleanliness get_external_vector_store.cache_clear() # type: ignore @pytest.fixture(autouse=True) def mock_llm_call_functions(request: pytest.FixtureRequest): """Mock LLM functions to avoid needing API keys during tests""" if not _requires_runtime_mocks(_get_nodeid(request)): yield return # Create an async generator for streaming responses async def mock_stream(*args, **kwargs): # pyright: ignore[reportUnusedParameter, reportMissingParameterType, reportUnknownParameterType] """Mock streaming response that yields chunks""" chunks = ["Test ", "streaming ", "response"] for chunk in chunks: yield chunk # Create mock responses for different function types # Note: critical_analysis_call was removed as the deriver now uses agentic approach # Note: dialectic_call/dialectic_stream were replaced with agentic_chat with ( patch( "src.utils.summarizer.create_short_summary", new_callable=AsyncMock ) as mock_short_summary, patch( "src.utils.summarizer.create_long_summary", new_callable=AsyncMock ) as mock_long_summary, patch( "src.routers.peers.agentic_chat", new_callable=AsyncMock ) as mock_agentic_chat, patch( "src.routers.peers.agentic_chat_stream", side_effect=mock_stream ) as mock_agentic_chat_stream, ): # Mock return values for different function types mock_short_summary.return_value = "Test short summary content" mock_long_summary.return_value = "Test long summary content" # Mock agentic_chat to return a string (matching actual return type). # With a response_model (structured output) the real function returns # a JSON string, so mirror that for SDK clients that parse content. async def _agentic_chat_response(*_args: object, **kwargs: object) -> str: if kwargs.get("response_model") is not None: return "{}" return "Test dialectic response" mock_agentic_chat.side_effect = _agentic_chat_response yield { "short_summary": mock_short_summary, "long_summary": mock_long_summary, "agentic_chat": mock_agentic_chat, "agentic_chat_stream": mock_agentic_chat_stream, } @pytest.fixture(autouse=True) def mock_honcho_llm_call(request: pytest.FixtureRequest): """Generic mock for the honcho_llm_call decorator to avoid actual LLM calls during tests""" if not _requires_runtime_mocks(_get_nodeid(request)): yield return from unittest.mock import AsyncMock from src.utils.representation import ( # DeductiveObservationBase, ExplicitObservationBase, PromptRepresentation, ) def create_mock_response( response_model: Any = None, stream: bool = False, return_call_response: bool = False, ) -> Any: """Create a mock response based on the expected return type""" if stream: # For streaming responses, return an async mock mock_stream = AsyncMock() mock_stream.__aiter__.return_value = iter([]) return mock_stream elif response_model: # For structured responses, create appropriate mock objects if getattr(response_model, "__name__", "") == "ReasoningResponse": _rep = PromptRepresentation( explicit=[ ExplicitObservationBase(content="Test explicit observation") ], # deductive=[ # DeductiveObservationBase( # conclusion="Test deductive conclusion", # premises=["Test premise 1", "Test premise 2"], # ), # ], ) mock_response = MagicMock(wraps=_rep) # Add the _response attribute that contains thinking (used in the actual code) mock_response._response = MagicMock() mock_response._response.thinking = "Test thinking content" return mock_response else: # Generic response model mock mock_response = MagicMock(spec=response_model) # Set some default attributes for common use cases if hasattr(mock_response, "content"): mock_response.content = "Test response content" return mock_response elif return_call_response: # For CallResponse objects, create a mock with content and usage mock_response = MagicMock() mock_response.content = "Test response content" mock_response.usage = MagicMock() mock_response.usage.input_tokens = 100 mock_response.usage.output_tokens = 50 return mock_response else: # For string responses, return a simple string return "Test response content" # Patch the honcho_llm_call decorator to prevent actual LLM calls at module level original_decorator = None try: import src.llm original_decorator = src.llm.honcho_llm_call src.llm.honcho_llm_call = lambda *args, **kwargs: lambda func: func # pyright: ignore[reportUnknownLambdaType] except ImportError: pass def decorator_factory(*args: Any, **kwargs: Any) -> Callable[..., Any]: # pyright: ignore[reportUnusedParameter] """Factory function that creates the mock decorator""" def mock_llm_decorator(func: Callable[..., Any]) -> Callable[..., Any]: async def async_wrapper(*func_args: Any, **func_kwargs: Any) -> Any: # pyright: ignore[reportUnusedParameter] # Create and return appropriate mock response return create_mock_response( response_model=kwargs.get("response_model"), stream=kwargs.get("stream", False), return_call_response=kwargs.get("return_call_response", False), ) def sync_wrapper(*func_args: Any, **func_kwargs: Any) -> Any: # pyright: ignore[reportUnusedParameter] # Create and return appropriate mock response return create_mock_response( response_model=kwargs.get("response_model"), stream=kwargs.get("stream", False), return_call_response=kwargs.get("return_call_response", False), ) # Check if the original function is async import inspect if inspect.iscoroutinefunction(func): return async_wrapper else: return sync_wrapper return mock_llm_decorator with patch("src.llm.honcho_llm_call", side_effect=decorator_factory): yield decorator_factory # Restore the original decorator if original_decorator: try: import src.llm src.llm.honcho_llm_call = original_decorator except ImportError: pass @pytest.fixture(autouse=True) def mock_tracked_db(request: pytest.FixtureRequest): """Mock tracked_db to create fresh sessions per call. Using a session factory instead of a shared session avoids asyncio lock errors when multiple tracked_db calls run concurrently via asyncio.gather. """ if not _requires_runtime_mocks(_get_nodeid(request)): yield return from contextlib import ExitStack, asynccontextmanager db_engine = request.getfixturevalue("db_engine") session_factory = async_sessionmaker(bind=db_engine, expire_on_commit=False) @asynccontextmanager async def mock_tracked_db_context(_: str | None = None, *, read_only: bool = False): # read_only is accepted (and ignored): in tests both engines resolve to # the same per-test database session. del read_only async with session_factory() as session: yield session # Each module imports tracked_db by name, so patch every import site. # Use ExitStack (not a parenthesized `with`) to stay under CPython's # 20-statically-nested-block limit as this list grows. tracked_db_targets = [ "src.dependencies.tracked_db", "src.deriver.queue_manager.tracked_db", "src.deriver.consumer.tracked_db", "src.deriver.enqueue.tracked_db", "src.routers.peers.tracked_db", "src.crud.representation.tracked_db", "src.dreamer.orchestrator.tracked_db", "src.dreamer.dream_scheduler.tracked_db", "src.dialectic.chat.tracked_db", "src.utils.summarizer.tracked_db", "src.webhooks.events.tracked_db", "src.webhooks.webhook_delivery.tracked_db", "src.utils.agent_tools.tracked_db", "src.utils.search.tracked_db", "src.crud.document.tracked_db", "src.crud.message.tracked_db", "src.reconciler.sync_vectors.tracked_db", "src.reconciler.embed_now.tracked_db", "src.dialectic.core.tracked_db", "src.dreamer.specialists.tracked_db", "src.dreamer.surprisal.tracked_db", ] with ExitStack() as stack: for target in tracked_db_targets: stack.enter_context(patch(target, mock_tracked_db_context)) yield @pytest.fixture(autouse=True) def enable_deriver_for_tests(request: pytest.FixtureRequest): """Enable deriver globally for tests that need queue processing""" if not _requires_runtime_mocks(_get_nodeid(request)): yield return from src.config import settings original_value = settings.DERIVER.ENABLED settings.DERIVER.ENABLED = True yield settings.DERIVER.ENABLED = original_value @pytest.fixture(autouse=True) def mock_crud_collection_operations(request: pytest.FixtureRequest): """Mock CRUD operations that try to commit to database during tests""" if not _requires_runtime_mocks(_get_nodeid(request)): yield return from nanoid import generate as generate_nanoid from src import models async def mock_get_or_create_collection( _: AsyncSession, workspace_name: str, observer: str, observed: str, ): # Create a mock collection object that doesn't require database commit mock_collection = models.Collection( observer=observer, observed=observed, workspace_name=workspace_name, ) mock_collection.id = generate_nanoid() return mock_collection with patch( "src.crud.get_or_create_collection", mock_get_or_create_collection, ): yield