mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
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.
138 lines
5.6 KiB
Python
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()}
|