# Testing con pytest-django y Factory Boy

## Configuración: pytest.ini y conftest.py base

```ini
# pytest.ini (o sección [tool.pytest.ini_options] en pyproject.toml)
[pytest]
DJANGO_SETTINGS_MODULE = config.settings.test
python_files = tests.py test_*.py *_test.py
python_classes = Test*
python_functions = test_*
addopts =
    --reuse-db          # reutiliza la BD entre sesiones para velocidad
    --strict-markers    # falla si se usa marker no declarado
    -p no:warnings      # silenciar deprecation warnings en CI
markers =
    slow: tests lentos que se omiten en desarrollo local
    integration: tests con servicios externos reales
```

```python
# config/settings/test.py
from .base import *  # noqa: F401, F403

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "NAME": "test_db",
        "USER": "postgres",
        "PASSWORD": "postgres",
        "HOST": "localhost",
        "PORT": "5432",
    }
}

# Acelerar hashing en tests — NUNCA en producción
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]

# Celery en modo síncrono para tests
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True

# Caché en memoria para tests
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
```

```python
# conftest.py (raíz del proyecto)
import pytest
from rest_framework.test import APIClient
from django.contrib.auth import get_user_model

from tests.factories import UsuarioFactory, EmpresaFactory

Usuario = get_user_model()


@pytest.fixture
def api_client() -> APIClient:
    """Cliente DRF sin autenticar."""
    return APIClient()


@pytest.fixture
def empresa(db):
    """Empresa de prueba creada en BD."""
    return EmpresaFactory()


@pytest.fixture
def usuario(db, empresa):
    """Usuario activo con empresa asignada."""
    return UsuarioFactory(empresa=empresa, is_active=True)


@pytest.fixture
def usuario_admin(db, empresa):
    """Usuario con rol administrador."""
    return UsuarioFactory(empresa=empresa, rol="admin", is_active=True)


@pytest.fixture
def cliente_autenticado(api_client: APIClient, usuario) -> APIClient:
    """Cliente DRF con usuario autenticado via JWT."""
    api_client.force_authenticate(user=usuario)
    return api_client


@pytest.fixture
def cliente_admin(api_client: APIClient, usuario_admin) -> APIClient:
    """Cliente DRF con usuario administrador autenticado."""
    api_client.force_authenticate(user=usuario_admin)
    return api_client
```

---

## Factory Boy: definición de factories

```python
# tests/factories.py
import factory
from factory.django import DjangoModelFactory
from factory import SubFactory, LazyAttribute, Faker, Sequence
from django.contrib.auth import get_user_model
from decimal import Decimal

from apps.empresas.models import Empresa
from apps.facturas.models import Factura, ItemFactura, Cliente

Usuario = get_user_model()


class EmpresaFactory(DjangoModelFactory):
    class Meta:
        model = Empresa

    nombre = Faker("company", locale="es_MX")
    rfc = Sequence(lambda n: f"EMP{n:09d}A1")
    activa = True


class UsuarioFactory(DjangoModelFactory):
    class Meta:
        model = Usuario

    email = Sequence(lambda n: f"usuario{n}@prueba.com")
    nombre = Faker("first_name", locale="es_MX")
    apellido = Faker("last_name", locale="es_MX")
    empresa = SubFactory(EmpresaFactory)
    rol = "operador"
    is_active = True

    # Contraseña manejada con set_password para que hashee correctamente
    @factory.post_generation
    def password(self, create, extracted, **kwargs):
        if not create:
            return
        self.set_password(extracted or "Contraseña!Segura123")
        self.save(update_fields=["password"])


class ClienteFactory(DjangoModelFactory):
    class Meta:
        model = Cliente

    nombre = Faker("company", locale="es_MX")
    rfc = Sequence(lambda n: f"CLI{n:09d}A1")
    empresa = SubFactory(EmpresaFactory)
    activo = True


class FacturaFactory(DjangoModelFactory):
    class Meta:
        model = Factura

    folio = Sequence(lambda n: f"FAC-{n:06d}")
    cliente = SubFactory(ClienteFactory)
    empresa = LazyAttribute(lambda obj: obj.cliente.empresa)
    estatus = "emitida"
    total = Faker(
        "pydecimal", min_value=100, max_value=100_000,
        right_digits=2, positive=True,
    )


class ItemFacturaFactory(DjangoModelFactory):
    class Meta:
        model = ItemFactura

    factura = SubFactory(FacturaFactory)
    descripcion = Faker("sentence", locale="es_MX", nb_words=4)
    cantidad = Faker("random_int", min=1, max=20)
    precio_unitario = Faker(
        "pydecimal", min_value=10, max_value=5_000, right_digits=2, positive=True
    )
```

