""" Integration Test Fixtures - ServiceManagerWeb (Docker / PostgreSQL) backend/app/tests/conftest.py Usa la BD Docker existente (servicemanager). Los fixtures leen datos reales ya seedeados — no crean ni eliminan nada. Los tests que inserten datos propios quedan aislados por rollback. Tenant de referencia : aduanasoft Usuarios de referencia: admin@aduanasoft.com → ADMIN manager@aduanasoft.com → SUPPORT_MANAGER agente@aduanasoft.com → AGENT auditor1@test.com → AUDITOR (tenant aduanasoft) admin-cliente@empresa-demo → CLIENT_ADMIN test_user@aduanasoft.com → CLIENT_USER """ import os import asyncio import pytest from typing import AsyncGenerator, Generator # ============================================================ # ENV VARS — antes de importar la app # ============================================================ os.environ.setdefault("ENVIRONMENT", "testing") os.environ.setdefault("TESTING", "true") os.environ.setdefault("DEBUG", "false") os.environ.setdefault("SECRET_KEY", "integration-secret-key-32chars!!!!") os.environ.setdefault("JWT_SECRET_KEY", "integration-jwt-secret-32chars!!!!") os.environ.setdefault( "DATABASE_URL", "postgresql+asyncpg://servicemanager:servicemanager123@localhost:5432/servicemanager", ) os.environ.setdefault("REDIS_URL", "redis://localhost:6379/14") os.environ.setdefault("CELERY_BROKER_URL", "redis://localhost:6379/14") os.environ.setdefault("CELERY_RESULT_BACKEND", "redis://localhost:6379/14") os.environ.setdefault("CORS_ORIGINS", "http://localhost:3000") os.environ.setdefault("ALLOWED_FILE_EXTENSIONS", "pdf,jpg,jpeg,png,doc,docx,txt") # ============================================================ # EVENT LOOP (session-scoped) # ============================================================ @pytest.fixture(scope="session") def event_loop() -> Generator: """Event loop compartido para toda la sesión de tests.""" policy = asyncio.get_event_loop_policy() loop = policy.new_event_loop() yield loop loop.close() # ============================================================ # ENGINE (session-scoped — reutiliza el pool toda la sesión) # ============================================================ @pytest.fixture(scope="session") async def engine(): """ Conecta al PostgreSQL Docker existente (servicemanager). NO crea ni destruye el schema — la BD ya está lista. """ from sqlalchemy.ext.asyncio import create_async_engine import app.models # noqa: F401 — registra todos los modelos _engine = create_async_engine(os.environ["DATABASE_URL"], echo=False) yield _engine await _engine.dispose() # ============================================================ # DB (function-scoped — rollback para datos creados en el test) # ============================================================ @pytest.fixture async def db(engine) -> AsyncGenerator: """ Sesión con transacción por test. Los datos seedeados son visibles (ya están committed). Cualquier INSERT hecho en el test se revierte al finalizar. """ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with factory() as session: await session.begin() yield session await session.rollback() # ============================================================ # TENANT (function-scoped — lee el registro existente) # ============================================================ @pytest.fixture async def tenant_a(db): """Tenant 'aduanasoft' ya existente en la BD.""" from sqlalchemy import select from app.models.tenant import Tenant result = await db.execute(select(Tenant).where(Tenant.slug == "aduanasoft")) return result.scalar_one() # ============================================================ # USUARIOS (function-scoped — leen registros existentes) # ============================================================ @pytest.fixture async def admin_user(db, tenant_a): """ADMIN: admin@aduanasoft.com (tenant aduanasoft).""" from sqlalchemy import select from app.models.user import User result = await db.execute( select(User) .where(User.email == "admin@aduanasoft.com") .where(User.tenant_id == tenant_a.id) ) return result.scalar_one() @pytest.fixture async def manager_user(db, tenant_a): """SUPPORT_MANAGER: manager@aduanasoft.com (tenant aduanasoft).""" from sqlalchemy import select from app.models.user import User result = await db.execute( select(User) .where(User.email == "manager@aduanasoft.com") .where(User.tenant_id == tenant_a.id) ) return result.scalar_one() @pytest.fixture async def agent_user(db, tenant_a): """AGENT: agente@aduanasoft.com (tenant aduanasoft).""" from sqlalchemy import select from app.models.user import User result = await db.execute( select(User) .where(User.email == "agente@aduanasoft.com") .where(User.tenant_id == tenant_a.id) ) return result.scalar_one() @pytest.fixture async def user_tenant_a(db, tenant_a): """CLIENT_USER: test_user@aduanasoft.com (tenant aduanasoft).""" from sqlalchemy import select from app.models.user import User result = await db.execute( select(User) .where(User.email == "test_user@aduanasoft.com") .where(User.tenant_id == tenant_a.id) ) return result.scalar_one() # ============================================================ # HTTP CLIENT (function-scoped) # ============================================================ @pytest.fixture async def client(db) -> AsyncGenerator: """ httpx.AsyncClient contra la app FastAPI en memoria (sin red). get_db queda sobreescrito para inyectar la sesión de test. Los cambios del test se revierten al terminar (rollback en db). """ import httpx from httpx import ASGITransport from app.main import app from app.core.database import get_db async def _override_get_db(): yield db app.dependency_overrides[get_db] = _override_get_db async with httpx.AsyncClient( transport=ASGITransport(app=app), base_url="http://test", ) as ac: yield ac app.dependency_overrides.pop(get_db, None) # ============================================================ # FIXTURES DE AISLAMIENTO MULTI-TENANT # ============================================================ @pytest.fixture def make_token(): """Factory de JWT tokens para autenticar clientes HTTP en tests.""" from app.core.security import security def _make(user): return security.create_access_token(data={"sub": str(user.id)}) return _make @pytest.fixture async def app_with_db(db): """ Override de get_db compartido para todos los HTTP clients de un mismo test. Garantiza que todos los clients usen la misma sesión (y el mismo rollback). """ from app.main import app as _app from app.core.database import get_db async def _override(): yield db _app.dependency_overrides[get_db] = _override yield _app _app.dependency_overrides.pop(get_db, None) @pytest.fixture async def tenant_b(db): """Tenant 'empresa-test' creado en la transacción del test (se revierte al final).""" from app.models.tenant import Tenant t = Tenant(name="Empresa Test", slug="empresa-test") db.add(t) await db.flush() return t @pytest.fixture async def user_b(db, tenant_b): """CLIENT_ADMIN en tenant_b — puede gestionar recursos de su tenant.""" from app.models.user import User, UserRole from app.core.security import security u = User( tenant_id=tenant_b.id, email="admin@empresa-test.com", first_name="Admin", last_name="Test", password_hash=security.hash_password("Test1234!"), role=UserRole.CLIENT_ADMIN, is_active=True, email_verified=True, ) db.add(u) await db.flush() return u @pytest.fixture async def client_tenant_a(app_with_db, manager_user, make_token): """HTTP client autenticado como SUPPORT_MANAGER de tenant_a (aduanasoft). Usa manager_user en lugar de admin_user para mantener el aislamiento de tenant en GET /users/ (el ADMIN global bypasa el filtro de tenant). """ import httpx from httpx import ASGITransport token = make_token(manager_user) async with httpx.AsyncClient( transport=ASGITransport(app=app_with_db), base_url="http://test", headers={"Authorization": f"Bearer {token}"}, ) as ac: yield ac @pytest.fixture async def client_tenant_b(app_with_db, user_b, make_token): """HTTP client autenticado como CLIENT_ADMIN de tenant_b (empresa-test).""" import httpx from httpx import ASGITransport token = make_token(user_b) async with httpx.AsyncClient( transport=ASGITransport(app=app_with_db), base_url="http://test", headers={"Authorization": f"Bearer {token}"}, ) as ac: yield ac @pytest.fixture async def client_admin(app_with_db, admin_user, make_token): """HTTP client autenticado como ADMIN global.""" import httpx from httpx import ASGITransport token = make_token(admin_user) async with httpx.AsyncClient( transport=ASGITransport(app=app_with_db), base_url="http://test", headers={"Authorization": f"Bearer {token}"}, ) as ac: yield ac @pytest.fixture def create_ticket_tenant_a(client_tenant_a): """Factory: crea un ticket en tenant_a vía HTTP y retorna el JSON de respuesta.""" async def _create(subject="Ticket Tenant A", priority="MEDIUM"): resp = await client_tenant_a.post("/v1/tickets/", json={ "subject": subject, "description": "Test de aislamiento tenant A", "priority": priority, }) assert resp.status_code in (200, 201), f"Error creando ticket A: {resp.text}" return resp.json() return _create @pytest.fixture def create_ticket_tenant_b(client_tenant_b): """Factory: crea un ticket en tenant_b vía HTTP y retorna el JSON de respuesta.""" async def _create(subject="Ticket Tenant B", priority="MEDIUM"): resp = await client_tenant_b.post("/v1/tickets/", json={ "subject": subject, "description": "Test de aislamiento tenant B", "priority": priority, }) assert resp.status_code in (200, 201), f"Error creando ticket B: {resp.text}" return resp.json() return _create @pytest.fixture def create_user_tenant_a(client_tenant_a): """Factory: crea un usuario en tenant_a vía HTTP y retorna el JSON de respuesta.""" async def _create(email="nuevo_user_a@test.com"): resp = await client_tenant_a.post("/v1/users/", json={ "email": email, "first_name": "Usuario", "last_name": "TenantA", "password": "Test1234!", "role": "CLIENT_USER", }) assert resp.status_code in (200, 201), f"Error creando user A: {resp.text}" return resp.json() return _create @pytest.fixture def create_user_tenant_b(client_tenant_b): """Factory: crea un usuario en tenant_b vía HTTP y retorna el JSON de respuesta.""" async def _create(email="nuevo_user_b@test.com"): resp = await client_tenant_b.post("/v1/users/", json={ "email": email, "first_name": "Usuario", "last_name": "TenantB", "password": "Test1234!", "role": "CLIENT_USER", }) assert resp.status_code in (200, 201), f"Error creando user B: {resp.text}" return resp.json() return _create