diff --git a/surfsense_backend/pyproject.toml b/surfsense_backend/pyproject.toml index 7f52d4881..af3abc457 100644 --- a/surfsense_backend/pyproject.toml +++ b/surfsense_backend/pyproject.toml @@ -70,6 +70,21 @@ dependencies = [ [dependency-groups] dev = [ "ruff>=0.12.5", + "pytest>=8.0", + "pytest-asyncio>=0.25", + "pytest-mock>=3.14", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +testpaths = ["tests"] +markers = [ + "unit: pure logic tests, no DB or external services", + "integration: tests that require a real PostgreSQL database", +] +filterwarnings = [ + "ignore::UserWarning:chonkie", ] [tool.ruff] diff --git a/surfsense_backend/tests/__init__.py b/surfsense_backend/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/conftest.py b/surfsense_backend/tests/conftest.py new file mode 100644 index 000000000..df36827b1 --- /dev/null +++ b/surfsense_backend/tests/conftest.py @@ -0,0 +1,16 @@ +import pytest + + +@pytest.fixture +def sample_user_id() -> str: + return "00000000-0000-0000-0000-000000000001" + + +@pytest.fixture +def sample_search_space_id() -> int: + return 1 + + +@pytest.fixture +def sample_connector_id() -> int: + return 42 diff --git a/surfsense_backend/tests/integration/__init__.py b/surfsense_backend/tests/integration/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/integration/conftest.py b/surfsense_backend/tests/integration/conftest.py new file mode 100644 index 000000000..f36649c5b --- /dev/null +++ b/surfsense_backend/tests/integration/conftest.py @@ -0,0 +1,46 @@ + +import os + +import pytest_asyncio +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.pool import NullPool + +from app.db import Base + +_DEFAULT_TEST_DB = "postgresql+asyncpg://postgres:postgres@localhost:5432/surfsense_test" +TEST_DATABASE_URL = os.environ.get("TEST_DATABASE_URL", _DEFAULT_TEST_DB) + + +@pytest_asyncio.fixture(scope="session") +async def async_engine(): + engine = create_async_engine(TEST_DATABASE_URL, poolclass=NullPool, echo=False) + + async with engine.begin() as conn: + await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + await conn.run_sync(Base.metadata.create_all) + + yield engine + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + await engine.dispose() + + +@pytest_asyncio.fixture +async def db_session(async_engine) -> AsyncSession: + # Bind the session to a connection that holds an outer transaction. + # join_transaction_mode="create_savepoint" makes session.commit() release + # a SAVEPOINT instead of committing the outer transaction, so the final + # transaction.rollback() undoes everything — including commits made by the + # service under test — leaving the DB clean for the next test. + async with async_engine.connect() as conn: + transaction = await conn.begin() + async with AsyncSession( + bind=conn, + expire_on_commit=False, + join_transaction_mode="create_savepoint", + ) as session: + yield session + await transaction.rollback() diff --git a/surfsense_backend/tests/integration/indexing_pipeline/__init__.py b/surfsense_backend/tests/integration/indexing_pipeline/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/__init__.py b/surfsense_backend/tests/unit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/adapters/__init__.py b/surfsense_backend/tests/unit/adapters/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/surfsense_backend/tests/unit/conftest.py b/surfsense_backend/tests/unit/conftest.py new file mode 100644 index 000000000..98fcfc147 --- /dev/null +++ b/surfsense_backend/tests/unit/conftest.py @@ -0,0 +1,49 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +_EMBEDDING_DIM = 4 # keep vectors tiny in tests; real model uses 768+ + + +@pytest.fixture +def mock_session() -> AsyncMock: + session = AsyncMock() + session.add = MagicMock() # synchronous in real SQLAlchemy + session.execute = AsyncMock() + session.scalar = AsyncMock() + session.scalars = AsyncMock() + session.flush = AsyncMock() + session.commit = AsyncMock() + session.rollback = AsyncMock() + session.refresh = AsyncMock() + return session + + +@pytest.fixture +def mock_llm() -> AsyncMock: + llm = AsyncMock() + llm.ainvoke = AsyncMock(return_value=MagicMock(content="Mocked summary.")) + return llm + + +@pytest.fixture +def mock_embedding_model() -> MagicMock: + model = MagicMock() + model.embed = MagicMock( + side_effect=lambda texts: [[0.1] * _EMBEDDING_DIM for _ in texts] + ) + return model + + +@pytest.fixture +def mock_chunker() -> MagicMock: + chunker = MagicMock() + chunker.chunk = MagicMock(return_value=["chunk one", "chunk two"]) + return chunker + + +@pytest.fixture +def mock_code_chunker() -> MagicMock: + chunker = MagicMock() + chunker.chunk = MagicMock(return_value=["chunk one", "chunk two"]) + return chunker diff --git a/surfsense_backend/tests/unit/indexing_pipeline/__init__.py b/surfsense_backend/tests/unit/indexing_pipeline/__init__.py new file mode 100644 index 000000000..e69de29bb