---

## Tests de modelo: validaciones, métodos y señales

```python
# apps/facturas/tests/test_models.py
import pytest
from django.core.exceptions import ValidationError
from tests.factories import FacturaFactory, ItemFacturaFactory


@pytest.mark.django_db
class TestFacturaModel:
    def test_calcular_total_suma_items(self):
        factura = FacturaFactory()
        ItemFacturaFactory(factura=factura, cantidad=2, precio_unitario=100)
        ItemFacturaFactory(factura=factura, cantidad=1, precio_unitario=50)

        assert factura.calcular_total() == pytest.approx(250.00)

    def test_no_se_puede_cancelar_factura_ya_cancelada(self):
        factura = FacturaFactory(estatus="cancelada")

        with pytest.raises(ValueError, match="ya está cancelada"):
            factura.cancelar(motivo="prueba")

    def test_estatus_emitida_es_valido(self):
        factura = FacturaFactory.build(estatus="emitida")
        # .full_clean() ejecuta validadores del modelo sin guardar en BD
        factura.full_clean()  # no debe lanzar excepción

    def test_estatus_invalido_falla_validacion(self):
        factura = FacturaFactory.build(estatus="INVALIDO")
        with pytest.raises(ValidationError):
            factura.full_clean()


@pytest.mark.django_db
class TestSignalCambioEstatus:
    def test_signal_post_save_envia_notificacion(self, mocker):
        """La señal post_save debe llamar al servicio de notificación al cambiar estatus."""
        mock_notificar = mocker.patch(
            "apps.facturas.signals.notificacion_service.enviar"
        )
        factura = FacturaFactory(estatus="emitida")
        # Simular tracker manualmente si se usa django-model-utils
        factura.estatus = "pagada"
        factura.save()

        mock_notificar.assert_called_once()
        call_kwargs = mock_notificar.call_args.kwargs
        assert call_kwargs["tipo"] == "CAMBIO_ESTATUS_FACTURA"
```

---

## Tests de API: status codes y payloads

```python
# apps/facturas/tests/test_api.py
import pytest
from django.urls import reverse
from rest_framework import status
from tests.factories import FacturaFactory, ClienteFactory


@pytest.mark.django_db
class TestFacturaListAPI:
    def test_listar_requiere_autenticacion(self, api_client):
        url = reverse("factura-list")
        respuesta = api_client.get(url)
        assert respuesta.status_code == status.HTTP_401_UNAUTHORIZED

    def test_listar_solo_facturas_propias(self, cliente_autenticado, usuario):
        """El usuario solo debe ver facturas de su empresa."""
        # Facturas de la empresa del usuario
        factura_propia = FacturaFactory(empresa=usuario.empresa)
        # Factura de otra empresa — NO debe aparecer
        FacturaFactory()

        url = reverse("factura-list")
        respuesta = cliente_autenticado.get(url)

        assert respuesta.status_code == status.HTTP_200_OK
        ids_retornados = [r["id"] for r in respuesta.data["resultados"]]
        assert str(factura_propia.id) in ids_retornados
        assert len(ids_retornados) == 1

    def test_crear_factura_con_items(self, cliente_autenticado, usuario):
        cliente = ClienteFactory(empresa=usuario.empresa)
        payload = {
            "cliente_id": str(cliente.id),
            "items": [
                {"producto_id": None, "descripcion": "Servicio A", "cantidad": 2, "precio_unitario": "500.00"},
                {"producto_id": None, "descripcion": "Servicio B", "cantidad": 1, "precio_unitario": "300.00"},
            ],
        }
        url = reverse("factura-list")
        respuesta = cliente_autenticado.post(url, data=payload, format="json")

        assert respuesta.status_code == status.HTTP_201_CREATED
        assert len(respuesta.data["items"]) == 2


@pytest.mark.django_db
class TestFacturaCancelarAccion:
    def test_cancelar_factura_emitida(self, cliente_admin, usuario_admin):
        factura = FacturaFactory(empresa=usuario_admin.empresa, estatus="emitida")
        url = reverse("factura-cancelar", kwargs={"pk": factura.pk})

        respuesta = cliente_admin.post(url, data={"motivo": "Error de captura"}, format="json")

        assert respuesta.status_code == status.HTTP_200_OK
        factura.refresh_from_db()
        assert factura.estatus == "cancelada"

    def test_cancelar_factura_ya_cancelada_retorna_422(self, cliente_admin, usuario_admin):
        factura = FacturaFactory(empresa=usuario_admin.empresa, estatus="cancelada")
        url = reverse("factura-cancelar", kwargs={"pk": factura.pk})

        respuesta = cliente_admin.post(url, data={}, format="json")

        assert respuesta.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
```

