"""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 abacus_event.api import create_app from abacus_event.config import Config from abacus_event.models.statement_period import StatementPeriod from tests.utils.collections import dedupe TOP_LEVEL_TABLES = [ 'abacus_event', ] class TestConfig(Config): """Test configuration.""" Testing = True ENVIRONMENT = os.environ.get('TEST_ENVIRONMENT', Config.ENVIRONMENT) # DB name will be overridden per worker. MYSQL_DB_NAME = os.environ.get('MYSQL_TEST_DB_NAME', Config.MYSQL_DB_NAME + '_test') 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)) # --- Helpers --- @lru_cache(maxsize=None) def _create_vendor_app(vendor: Dialect, worker_id: str | None) -> Flask: """Create a Flask app once per vendor and reuse across tests.""" class AppConfig(TestConfig): DB_VENDOR = vendor adapter = get_adapter(vendor) # Create the worker DB if worker_id: if hasattr(AppConfig, 'MYSQL_DB_NAME'): AppConfig.MYSQL_DB_NAME = f'{AppConfig.MYSQL_DB_NAME}_{worker_id}' if hasattr(AppConfig, 'SQLITE_DB_NAME'): AppConfig.SQLITE_DB_NAME = f'{AppConfig.SQLITE_DB_NAME}_{worker_id}' if not adapter.db_exists(AppConfig): adapter.create_db(AppConfig) # Create the app app = create_app(AppConfig) # Clone source DB -> worker DB if worker_id: src_engine = adapter.create_engine(TestConfig) dst_engine = adapter.create_engine(AppConfig) try: if str(src_engine.url) != str(dst_engine.url): adapter.clone_db(src_engine, dst_engine) finally: src_engine.dispose() dst_engine.dispose() # Create DB tables with app.app_context(): db.create_all() return app @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_chosen_vendor(node: Node) -> Dialect: """Resolve the winning vendor for the given test node (fast if cached).""" order = _get_node_vendors(node) vendor, _ = _get_engine(order) return vendor 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.fail('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.') # --- App --- @pytest.fixture def test_app(request, worker_id: str): """Create a test application.""" vendor = _get_chosen_vendor(request.node) return _create_vendor_app(vendor, worker_id) @pytest.fixture(autouse=True) def test_app_in_context(test_app): """Push the test app onto the context.""" 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.""" 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.""" with test_app_in_context.test_client() as test_client: yield test_client @pytest.fixture def db_session(test_app, request) -> Generator[Session]: """Transactional SQLAlchemy Session that rolls back after each test.""" # Dedicated connection conn = db.engine.connect() # If the test asked for fresh_db, truncate tables before any txn if 'fresh_db' in request.fixturenames: get_adapter(conn).truncate_tables(conn, TOP_LEVEL_TABLES) # Outer transaction 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() # Seed here so it’s inside the txn if 'fresh_db' in request.fixturenames: seed_database() 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() @pytest.fixture def fresh_db(test_app, request): """Refresh the test database by truncating and seeding.""" # If db_session is also being used, let it handle the truncate/seed if 'db_session' in request.fixturenames: return True conn = db.engine.connect() try: get_adapter(conn).truncate_tables(conn, TOP_LEVEL_TABLES) seed_database() db.session.commit() finally: conn.close() return True # --- Seeds --- def seed_database() -> None: """Seed the database by running all seed functions.""" seed_current_statement_period() def seed_current_statement_period() -> None: """Seed a 'current' statement_period inside the test transaction.""" if StatementPeriod.get_current(): return StatementPeriod.upsert( statement_period_id=282, statement_period_name='June 2022', statement_period_status='current', statement_month=6, statement_year=2022, ) db.session.flush()