Files
service_manager/backend/app/tests/conftest.py
2026-03-03 14:02:18 -07:00

374 lines
12 KiB
Python

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