---

## Tests de permisos

```python
# apps/facturas/tests/test_permisos.py
import pytest
from django.urls import reverse
from rest_framework import status
from tests.factories import FacturaFactory


@pytest.mark.django_db
class TestPermisosFactura:
    def test_operador_no_puede_cancelar(self, cliente_autenticado, usuario):
        """El rol 'operador' no tiene permiso para cancelar facturas."""
        assert usuario.rol == "operador"
        factura = FacturaFactory(empresa=usuario.empresa, estatus="emitida")
        url = reverse("factura-cancelar", kwargs={"pk": factura.pk})

        respuesta = cliente_autenticado.post(url, data={}, format="json")

        assert respuesta.status_code == status.HTTP_403_FORBIDDEN

    def test_no_puede_ver_factura_de_otra_empresa(self, cliente_autenticado):
        factura_ajena = FacturaFactory()  # empresa diferente
        url = reverse("factura-detail", kwargs={"pk": factura_ajena.pk})

        respuesta = cliente_autenticado.get(url)

        # 404 es preferible a 403 para no revelar que el recurso existe
        assert respuesta.status_code == status.HTTP_404_NOT_FOUND
```

---

## Mocking: servicios externos y tareas Celery

```python
# apps/facturas/tests/test_servicios.py
import pytest
from unittest.mock import patch, MagicMock


@pytest.mark.django_db
def test_crear_factura_envia_email(mocker):
    """El servicio de factura debe encolar email sin llamar SMTP real."""
    mock_tarea = mocker.patch("apps.facturas.services.enviar_email.delay")

    servicio = FacturaService()
    factura = servicio.crear(cliente_id=1, items=[...])

    mock_tarea.assert_called_once_with(
        destinatario=factura.cliente.email,
        folio=factura.folio,
    )


@pytest.mark.django_db
def test_integracion_pasarela_pago(mocker):
    """Mockear cliente HTTP de la pasarela para no hacer llamadas reales."""
    mock_respuesta = MagicMock()
    mock_respuesta.status_code = 200
    mock_respuesta.json.return_value = {"transaccion_id": "TXN-001", "aprobado": True}

    mocker.patch(
        "apps.pagos.clients.PasarelaPagoClient.procesar",
        return_value=mock_respuesta.json(),
    )

    resultado = PasarelaPagoService().pagar(factura_id=1, monto=500.00)
    assert resultado["aprobado"] is True


# Celery con CELERY_TASK_ALWAYS_EAGER=True — las tareas se ejecutan síncronamente
@pytest.mark.django_db
def test_tarea_celery_ejecuta_logica():
    from apps.reportes.tasks import generar_reporte_mensual

    # Con ALWAYS_EAGER, .delay() ejecuta la tarea inmediatamente en el mismo proceso
    resultado = generar_reporte_mensual.delay(empresa_id=1, mes=1, anio=2025)
    assert resultado.successful()
```

---

## Performance en tests: transaction.on_commit

```python
# El callback de on_commit NO se ejecuta en tests con @pytest.mark.django_db
# porque los tests hacen rollback en lugar de commit real.
# Solución: usar transaction=True o TestCase.captureOnCommitCallbacks

@pytest.mark.django_db(transaction=True)
def test_on_commit_ejecuta_tarea(mocker):
    """Necesita transaction=True para que on_commit dispare el callback."""
    mock_tarea = mocker.patch("apps.facturas.tasks.indexar_factura.delay")
    FacturaFactory()  # el signal usa on_commit para encolar la tarea

    mock_tarea.assert_called_once()


# Alternativa con Django 4.1+: captureOnCommitCallbacks sin transaction=True
@pytest.mark.django_db
def test_on_commit_sin_transaccion_real(mocker):
    mock_tarea = mocker.patch("apps.facturas.tasks.indexar_factura.delay")

    from django.test import TestCase
    # En pytest-django se puede usar con el fixture `django_capture_on_commit_callbacks`
    # disponible desde pytest-django 4.6+
    pass  # ver documentación de pytest-django para el fixture exacto
```
