from __future__ import annotations import asyncio import os from collections.abc import AsyncIterator, Iterator import pytest import pytest_asyncio from alembic.config import Config from httpx import ASGITransport, AsyncClient from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from testcontainers.postgres import PostgresContainer from alembic import command os.environ.setdefault("MONLET_AUTH_TOKEN", "test-ui-token") os.environ.setdefault("MONLET_ENABLE_DETECTOR", "false") os.environ.setdefault("MONLET_ENABLE_NOTIFIER_WORKER", "false") from monlet_server import db as db_module # noqa: E402 from monlet_server.main import create_app # noqa: E402 from monlet_server.settings import get_settings, reset_settings_cache # noqa: E402 HERE = os.path.dirname(__file__) SERVER_ROOT = os.path.dirname(HERE) @pytest.fixture(scope="session") def event_loop() -> Iterator[asyncio.AbstractEventLoop]: loop = asyncio.new_event_loop() yield loop loop.close() @pytest.fixture(scope="session") def pg_container() -> Iterator[PostgresContainer]: with PostgresContainer("postgres:16-alpine") as pg: yield pg @pytest.fixture(scope="session") def database_url(pg_container: PostgresContainer) -> str: raw = pg_container.get_connection_url() if raw.startswith("postgresql+psycopg2://"): async_url = raw.replace("postgresql+psycopg2://", "postgresql+asyncpg://") elif raw.startswith("postgresql://"): async_url = raw.replace("postgresql://", "postgresql+asyncpg://", 1) else: async_url = raw return async_url @pytest.fixture(scope="session", autouse=True) def _apply_migrations(database_url: str) -> Iterator[None]: os.environ["MONLET_DATABASE_URL"] = database_url reset_settings_cache() cfg = Config(os.path.join(SERVER_ROOT, "alembic.ini")) cfg.set_main_option("script_location", os.path.join(SERVER_ROOT, "alembic")) cfg.set_main_option("sqlalchemy.url", get_settings().sync_database_url) command.upgrade(cfg, "head") # Partitions for `events` are created at runtime, not by the migration. async def _bootstrap() -> None: from sqlalchemy.ext.asyncio import create_async_engine from monlet_server.services.partitioning import ensure_event_partitions eng = create_async_engine(database_url, future=True) try: await ensure_event_partitions(eng) finally: await eng.dispose() asyncio.run(_bootstrap()) yield @pytest_asyncio.fixture async def engine(database_url: str): eng = create_async_engine(database_url, future=True) yield eng await eng.dispose() @pytest_asyncio.fixture async def session(engine) -> AsyncIterator[AsyncSession]: sm = async_sessionmaker(engine, expire_on_commit=False) async with sm() as s: yield s @pytest_asyncio.fixture(autouse=True) async def _truncate_tables(engine) -> AsyncIterator[None]: yield from sqlalchemy import text async with engine.begin() as conn: await conn.execute( text( "TRUNCATE TABLE notification_outbox, incidents, events, event_ingest_dedup, checks, agent_blacklist, agents RESTART IDENTITY CASCADE" ) ) @pytest_asyncio.fixture async def app_client(database_url: str) -> AsyncIterator[AsyncClient]: reset_settings_cache() await db_module.dispose_engine() app = create_app() async with app.router.lifespan_context(app): transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: yield client await db_module.dispose_engine() @pytest.fixture def auth_headers() -> dict[str, str]: from tests._helpers import agent_headers return agent_headers() @pytest.fixture def ui_auth_headers() -> dict[str, str]: return {"Authorization": "Bearer test-ui-token"}