"""Shared fixtures for the BulkGmailMailer test suite. Strategy: - Unit tests (pure logic: variables, invoice numbers, CSV/TXT parsing, validation, error classification, rotation) run with no external services. - API integration tests run against a REAL PostgreSQL instance (the same engine the app uses) with a dedicated test database created per session and truncated between tests. - Real-send integration tests are skipped unless GMAIL_TEST_* env vars are configured (never send real email in CI without credentials). """ import os import sys import uuid import pytest ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) if ROOT not in sys.path: sys.path.insert(0, ROOT) TEST_DB_NAME = f"bulkmailer_test_{uuid.uuid4().hex[:8]}" POSTGRES_ADMIN_URL = os.environ.get( "TEST_DATABASE_ADMIN_URL", "postgresql+psycopg://mailer:mailer_dev_pw@localhost:5432/postgres", ) TEST_DATABASE_URL = os.environ.get( "TEST_DATABASE_URL", f"postgresql+psycopg://mailer:mailer_dev_pw@localhost:5432/{TEST_DB_NAME}", ) def _probe_postgres() -> bool: """True if a real PostgreSQL server is reachable for integration tests.""" try: from sqlalchemy import create_engine, text eng = create_engine(POSTGRES_ADMIN_URL, pool_pre_ping=True) with eng.connect() as c: c.execute(text("SELECT 1")) eng.dispose() return True except Exception: return False PG_AVAILABLE = _probe_postgres() # --------------------------------------------------------------------------- # Session-scoped: create the real test database, build app bound to it # --------------------------------------------------------------------------- @pytest.fixture(scope="session") def test_app(): if not PG_AVAILABLE: pytest.skip("PostgreSQL not available for integration tests") import importlib from sqlalchemy import create_engine, text # 1. create fresh test database admin = create_engine(POSTGRES_ADMIN_URL, pool_pre_ping=True, isolation_level="AUTOCOMMIT") with admin.connect() as c: c.execute(text(f'DROP DATABASE IF EXISTS "{TEST_DB_NAME}"')) c.execute(text(f'CREATE DATABASE "{TEST_DB_NAME}"')) admin.dispose() # 2. point settings at it and reload DB + main modules in dependency order import app.core.config as config_mod original_url = config_mod.settings.DATABASE_URL config_mod.settings.DATABASE_URL = TEST_DATABASE_URL import app.db.session as db_session_mod importlib.reload(db_session_mod) import app.main as main_mod importlib.reload(main_mod) # 3. create schema from app.models import Base eng = create_engine(TEST_DATABASE_URL, pool_pre_ping=True) Base.metadata.create_all(eng) eng.dispose() yield main_mod.app # 4. restore global modules and drop the test database config_mod.settings.DATABASE_URL = original_url importlib.reload(db_session_mod) admin = create_engine(POSTGRES_ADMIN_URL, pool_pre_ping=True, isolation_level="AUTOCOMMIT") with admin.connect() as c: c.execute(text(f'DROP DATABASE IF EXISTS "{TEST_DB_NAME}"')) admin.dispose() # Tables to truncate between API tests (order-independent via TRUNCATE CASCADE) _TRUNCATE = [ "audit_logs", "email_events", "send_attempts", "send_jobs", "campaign_recipients", "campaigns", "recipients", "recipient_lists", "subject_lines", "html_templates", "attachments", "company_phone_numbers", "suppression_list", "oauth_tokens", "oauth_client_configs", "gmail_accounts", "application_settings", "users", ] @pytest.fixture() def api(test_app): """Fresh TestClient with a clean database and one registered+logged-in user.""" from fastapi.testclient import TestClient from sqlalchemy import create_engine, text # wipe all data (keep schema) eng = create_engine(TEST_DATABASE_URL) with eng.connect() as c: for t in _TRUNCATE: try: c.execute(text(f'TRUNCATE TABLE "{t}" RESTART IDENTITY CASCADE')) except Exception: pass c.commit() eng.dispose() with TestClient(test_app) as client: em = f"t_{uuid.uuid4().hex[:10]}@example.com" pw = "TestPass#12345" r = client.post("/auth/register", json={"email": em, "password": pw, "full_name": "Tester"}) assert r.status_code in (200, 201), f"register failed: {r.status_code} {r.text[:300]}" r = client.post("/auth/login", json={"email": em, "password": pw}) assert r.status_code == 200, f"login failed: {r.status_code} {r.text[:300]}" client.email, client.password = em, pw yield client @pytest.fixture(scope="session") def db_engine(test_app): from sqlalchemy import create_engine