Files
aurum/backend/tests/conftest.py
ZProger 8e46726b4d test: real backend pytest + E2E Playwright suites, both isolated from prod data
backend/tests/: 33 pytest tests against a real Postgres database
(aurum_test, created/migrated/dropped per run) exercised through the actual
HTTP API — not sqlite, not mocks, and the real `aurum` database is never
opened (app lifespan is skipped, get_session is overridden per test).
Covers transaction category-kind validation, year/month/date-range
filtering and pagination, dashboard summary arithmetic, budget status, cash
flow, and the /transactions/years picker.

e2e/: 6 Playwright specs driving a real browser against a fully separate
throwaway docker-compose stack (run.sh: separate project name, database,
port, and volume, torn down with `down -v` on exit) — regression coverage
for the Recent Transactions month bug, the Card grid-overflow bug, the
frozen-year bug, the Net Worth default range, and the Dashboard->
Transactions deep link, all fixed earlier this session.

Also synced the backend's APP_VERSION constant (stale at 0.15.2, embedded
in exported backups) with the frontend version.
2026-08-16 22:56:53 +03:00

138 lines
5.6 KiB
Python

"""Test harness wiring.
Everything here talks to a dedicated ``aurum_test`` Postgres database on the
same server the app already uses — created fresh, migrated with the real
Alembic chain, and dropped again at the end of the run. The app's own
``AsyncSessionLocal``/``lifespan`` (which would touch the real ``aurum``
database with the user's actual financial history) is never invoked: the
ASGI app is exercised directly over httpx without running startup events,
and the ``get_session`` dependency is overridden per-test to point at the
test database instead. See tests/README.md for how to run this.
"""
import asyncio
import os
import subprocess
from collections.abc import AsyncGenerator, Generator
from pathlib import Path
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.api.deps import get_session
from app.core.config import get_settings
from app.db.base import Base
from app.db.seed import seed_default_account, seed_default_app_settings, seed_default_categories
from app.main import app
BACKEND_DIR = Path(__file__).resolve().parents[1]
TEST_DB_NAME = "aurum_test"
_base_settings = get_settings()
def _url(db_name: str) -> str:
return (
f"postgresql+asyncpg://{_base_settings.postgres_user}:{_base_settings.postgres_password}"
f"@{_base_settings.postgres_host}:{_base_settings.postgres_port}/{db_name}"
)
async def _drop_and_create_test_database() -> None:
engine = create_async_engine(_url("postgres"), isolation_level="AUTOCOMMIT")
async with engine.connect() as conn:
await conn.execute(text(f'DROP DATABASE IF EXISTS "{TEST_DB_NAME}" WITH (FORCE)'))
await conn.execute(text(f'CREATE DATABASE "{TEST_DB_NAME}"'))
await engine.dispose()
async def _drop_test_database() -> None:
engine = create_async_engine(_url("postgres"), isolation_level="AUTOCOMMIT")
async with engine.connect() as conn:
await conn.execute(text(f'DROP DATABASE IF EXISTS "{TEST_DB_NAME}" WITH (FORCE)'))
await engine.dispose()
@pytest.fixture(scope="session")
def _test_database() -> Generator[None, None, None]:
"""Create aurum_test from scratch and run every Alembic migration
against it. Connects to Postgres' always-present `postgres` maintenance
database to issue CREATE/DROP DATABASE — the real `aurum` database is
never opened by this fixture.
Deliberately a *sync* fixture that drives asyncio.run() itself rather
than an async one: session-scoped async fixtures need to share a loop
with function-scoped async tests, which pytest-asyncio doesn't do by
default and led to "attached to a different loop" errors here. A plain
sync fixture sidesteps the whole question — asyncio.run() opens and
cleanly closes its own throwaway loop for each of the two calls below.
"""
asyncio.run(_drop_and_create_test_database())
env = {**os.environ, "AURUM_POSTGRES_DB": TEST_DB_NAME}
subprocess.run(["alembic", "upgrade", "head"], cwd=BACKEND_DIR, env=env, check=True)
yield
asyncio.run(_drop_test_database())
@pytest_asyncio.fixture
async def test_sessionmaker(_test_database) -> AsyncGenerator[async_sessionmaker[AsyncSession], None]:
# Function-scoped, not session-scoped: pytest-asyncio gives each test
# function its own event loop, and an asyncpg engine/pool created under
# one loop can't be reused from another ("attached to a different
# loop"). Recreating the engine per test keeps it bound to whichever
# loop is actually running.
engine = create_async_engine(_url(TEST_DB_NAME), pool_pre_ping=True)
yield async_sessionmaker(bind=engine, expire_on_commit=False, autoflush=False)
await engine.dispose()
@pytest_asyncio.fixture(autouse=True)
async def _clean_database(test_sessionmaker):
"""Wipe every table and reseed the default categories/account/app
settings before each test, so tests never see leftovers from a previous
one and never have to guess at auto-incremented IDs from prior runs."""
async with test_sessionmaker() as session:
for table in reversed(Base.metadata.sorted_tables):
await session.execute(text(f'TRUNCATE TABLE "{table.name}" RESTART IDENTITY CASCADE'))
await session.commit()
await seed_default_categories(session)
await seed_default_account(session)
await seed_default_app_settings(session)
yield
@pytest_asyncio.fixture
async def client(test_sessionmaker) -> AsyncGenerator[AsyncClient, None]:
async def override_get_session() -> AsyncGenerator[AsyncSession, None]:
async with test_sessionmaker() as session:
yield session
app.dependency_overrides[get_session] = override_get_session
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test/api") as ac:
yield ac
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def account_id(client: AsyncClient) -> int:
"""The default seeded account (see app/db/seed.py) — every transaction
needs one, and the app itself always seeds exactly this one on first
boot, so tests build on the same shape real usage does."""
resp = await client.get("/accounts")
accounts = resp.json()
assert accounts, "seed_default_account should have created exactly one account"
return accounts[0]["id"]
@pytest_asyncio.fixture
async def categories(client: AsyncClient) -> dict[str, dict]:
"""Default seeded categories keyed by name, e.g. categories["Groceries"]["id"]."""
resp = await client.get("/categories")
return {c["name"]: c for c in resp.json()}