Saltar a contenido

Documento histórico

Este documento se conserva solo para trazabilidad. Consulte el índice maestro para la fuente vigente.

Documento histórico — no usar como fuente de decisiones actuales

Reemplazado por la versión canónica del 2026-07-11. Conservar solo como trazabilidad histórica.


Plan de pruebas

Estrategia integral de testing para el sistema IslaBox, organizada por niveles (unitario, integración, sistema, UAT) y por tipos (funcional, no funcional, regresión, performance, seguridad). Documento vivo que se actualiza con cada sprint.


1. Estrategia general

1.1 Niveles de prueba

Nivel Propósito Responsable Herramientas
Unitario Validar funciones puras, cálculos, validaciones Desarrolladores pytest (Python), JUnit (Kotlin)
Integración Validar interacción entre componentes (DB, Redis, MinIO) Desarrolladores + QA pytest + testcontainers, Compose
Sistema (E2E) Validar flujos completos QA Cypress (web), Compose UI tests (Android)
UAT (User Acceptance) Validar con usuarios reales Stakeholders + QA Beta cerrada + feedback manual
Regresión Asegurar que nuevos cambios no rompen lo existente QA automatizado CI + suite completa

1.2 Pirámide de pruebas

        /\
       /  \         UAT (manual, ~5%)
      / E2E\        Sistema/E2E (~15%)
     /______\
    /        \      Integración (~30%)
   / Integrac. \
  /____________\
 /              \  Unitario (~50%)
/   Unitarios   \
/________________\

1.3 Tipos de prueba

Tipo Cuándo se ejecuta Cubre
Funcional Cada commit / PR RF-001 a RF-051
No funcional Sprint / pre-release RNF-001 a RNF-044
Regresión Pre-release + nightly Suite completa
Performance Pre-release RNF-001 a RNF-004
Seguridad Pre-release + post-deploy RNF-005 a RNF-012, riesgos.md
Carga/estrés Pre-release Concurrencia, locks
Compatibilidad Pre-release Navegadores, dispositivos
Accesibilidad Pre-release WCAG AA
Usabilidad Beta Flujos intuitivos

2. Cobertura objetivo

Capa Cobertura mínima
core/services/ (lógica de negocio) ≥ 80%
core/repositories/ (acceso a datos) ≥ 70%
core/validators/ ≥ 90%
core/models/ (métodos, properties) ≥ 60%
API endpoints (FastAPI) ≥ 70%
App Android (UI + ViewModel) ≥ 50%
Utilidades y helpers ≥ 80%

Métrica global objetivo: ≥ 70% en código nuevo de construcción.


3. Pruebas unitarias

3.1 Backend (Python / pytest)

3.1.1 LockerCode

# test_locker_code.py
import pytest
from core.models.locker_code import LockerCode, LockerCodeStatus
from core.services.locker_code_service import (
    calculate_check_digit,
    normalize,
    validate,
    format,
    generate,
)


class TestCalculateCheckDigit:
    def test_ejemplo_oficial_001042(self):
        """El ejemplo del documento debe dar 8."""
        assert calculate_check_digit(1042) == 8  # padded a 001042

    def test_secuencial_0(self):
        assert calculate_check_digit(0) == 0

    def test_secuencial_1(self):
        assert calculate_check_digit(1) == 2

    def test_secuencial_9(self):
        assert calculate_check_digit(9) == 8

    def test_secuencial_999999(self):
        assert calculate_check_digit(999999) == 6

    @pytest.mark.parametrize("sequential,expected", [
        (0, 0),
        (1, 2),
        (10, 7),
        (100, 4),
        (1000, 9),
        (10000, 0),
        (100000, 0),
    ])
    def test_varios_secuenciales(self, sequential, expected):
        assert calculate_check_digit(sequential) == expected


