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
# 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
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
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:
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