"""Test fixtures: PostgreSQL test DB, Redis, async test client, auth helpers. Each test gets a fresh database schema (created from metadata) and a clean Redis. Auth helpers talk to the HTTP API (integration tests). """ from __future__ import annotations import asyncio import json import uuid from collections.abc import AsyncGenerator from typing import Any import pytest import pytest_asyncio import redis.asyncio as aioredis from httpx import ASGITransport, AsyncClient from sqlalchemy import text from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine, ) from app.config import get_settings from app.core.db import Base, reset_engine_for_testing, close_engine from app.core.auth import hash_password from app.models.tenant import Tenant from app.models.user import User, UserTenant from app.models.role import Role from app.models.company import Company from app.models.contact import Contact, CompanyContact from app.models.plugin import Plugin, PluginMigration from app.main import create_app TEST_DB_URL = "postgresql+asyncpg://leocrm:leocrm@localhost:5432/leocrm_test" def _get_sync_engine(): """Create a sync engine for DDL operations (drop/create schema). Uses postgres superuser because leocrm user doesn't own the public schema. """ from sqlalchemy import create_engine return create_engine( "postgresql+psycopg2://postgres@localhost:5432/leocrm_test", echo=False, ) @pytest.fixture(scope="session", autouse=True) def db_setup(): """Drop and recreate all tables once per test session.""" sync_eng = _get_sync_engine() with sync_eng.connect() as conn: # Drop all tables and types conn.execute(text("DROP SCHEMA public CASCADE;")) conn.execute(text("CREATE SCHEMA public;")) conn.execute(text("GRANT ALL ON SCHEMA public TO leocrm;")) conn.commit() sync_eng.dispose() # Create tables using async engine async def _create(): eng = create_async_engine(TEST_DB_URL, echo=False) async with eng.begin() as conn: await conn.run_sync(Base.metadata.create_all) await eng.dispose() asyncio.get_event_loop().run_until_complete(_create()) yield # Cleanup after session sync_eng = _get_sync_engine() with sync_eng.connect() as conn: conn.execute(text("DROP SCHEMA public CASCADE;")) conn.execute(text("CREATE SCHEMA public;")) conn.execute(text("GRANT ALL ON SCHEMA public TO leocrm;")) conn.commit() sync_eng.dispose() @pytest.fixture(autouse=True) def clean_tables(db_setup): """Clean all table data before each test (preserve schema).""" sync_eng = _get_sync_engine() with sync_eng.connect() as conn: # TRUNCATE all tables with CASCADE — fast and reliable isolation conn.execute(text("TRUNCATE TABLE plugin_migrations, plugins, company_contacts, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, companies, user_tenants, users, tenants CASCADE;")) conn.commit() sync_eng.dispose() yield @pytest_asyncio.fixture async def redis_client() -> AsyncGenerator[aioredis.Redis, None]: """Redis client for tests — flushes DB before and after.""" r = aioredis.from_url("redis://localhost:6379/0", decode_responses=True) await r.flushdb() yield r await r.flushdb() await r.aclose() @pytest_asyncio.fixture async def engine() -> AsyncGenerator[AsyncEngine, None]: """Async engine for the test database.""" eng = create_async_engine(TEST_DB_URL, echo=False) yield eng await eng.dispose() @pytest_asyncio.fixture async def session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]: """Session factory bound to the test engine.""" return async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession) @pytest_asyncio.fixture async def db_session(session_factory: async_sessionmaker[AsyncSession]) -> AsyncGenerator[AsyncSession, None]: """Database session for direct DB operations in tests.""" async with session_factory() as session: yield session await session.rollback() @pytest_asyncio.fixture async def app(engine: AsyncEngine, redis_client: aioredis.Redis): """FastAPI app with test engine injected.""" reset_engine_for_testing(engine) app = create_app() yield app await close_engine() @pytest_asyncio.fixture async def client(app) -> AsyncGenerator[AsyncClient, None]: """HTTP async test client.""" transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c # ─── Seed Data Helpers ─── ORIGIN_HEADER = {"Origin": "http://localhost:5173"} async def seed_tenant_and_users(db: AsyncSession) -> dict[str, Any]: """Seed two tenants with admin, editor, viewer users. Returns dict with all created entity IDs. """ tenant_a = Tenant(name="Tenant A", slug="tenant-a") tenant_b = Tenant(name="Tenant B", slug="tenant-b") db.add_all([tenant_a, tenant_b]) await db.flush() # Admin in tenant A admin_a = User( tenant_id=tenant_a.id, email="admin@tenanta.com", name="Admin A", password_hash=hash_password("TestPass123!"), role="admin", is_active=True, preferences={}, ) # Viewer in tenant A viewer_a = User( tenant_id=tenant_a.id, email="viewer@tenanta.com", name="Viewer A", password_hash=hash_password("TestPass123!"), role="viewer", is_active=True, preferences={}, ) # Editor in tenant A editor_a = User( tenant_id=tenant_a.id, email="editor@tenanta.com", name="Editor A", password_hash=hash_password("TestPass123!"), role="editor", is_active=True, preferences={}, ) # Admin in tenant B admin_b = User( tenant_id=tenant_b.id, email="admin@tenantb.com", name="Admin B", password_hash=hash_password("TestPass123!"), role="admin", is_active=True, preferences={}, ) db.add_all([admin_a, viewer_a, editor_a, admin_b]) await db.flush() # User-tenant memberships ut1 = UserTenant(user_id=admin_a.id, tenant_id=tenant_a.id, is_default=True) ut2 = UserTenant(user_id=viewer_a.id, tenant_id=tenant_a.id, is_default=True) ut3 = UserTenant(user_id=editor_a.id, tenant_id=tenant_a.id, is_default=True) ut4 = UserTenant(user_id=admin_b.id, tenant_id=tenant_b.id, is_default=True) # Admin A is also member of tenant B (for switch-tenant test) ut5 = UserTenant(user_id=admin_a.id, tenant_id=tenant_b.id, is_default=False) db.add_all([ut1, ut2, ut3, ut4, ut5]) await db.flush() # Create a custom role with field permissions in tenant A custom_role = Role( tenant_id=tenant_a.id, name="sales_rep", permissions={"companies": {"read": True, "create": True, "update": True, "delete": False}}, field_permissions={"annual_revenue": "hidden"}, ) db.add(custom_role) await db.flush() # Create a company in tenant A company_a = Company( tenant_id=tenant_a.id, name="Company Alpha", industry="IT", created_by=admin_a.id, updated_by=admin_a.id, ) # Create a company in tenant B company_b = Company( tenant_id=tenant_b.id, name="Company Beta", industry="Finance", created_by=admin_b.id, updated_by=admin_b.id, ) db.add_all([company_a, company_b]) await db.flush() await db.commit() return { "tenant_a": tenant_a, "tenant_b": tenant_b, "admin_a": admin_a, "viewer_a": viewer_a, "editor_a": editor_a, "admin_b": admin_b, "company_a": company_a, "company_b": company_b, "custom_role": custom_role, } async def login_client(client: AsyncClient, email: str, password: str = "TestPass123!") -> dict[str, str]: """Login via HTTP API and return cookies dict.""" resp = await client.post( "/api/v1/auth/login", json={"email": email, "password": password}, headers=ORIGIN_HEADER, ) assert resp.status_code == 200, f"Login failed: {resp.status_code} {resp.text}" return dict(resp.cookies) async def get_auth_client(client: AsyncClient, email: str, password: str = "TestPass123!") -> AsyncClient: """Return a client that's logged in.""" await login_client(client, email, password) return client