class TestNormalize:
    def test_con_guiones(self):
        assert normalize("CP-001042-8") == "CP0010428"

    def test_sin_guiones(self):
        assert normalize("CP0010428") == "CP0010428"

    def test_minusculas(self):
        assert normalize("cp0010428") == "CP0010428"

    def test_con_espacios(self):
        assert normalize("CP 001042 8") == "CP0010428"

    def test_con_mezcla(self):
        assert normalize("cp-001042 8") == "CP0010428"

    def test_caracteres_invalidos(self):
        with pytest.raises(InvalidFormatError):
            normalize("CP-001042-8!@#")


class TestValidate:
    def test_valido(self):
        result = validate("CP-001042-8")
        assert result.is_valid

    def test_invalido_digito(self):
        result = validate("CP-001042-7")  # ejemplo app, incorrecto
        assert not result.is_valid
        assert result.error_code == "INVALID_CHECK_DIGIT"

    def test_formato_invalido(self):
        result = validate("XX-001042-8")
        assert not result.is_valid
        assert result.error_code == "INVALID_PREFIX"

    def test_vacio(self):
        result = validate("")
        assert not result.is_valid
        assert result.error_code == "EMPTY_CODE"


class TestGenerate:
    def test_genera_codigo_unico(self, db):
        code1 = generate(db, prefix="CP")
        code2 = generate(db, prefix="CP")
        assert code1.full_code != code2.full_code
        assert code1.check_digit == calculate_check_digit(code1.sequential)

    def test_prefijo_custom(self, db):
        code = generate(db, prefix="US")
        assert code.full_code.startswith("US")

3.1.2 Quote / Cotización

# test_quote.py
import pytest
from decimal import Decimal
from core.services.quote_service import calculate_quote


class TestCalculateQuote:
    def test_envio_barco_sin_servicios(self):
        quote = calculate_quote(
            weight_lb=Decimal("10.0"),
            shipping_type="barco",
            additional_services=[],
        )
        # 10 lb × $2.40 = $24.00
        assert quote.total_amount == Decimal("24.00")

    def test_envio_avion(self):
        quote = calculate_quote(
            weight_lb=Decimal("10.0"),
            shipping_type="avion",
            additional_services=[],
        )
        # 10 lb × $3.35 = $33.50
        assert quote.total_amount == Decimal("33.50")

    def test_con_inspeccion(self):
        quote = calculate_quote(
            weight_lb=Decimal("10.0"),
            shipping_type="barco",
            additional_services=[{"type": "inspeccion", "cost": Decimal("5.00")}],
        )
        # 24 + 5 = 29
        assert quote.total_amount == Decimal("29.00")

    def test_con_multiples_servicios(self):
        quote = calculate_quote(
            weight_lb=Decimal("20.0"),
            shipping_type="avion",
            additional_services=[
                {"type": "inspeccion", "cost": Decimal("5.00")},
                {"type": "reempaque", "cost": Decimal("10.00")},
                {"type": "prueba", "cost": Decimal("8.00")},
            ],
        )
        # 20 × 3.35 + 5 + 10 + 8 = 67 + 23 = 90
        assert quote.total_amount == Decimal("90.00")

    def test_peso_cero(self):
        with pytest.raises(InvalidWeightError):
            calculate_quote(weight_lb=Decimal("0"), shipping_type="barco", additional_services=[])

    def test_via_invalida(self):
        with pytest.raises(InvalidShippingTypeError):
            calculate_quote(weight_lb=Decimal("10"), shipping_type="dron", additional_services=[])

3.1.3 Storage / Almacenaje

# test_storage.py
from datetime import datetime, timedelta
from core.services.storage_service import (
    is_in_free_period,
    days_to_expiration,
    apply_charge,
)


