"""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.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 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