"""Shared fixtures for tests.""" import contextlib import json import logging import os from functools import lru_cache from typing import Any, Generator, cast import pytest from _pytest.nodes import Node from abacus_common_logic.connectors.database import db from abacus_common_logic.db.adapters import Dialect, get_adapter, is_dialect from flask import Flask from sqlalchemy import event, text from sqlalchemy.engine import Engine, Transaction from sqlalchemy.orm import Session, SessionTransaction from core.app_factory import create_app from core.config import Config from core.tests.utils import dedupe class TestConfig(Config): """Test configuration.""" Testing = True ENVIRONMENT = os.environ.get('TEST_ENVIRONMENT', Config.ENVIRONMENT) # Mysql database connection MYSQL_DB_NAME = Config.MYSQL_TEST_DB_NAME RATELIMIT_MODE = ( 'shadow' # limiter still runs (counts/logs) but never blocks the existing tests ) if ENVIRONMENT == Config.DEV_ENVIRONMENT: # Database vendor DB_VENDOR = os.environ.get('DB_VENDOR', 'sqlite,mysql') # SQLite database connection SQLITE_DB_NAME = os.environ.get('SQLITE_DB_NAME', Config.MYSQL_DB_NAME) SQLITE_DB_NAME = os.environ.get('SQLITE_TEST_DB_NAME', f'{SQLITE_DB_NAME}_test') SQLITE_DIR = os.environ.get('SQLITE_DIR', '/var/lib/sqlite') SQLITE_EXT = os.environ.get('SQLITE_EXT', '.sqlite3') @pytest.fixture(scope='session', autouse=True) def _prologue() -> None: """Log the testing configuration.""" payload = { 'env': TestConfig.ENVIRONMENT, 'db(s)': TestConfig.DB_VENDOR, 'mysql': TestConfig.MYSQL_DB_NAME, } if TestConfig.ENVIRONMENT == Config.DEV_ENVIRONMENT: sqlite = f'{TestConfig.SQLITE_DIR}/{TestConfig.SQLITE_DB_NAME}{TestConfig.SQLITE_EXT}' payload.update(sqlite=sqlite) logging.getLogger(__name__).info('Test config: %s', json.dumps(payload)) # --- App --- @pytest.fixture def test_app(_chosen_vendor): """Create a test application.""" return _create_vendor_app(_chosen_vendor) @pytest.fixture(autouse=True) def test_app_in_context(request): """Push the test app onto the context. Pure-unit tests that need no Flask app or DB opt out with ``@pytest.mark.no_db``; for those we skip the app build entirely (and never reach the DB-vendor resolution that would otherwise ``pytest.skip`` them). ``test_app`` is resolved lazily so the marker is honored before the DB is touched. """ if request.node.get_closest_marker('no_db'): yield None return test_app = request.getfixturevalue('test_app') with test_app.app_context(): yield test_app @pytest.fixture(autouse=True) def test_app_request(test_app_in_context): """Push the test app onto the context and trigger preprocessing (skipped for no_db tests).""" if test_app_in_context is None: yield None return with test_app_in_context.test_request_context(): test_app_in_context.preprocess_request() yield test_app_in_context @pytest.fixture def fixture_client(test_app_in_context): """Create a client fixture.""" if test_app_in_context is None: pytest.skip( 'fixture_client requires an app; not available under @pytest.mark.no_db' ) with test_app_in_context.test_client() as test_client: yield test_client # --- DB --- @lru_cache(maxsize=None) def _create_vendor_app(vendor: Dialect) -> Flask: """Create a Flask app once per vendor and reuse across tests.""" class AppConfig(TestConfig): DB_VENDOR = vendor return create_app(AppConfig) def make_app(config: type[Config] = TestConfig) -> Flask: """Build a FRESH (non-lru_cached) app for hardening tests that vary config/policy. Mirrors `_create_vendor_app`: resolve a single available DB vendor and layer it onto the supplied config, then build the app WITHOUT the @lru_cache so each test gets clean process-global limiter/breaker/readiness state. `create_app(TestConfig)` alone would fail DB setup because the raw `DB_VENDOR` may list several vendors (e.g. 'sqlite,mysql'). """ parts = str(getattr(config, 'DB_VENDOR', '')).split(',') vendors = [cast(Dialect, p) for v in parts if is_dialect(p := v.strip().lower())] vendor, _ = _get_engine(vendors) class AppConfig(config): # type: ignore[valid-type, misc] DB_VENDOR = vendor return create_app(AppConfig) @lru_cache(maxsize=None) def _create_vendor_engine(vendor: Dialect) -> Engine: engine = get_adapter(vendor).create_engine(TestConfig) with engine.connect() as conn: conn.execute(text('SELECT 1')) return engine def _get_engine(vendors: list[Dialect]) -> tuple[Dialect, Engine]: errors: list[tuple[str, Exception]] = [] for vendor in vendors: try: return vendor, _create_vendor_engine(vendor) except Exception as e: errors.append((vendor, e)) out = [f'{v}: {e}' for (v, e) in errors] pytest.skip('No DB vendor is available: ' + '; '.join(out)) def _get_node_vendors(node: Node) -> list[Dialect]: groups: list[Any] = [marker.args for marker in node.iter_markers(name='db')] groups.append([getattr(TestConfig, 'DB_VENDOR', '')]) vendors: list[Dialect] = [] for group in groups: for arg in group: parts = arg.split(',') if isinstance(arg, str) else [str(arg)] for vendor in parts: vendor = vendor.strip().lower() if vendor and is_dialect(vendor): vendors.append(cast(Dialect, vendor)) if vendors: return dedupe(vendors) raise ValueError('No supported DB vendors configured.') @pytest.fixture def _chosen_vendor(request) -> str: """Resolve the winning vendor for THIS test (fast if cached).""" order = _get_node_vendors(request.node) vendor, _ = _get_engine(order) return vendor @pytest.fixture def db_session(test_app) -> Generator[Session]: """Transactional SQLAlchemy Session that rolls back after each test.""" # Dedicated connection and outer transaction conn = db.engine.connect() txn = cast(Transaction, conn.begin()) # Bind Flask session to the connection db.session.remove() db.session.configure(bind=conn) # Start a SAVEPOINT so tests can commit/flush db_session = db.session() db_session.begin_nested() @event.listens_for(db.session, 'after_transaction_end') def _restart_savepoint(session_: Session, session_txn: SessionTransaction) -> None: parent = getattr(session_txn, '_parent', None) if session_txn.nested and parent is not None and not parent.nested: # Re-open the SAVEPOINT after each nested txn ends session_.begin_nested() try: yield db_session finally: with contextlib.suppress(Exception): event.remove(db.session, 'after_transaction_end', _restart_savepoint) with contextlib.suppress(Exception): db.session.remove() with contextlib.suppress(Exception): db.session.configure(bind=None) with contextlib.suppress(Exception): txn.rollback() with contextlib.suppress(Exception): conn.close()