class TestStorage:
    """
    Valores resueltos (ver [preguntas.md](../../../roadmap/preguntas.md)):
    - Días gratis: 15 días naturales
    - Costo por día extra: $0.50 USD
    """

    def test_dentro_de_periodo_gratis(self):
        # Día 14 desde la recepción: aún gratis
        arrival = datetime(2026, 1, 1)
        now = datetime(2026, 1, 15)  # 14 días
        assert is_in_free_period(arrival, now, free_days=15)

    def test_en_el_limite_gratis(self):
        # Día 15: último día gratis
        arrival = datetime(2026, 1, 1)
        now = datetime(2026, 1, 16)  # 15 días
        assert is_in_free_period(arrival, now, free_days=15)

    def test_fuera_de_periodo_gratis(self):
        # Día 16: ya no es gratis
        arrival = datetime(2026, 1, 1)
        now = datetime(2026, 1, 17)  # 16 días
        assert not is_in_free_period(arrival, now, free_days=15)

    def test_dias_para_vencer(self):
        arrival = datetime(2026, 1, 1)
        now = datetime(2026, 1, 8)  # 7 días
        # Faltan 8 días para llegar a 15 (gratis)
        assert days_to_expiration(arrival, now, free_days=15) == 8

    def test_aplicar_cargo_1_dia(self):
        # 1 día extra a $0.50/día
        charge = apply_charge(extra_days=1, rate_per_day=Decimal("0.50"))
        assert charge.amount == Decimal("0.50")

    def test_aplicar_cargo_5_dias(self):
        charge = apply_charge(extra_days=5, rate_per_day=Decimal("0.50"))
        assert charge.amount == Decimal("2.50")

    def test_aplicar_cargo_10_dias(self):
        charge = apply_charge(extra_days=10, rate_per_day=Decimal("0.50"))
        assert charge.amount == Decimal("5.00")

    def test_sin_cargo_si_dentro_de_periodo(self):
        # Aunque se llame con extra_days, el cargo no debe aplicarse si está en período gratis
        charge = apply_charge(extra_days=0, rate_per_day=Decimal("0.50"))
        assert charge.amount == Decimal("0.00")

3.2 App Android (Kotlin / JUnit + Compose UI Test)

// test_register_screen.kt
class RegisterScreenTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun `boton registrarse deshabilitado si campos vacios`() {
        composeTestRule.setContent {
            IslaBoxTheme {
                RegisterScreen(onRegisterClick = {}, onLoginClick = {})
            }
        }
        composeTestRule.onNodeWithText("Registrarse").assertIsNotEnabled()
    }

    @Test
    fun `boton habilitado cuando todo es valido`() {
        composeTestRule.setContent {
            IslaBoxTheme {
                RegisterScreen(onRegisterClick = {}, onLoginClick = {})
            }
        }
        composeTestRule.onNodeWithText("Nombre").performTextInput("Juan")
        composeTestRule.onNodeWithText("Apellidos").performTextInput("Pérez")
        composeTestRule.onNodeWithText("Email").performTextInput("juan@test.com")
        composeTestRule.onNodeWithText("Teléfono").performTextInput("3055551234")
        composeTestRule.onNodeWithText("Contraseña").performTextInput("Pass1234")
        composeTestRule.onNodeWithText("Repetir contraseña").performTextInput("Pass1234")
        composeTestRule.onNodeWithText("Acepto los términos").performClick()
        composeTestRule.onNodeWithText("Registrarse").assertIsEnabled()
    }

    @Test
    fun `muestra error si contraseñas no coinciden`() {
        composeTestRule.setContent {
            IslaBoxTheme {
                RegisterScreen(onRegisterClick = {}, onLoginClick = {})
            }
        }
        composeTestRule.onNodeWithText("Contraseña").performTextInput("Pass1234")
        composeTestRule.onNodeWithText("Repetir contraseña").performTextInput("Otra1234")
        composeTestRule.onNodeWithText("Las contraseñas no coinciden").assertIsDisplayed()
    }
}

4. Pruebas de integración

4.1 Escenarios críticos

# test_integration_purchase_report.py
import pytest
from rest_framework.test import APIClient
from django.contrib.auth import get_user_model


@pytest.mark.django_db
class TestPurchaseReportCreation:
    def test_crear_prealerta_completa(self, api_client, user_with_kyc):
        client = api_client(user_with_kyc)
        data = {
            "marketplace": "Amazon",
            "order_number": "111-2222222-3333333",
            "purchase_date": "2026-05-15",
            "tracking_number": "TBA123456",
            "category_code": "ropa",
            "items": [
                {
                    "description": "Camiseta",
                    "quantity": 2,
                    "value_paid": 25.00,
                }
            ],
            "recipient_id": user_with_kyc.recipients.first().id,
        }
        response = client.post("/api/v1/purchase-reports/", data, format="json")
        assert response.status_code == 201
        assert response.data["purchase_status"] == "tracking_actualizado"

    def test_sin_kyc_puede_crear_prealerta(self, api_client, user_without_kyc):
        # KYC no bloquea prealerta
        client = api_client(user_without_kyc)
        data = {
            "marketplace": "SHEIN",
            "order_number": "SH-12345",
            "purchase_date": "2026-05-15",
            "category_code": "ropa",
            "items": [{"description": "Vestido", "quantity": 1, "value_paid": 30.00}],
            "recipient_id": user_without_kyc.recipients.first().id,
        }
        response = client.post("/api/v1/purchase-reports/", data, format="json")
        assert response.status_code == 201

    def test_categoria_prohibida_bloquea(self, api_client, user_with_kyc):
        client = api_client(user_with_kyc)
        data = {
            "marketplace": "Otro",
            "order_number": "X-1",
            "purchase_date": "2026-05-15",
            "category_code": "restringido",  # prohibida
            "items": [{"description": "X", "quantity": 1, "value_paid": 100.00}],
            "recipient_id": user_with_kyc.recipients.first().id,
        }
        response = client.post("/api/v1/purchase-reports/", data, format="json")
        assert response.status_code == 422
        assert "restringida" in str(response.data).lower()

4.2 Concurrencia y locks

# test_concurrency.py
import threading
import pytest


@pytest.mark.django_db(transaction=True)
class TestLockerCodeConcurrency:
    def test_generacion_sin_duplicados_bajo_carga(self):
        from core.services.locker_code_service import generate

        codigos = []
        lock = threading.Lock()

        def worker():
            code = generate()
            with lock:
                codigos.append(code.full_code)

        threads = [threading.Thread(target=worker) for _ in range(100)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert len(codigos) == 100
        assert len(set(codigos)) == 100  # todos únicos


@pytest.mark.django_db(transaction=True)
class TestConsolidationConcurrency:
    def test_consolidacion_doble_no_se_duplica(self, user_with_packages):
        # Intentar consolidar los mismos paquetes 2 veces en paralelo
        results = []
        def worker():
            try:
                consolidate(user_id, package_ids)
                results.append("ok")
            except InvalidStateError:
                results.append("blocked")

        threads = [threading.Thread(target=worker) for _ in range(2)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert results.count("ok") == 1
        assert results.count("blocked") == 1

5. Pruebas de sistema (E2E)

5.1 Cypress (backoffice)

// cypress/e2e/backoffice/03-recibir-paquete.cy.js
describe('Recibir paquete', () => {
  beforeEach(() => {
    cy.login('operador@islabox.com', 'Pass1234')
  })

  it('recibe paquete con identificador válido', () => {
    cy.visit('/admin/recepcion/')
    cy.get('[data-cy=identificador-input]').type('CP-001042-8')
    cy.get('[data-cy=identificador-input]').blur()
    cy.get('[data-cy=usuario-info]').should('be.visible')
    cy.get('[data-cy=foto-input]').attachFile('paquete.jpg')
    cy.get('[data-cy=tracking-input]').type('TBA123456')
    cy.get('[data-cy=peso-input]').type('5.2')
    cy.get('[data-cy=estado-embalaje-select]').select('Bien')
    cy.get('[data-cy=confirmar-btn]').click()
    cy.contains('Paquete registrado').should('be.visible')
  })

  it('rechaza identificador con dígito incorrecto', () => {
    cy.visit('/admin/recepcion/')
    cy.get('[data-cy=identificador-input]').type('CP-001042-7')
    cy.get('[data-cy=identificador-input]').blur()
    cy.contains('Dígito verificador no coincide').should('be.visible')
  })
})

5.2 Flujos E2E críticos (lista de verificación)

# Flujo Resultado esperado
E2E-001 Registro → OTP → Dashboard Usuario entra al dashboard con identificador visible
E2E-002 Login → Dashboard Sesión iniciada correctamente
E2E-003 Reportar compra → Detalle Reporte visible en lista con estado correcto
E2E-004 Operador recibe → Push al usuario Push llega con datos correctos
E2E-005 Consolidar 2 paquetes → Cotización Cotización generada con cálculo correcto
E2E-006 Pagar → Estado pagado Pago registrado, invoice generada
E2E-007 Operador despacha → Push con guía Push con master_guide
E2E-008 KYC completo → Aprobado KYC vigente por 10 años
E2E-009 Cambio de estado en tránsito → Push Push al usuario en cada paso
E2E-010 Almacenaje vence → Cargo aplicado Cargo visible y push al usuario

6. Pruebas no funcionales

6.1 Performance (RNF-001)

# test_performance.py
import time


def test_api_response_time_under_500ms():
    """p95 de GET /packages debe ser < 500ms"""
    times = []
    for _ in range(100):
        start = time.perf_counter()
        response = client.get("/api/v1/packages/")
        times.append((time.perf_counter() - start) * 1000)

    p95 = sorted(times)[94]
    assert p95 < 500, f"p95 = {p95}ms, esperado < 500ms"

6.2 Seguridad

Test Criterio
Login sin HTTPS Rechazado
JWT expirado 401 + redirige a login
Acceso a archivo KYC sin URL firmada 403
URL firmada de KYC expirada (> 5 min) 403
Rate limit login (5/min) 6° intento en 1 min → 429
Rate limit OTP (3/min) 4° intento → 429
Inyección SQL en campos Sanitización
XSS en campos de texto Sanitización
Acceso a paquete de otro usuario 403
Modificación de identificador Rechazado (no editable)

6.3 Compatibilidad

Dispositivo Versión Resultado
Samsung Galaxy A13 Android 13
Xiaomi Redmi Note 10 Android 11
Google Pixel 7 Android 14
Motorola Edge Android 12
Chrome desktop Última
Firefox desktop Última
Safari desktop Última
iPhone (web) iOS 15+ ✅ (PWA en MVP)

6.4 Accesibilidad

  • Contraste WCAG AA verificado en paleta
  • Touch targets ≥ 48dp
  • Screen reader compatible (TalkBack / VoiceOver)
  • Navegación por teclado en backoffice
  • Textos con contentDescription en imágenes

7. Pruebas de UAT (User Acceptance Testing)

7.1 Plan de beta

Fase Usuarios Duración Objetivo
Alfa interna 5-10 (equipo + familia) 2 semanas Encontrar bugs críticos
Beta cerrada 20-50 (amigos, colegas) 4 semanas Validar UX y flujos
Beta abierta 100-200 (interesados en lista de espera) 2 semanas Validar carga + diversidad
Lanzamiento público indefinido Operación normal

7.2 Feedback channels

  • Email dedicado: beta@islabox.com
  • Formulario in-app (en perfil → "Enviar feedback")
  • Canal de Slack con los beta testers
  • Llamadas quincenales de 30 min con un grupo pequeño

7.3 Criterios de salida de beta

  • < 5 bugs críticos abiertos
  • NPS > 40
  • Tiempo medio de uso > 3 min/sesión
  • < 10% de crash rate
  • < 20% de usuarios con dificultades para completar el flujo principal

8. Pruebas de regresión

8.1 Suite nocturna

Ejecuta todas las pruebas unitarias + integración + E2E de los flujos críticos:

# .github/workflows/nightly.yml
name: Nightly regression
on:
  schedule:
    - cron: '0 3 * * *'  # 3am UTC
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
      - name: Run unit + integration tests
        run: pytest --cov=core --cov-fail-under=70
      - name: Run E2E tests
        run: cypress run --headless

8.2 Pre-release checklist

Antes de cada release, validar:

  • Suite completa pasa al 100%
  • Cobertura ≥ 70% en código nuevo
  • Sin bugs críticos ni altos abiertos
  • Performance: p95 < 500ms
  • Seguridad: 0 vulnerabilidades críticas/altas
  • Accesibilidad: WCAG AA
  • Compatibilidad: dispositivos validados
  • Documentación actualizada
  • CHANGELOG actualizado

9. Herramientas

Tipo Herramienta
Tests Python pytest, pytest-django, pytest-cov, factory_boy
Tests Android JUnit5, Compose UI Test, MockK
Tests E2E Cypress (web), Appium (móvil)
Cobertura coverage.py (Python), Kover (Kotlin)
Mocking responses, fakeredis, moto (AWS)
Load testing Locust, k6
Security bandit, safety, OWASP ZAP
Linting ruff, mypy, ktlint, detekt
Formateo black, prettier

10. Métricas de calidad

Métrica Objetivo Cómo medir
Cobertura de tests ≥ 70% coverage.py + Kover
Tests pasando 100% CI
Bugs por KLOC < 5 Sentry + Linear
Tiempo de detección de bug < 24h Logs + alertas
Tiempo de resolución crítico < 24h Sentry severity
Tiempo de resolución alto < 72h Sentry severity
Crash rate móvil < 1% Crashlytics
p95 API < 500ms APM (DataDog/New Relic)
Disponibilidad ≥ 99% Monitoring

11. Trazabilidad RF → Tests

RF Tests asociados
RF-001 Registro PT-001 a PT-008, E2E-001
RF-002 OTP PT-005 a PT-007, CU-001
RF-003 Verificación correo PT-001, E2E-001
RF-004 Login PT-010 a PT-013, E2E-002
RF-005 Recuperación PT-014 a PT-018
RF-007 Identificador UT-LockerCode (todos), PT-007, PT-008
RF-013 a RF-017 Prealerta PT-050 a PT-063, E2E-003
RF-018 a RF-020 Recepción PT-120 a PT-130, E2E-004, cypress/recepcion
RF-024 a RF-026 Consolidación PT-090 a PT-099, E2E-005, UT-Quote
RF-026 Pago PT-100 a PT-110, E2E-006, seq-pago
RF-027 Despacho PT-104 a PT-110, E2E-007, seq-pago
RF-030 a RF-032 KYC PT-140 a PT-155, E2E-008
RNF-001 Performance test_performance.py
RNF-005 a RNF-012 Seguridad Tests de seguridad manuales y automatizados
RNF-013 a RNF-017 Usabilidad UAT + accesibilidad

12. Plan de ejecución por sprint

Sprint Tests a crear / actualizar Salida
S0 Setup CI + coverage + 1 test de ejemplo Pipeline verde
S1 PT-001 a PT-018 (auth) + E2E-001, E2E-002 Auth validado
S2 UT-LockerCode (todos) + PT-007 a PT-008 Identificador validado
S3 PT-040 a PT-048 (dashboard) Dashboard validado
S4 PT-050 a PT-063 (prealerta) + E2E-003 Prealerta validada
S5 PT-120 a PT-130 (recepción) + E2E-004 + cypress Recepción validada
S6 PT-070 a PT-079 (paquete) + PT-130 a PT-135 (conciliación) Consulta validada
S7 PT-080 a PT-085 (almacenaje) + UT-Storage Almacenaje validado
S8 UT-Quote + PT-090 a PT-099 + E2E-005 Consolidación validada
S9 PT-100 a PT-110 + E2E-006, E2E-007 + seq-pago Pago validado
S10 PT-140 a PT-155 + E2E-008 KYC validado
S11 PT-160 a PT-170 (servicios) Servicios validados
S12 Regresión completa + performance + seguridad Pre-beta listo
S13 UAT + feedback + ajustes Beta lista
S14 Regresión final + smoke en producción Lanzamiento

12.1 Pruebas específicas de la integración con Stripe

Test Criterio Tipo
ST-001 Crear Checkout Session con monto y metadata correctos Unit
ST-002 Crear Checkout Session falla con monto ≤ 0 Unit
ST-003 Webhook sin firma Stripe-Signature → 400 Integración
ST-004 Webhook con firma inválida → 400 Integración
ST-005 Webhook con firma válida y event.id nuevo → procesa Integración
ST-006 Webhook con event.id ya procesado (idempotencia) → 200 sin reprocesar Integración
ST-007 Webhook checkout.session.completed → actualiza Payment + Shipment Integración
ST-008 Webhook payment_intent.payment_failed → marca Payment fallido Integración
ST-009 El handler responde en < 5 s (latencia) Performance
ST-010 El modo test no se conecta a Stripe live (verificar URL base) Unit
ST-011 Las claves de Stripe no aparecen en logs ni en respuestas HTTP Seguridad
ST-012 Flujo E2E: usuario paga con tarjeta de prueba 4242 4242 4242 4242 y ve pagado E2E
ST-013 Flujo E2E: usuario cierra el Checkout y ve pendiente_pago E2E
ST-014 Stripe CLI replay: reenvío de un evento no duplica el pago E2E
# test_stripe_webhook.py
import pytest
from unittest.mock import patch
import stripe


@pytest.mark.django_db
class TestStripeWebhook:

    def test_rechaza_sin_firma(self, api_client):
        response = api_client.post(
            "/api/v1/webhooks/stripe/",
            data={"type": "checkout.session.completed"},
            content_type="application/json",
        )
        assert response.status_code == 400

    def test_rechaza_firma_invalida(self, api_client):
        response = api_client.post(
            "/api/v1/webhooks/stripe/",
            data={"type": "checkout.session.completed"},
            content_type="application/json",
            HTTP_STRIPE_SIGNATURE="t=123,v1=invalido",
        )
        assert response.status_code == 400

    def test_procesa_evento_valido(self, api_client, shipment_pending):
        with patch("stripe.Webhook.construct_event") as mock_construct:
            mock_construct.return_value = {
                "id": "evt_test_123",
                "type": "checkout.session.completed",
                "data": {"object": {
                    "id": "cs_test_abc",
                    "payment_intent": "pi_test_xyz",
                    "metadata": {"shipment_id": str(shipment_pending.id)},
                }},
            }
            response = api_client.post(
                "/api/v1/webhooks/stripe/",
                data="{}",
                content_type="application/json",
                HTTP_STRIPE_SIGNATURE="t=123,v1=valido",
            )
            assert response.status_code == 200
            shipment_pending.refresh_from_db()
            assert shipment_pending.status == "pagado"

    def test_idempotencia(self, api_client, shipment_pending):
        """Mismo event.id dos veces no debe duplicar efectos."""
        with patch("stripe.Webhook.construct_event") as mock_construct:
            event = {
                "id": "evt_test_dup",
                "type": "checkout.session.completed",
                "data": {"object": {
                    "id": "cs_test_abc",
                    "payment_intent": "pi_test_xyz",
                    "metadata": {"shipment_id": str(shipment_pending.id)},
                }},
            }
            mock_construct.return_value = event
            for _ in range(2):
                response = api_client.post(
                    "/api/v1/webhooks/stripe/",
                    data="{}",
                    content_type="application/json",
                    HTTP_STRIPE_SIGNATURE="t=123,v1=valido",
                )
                assert response.status_code == 200
            # Solo un Payment creado
            assert Payment.objects.filter(
                shipment=shipment_pending,
                provider_reference="pi_test_xyz",
            ).count() == 1

13. Riesgos de testing

Riesgo Mitigación
Falta de datos realistas Generar fixtures con factory_boy + Faker
Cobertura insuficiente en servicios críticos Forzar ≥ 80% en core/services/
Pruebas flaky (timing, orden) Usar lock de BD en tests, esperar eventos async
Dispositivos no disponibles Usar emuladores + 1 device farm (BrowserStack/Firebase Test Lab)
Beta testers no dan feedback Incentivos (crédito, sorteos); recordatorios semanales
Seguridad no probada en profundidad Auditoría externa pre-lanzamiento

14. Referencias