Generador de Suite de Tests para APIs
Escanea definiciones de rutas en Next.js, Express, FastAPI y Django REST para generar suites de tests completas que cubren autenticación, validación de inputs, códigos de error, paginación y subida de ficheros. Produce archivos listos para ejecutar con Vitest+Supertest o Pytest+httpx.
Incluida en el Pase · para Next.js, Express, FastAPI
=============================================================================
AgendaIA SaaS — Suite de Tests API completa
Framework: Pytest + httpx (FastAPI)
Generado por: generador-suite-tests-api (CULTIVA IA)
=============================================================================
Cobertura:
✓ Auth (401/403/expired/malformed/missing)
✓ Validación de inputs (422/400/boundary values/injection)
✓ Códigos de error por ruta (400/401/403/404/409/422/500)
✓ Paginación (primera/última/vacía/oversized)
✓ Upload de ficheros (válido/sobredimensionado/MIME incorrecto/vacío/spoofing)
✓ Rate limiting (burst/retry-after)
✓ Reglas de negocio (cancelación 24h, enum duración, precio)
=============================================================================
import pytest import httpx from datetime import datetime, timedelta, timezone from decimal import Decimal import jwt import io
BASE_URL = "http://localhost:8000" JWT_SECRET = "test-secret-agenda-ia" # usar config de test, NUNCA el secreto de producción JWT_ALGORITHM = "HS256"
=============================================================================
HELPERS: generación de tokens
=============================================================================
def make_token( user_id: str, role: str = "patient", clinic_id: str = "clinic-001", expired: bool = False, malformed: bool = False, ) -> str: if malformed: return "esto.no.es.un.jwt.valido" exp = datetime.now(timezone.utc) + ( timedelta(hours=-2) if expired else timedelta(hours=2) ) return jwt.encode( {"sub": user_id, "role": role, "clinic_id": clinic_id, "exp": exp}, JWT_SECRET, algorithm=JWT_ALGORITHM, )
=============================================================================
FIXTURES
=============================================================================
@pytest.fixture(scope="session") def client(): with httpx.Client(base_url=BASE_URL, timeout=10.0) as c: yield c
@pytest.fixture(scope="session") def patient_token(): return make_token("patient-uuid-001", role="patient")
@pytest.fixture(scope="session") def staff_token(): return make_token("staff-uuid-001", role="clinic_staff")
@pytest.fixture(scope="session") def admin_token(): return make_token("admin-uuid-001", role="admin")
@pytest.fixture(scope="session") def expired_token(): return make_token("patient-uuid-001", expired=True)
@pytest.fixture(scope="session") def malformed_token(): return make_token("", malformed=True)
@pytest.fixture(scope="session") def other_patient_token(): """Token de un paciente DIFERENTE — para tests de ownership.""" return make_token("patient-uuid-999", role="patient")
@pytest.fixture(scope="session") def existing_appointment_id(): """ID de cita existente creada en el setup de la sesión de tests.""" return "appt-uuid-existing-001"
@pytest.fixture(scope="session") def future_appointment_id(): """ID de cita con start_time > now + 24h (cancelable).""" return "appt-uuid-future-001"
@pytest.fixture(scope="session") def imminent_appointment_id(): """ID de cita con start_time < now + 24h (NO cancelable).""" return "appt-uuid-imminent-001"
=============================================================================
POST /api/v1/auth/login
=============================================================================
class TestAuthLogin: URL = "/api/v1/auth/login"
def test_returns_200_with_valid_credentials(self, client):
res = client.post(self.URL, json={"email": "doctor@agendaia.test", "password": "SecurePass123!"})
assert res.status_code == 200
data = res.json()
assert "access_token" in data
assert "refresh_token" in data
assert data.get("token_type") == "bearer"
def test_returns_401_with_wrong_password(self, client):
res = client.post(self.URL, json={"email": "doctor@agendaia.test", "password": "wrongpassword"})
assert res.status_code == 401
assert "detail" in res.json()
def test_returns_401_with_nonexistent_email(self, client):
res = client.post(self.URL, json={"email": "noexiste@nope.com", "password": "cualquiera"})
assert res.status_code == 401
def test_returns_422_with_missing_email(self, client):
res = client.post(self.URL, json={"password": "SecurePass123!"})
assert res.status_code == 422
fields = [e["loc"][-1] for e in res.json()["detail"]]
assert "email" in fields
def test_returns_422_with_invalid_email_format(self, client):
res = client.post(self.URL, json={"email": "no-es-un-email", "password": "pass"})
assert res.status_code == 422
def test_returns_422_with_empty_body(self, client):
res = client.post(self.URL, json={})
assert res.status_code == 422
def test_does_not_expose_password_in_response(self, client):
res = client.post(self.URL, json={"email": "doctor@agendaia.test", "password": "SecurePass123!"})
data = res.json()
assert "password" not in data
assert "hashed_password" not in data
=============================================================================
POST /api/v1/auth/refresh
=============================================================================
class TestAuthRefresh: URL = "/api/v1/auth/refresh"
def test_returns_200_with_valid_refresh_token(self, client):
# Primero hacemos login para obtener un refresh token real
login = client.post("/api/v1/auth/login", json={"email": "doctor@agendaia.test", "password": "SecurePass123!"})
refresh_token = login.json().get("refresh_token")
res = client.post(self.URL, json={"refresh_token": refresh_token})
assert res.status_code == 200
assert "access_token" in res.json()
def test_returns_401_with_expired_refresh_token(self, client):
res = client.post(self.URL, json={"refresh_token": make_token("user", expired=True)})
assert res.status_code == 401
def test_returns_422_with_missing_refresh_token(self, client):
res = client.post(self.URL, json={})
assert res.status_code == 422
=============================================================================
GET /api/v1/slots (público)
=============================================================================
class TestGetSlots: URL = "/api/v1/slots"
def test_returns_200_without_auth(self, client):
res = client.get(self.URL, params={"clinic_id": "clinic-001", "date": "2026-07-01"})
assert res.status_code == 200
data = res.json()
assert "slots" in data
def test_returns_422_without_required_params(self, client):
res = client.get(self.URL)
assert res.status_code == 422
def test_returns_422_with_invalid_date_format(self, client):
res = client.get(self.URL, params={"clinic_id": "clinic-001", "date": "01-07-2026"})
assert res.status_code in (400, 422)
def test_returns_404_for_nonexistent_clinic(self, client):
res = client.get(self.URL, params={"clinic_id": "clinic-no-existe", "date": "2026-07-01"})
assert res.status_code == 404
=============================================================================
GET /api/v1/appointments — lista paginada
=============================================================================
class TestListAppointments: URL = "/api/v1/appointments"
# --- Auth ---
def test_returns_401_without_auth(self, client):
res = client.get(self.URL)
assert res.status_code == 401
def test_returns_401_with_malformed_token(self, client, malformed_token):
res = client.get(self.URL, headers={"Authorization": f"Bearer {malformed_token}"})
assert res.status_code == 401
def test_returns_401_with_expired_token(self, client, expired_token):
res = client.get(self.URL, headers={"Authorization": f"Bearer {expired_token}"})
assert res.status_code == 401
assert "expired" in res.json().get("detail", "").lower()
def test_returns_200_for_patient_own_appointments(self, client, patient_token):
res = client.get(self.URL, headers={"Authorization": f"Bearer {patient_token}"})
assert res.status_code == 200
data = res.json()
assert "items" in data
assert "total" in data
assert "page" in data
def test_returns_200_for_admin(self, client, admin_token):
res = client.get(self.URL, headers={"Authorization": f"Bearer {admin_token}"})
assert res.status_code == 200
# --- Paginación ---
def test_first_page_returns_correct_structure(self, client, patient_token):
res = client.get(
self.URL,
params={"page": 1, "size": 5},
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 200
data = res.json()
assert data["page"] == 1
assert len(data["items"]) <= 5
def test_out_of_range_page_returns_empty_list(self, client, patient_token):
res = client.get(
self.URL,
params={"page": 99999},
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 200
assert res.json()["items"] == []
def test_returns_422_for_page_zero(self, client, patient_token):
res = client.get(
self.URL,
params={"page": 0},
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 422
def test_returns_422_for_negative_page(self, client, patient_token):
res = client.get(
self.URL,
params={"page": -1},
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 422
def test_caps_page_size_at_100(self, client, admin_token):
res = client.get(
self.URL,
params={"size": 9999},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert res.status_code == 200
assert len(res.json()["items"]) <= 100
=============================================================================
POST /api/v1/appointments — crear cita
=============================================================================
VALID_APPOINTMENT_PAYLOAD = { "clinic_id": "clinic-001", "slot_id": "slot-uuid-001", "duration_minutes": 30, "price": 75.00, "notes": "Primera consulta general", }
class TestCreateAppointment: URL = "/api/v1/appointments"
# --- Auth ---
def test_returns_401_without_auth(self, client):
res = client.post(self.URL, json=VALID_APPOINTMENT_PAYLOAD)
assert res.status_code == 401
def test_returns_401_with_expired_token(self, client, expired_token):
res = client.post(
self.URL,
json=VALID_APPOINTMENT_PAYLOAD,
headers={"Authorization": f"Bearer {expired_token}"},
)
assert res.status_code == 401
# --- Happy path ---
def test_creates_appointment_successfully(self, client, patient_token):
res = client.post(
self.URL,
json=VALID_APPOINTMENT_PAYLOAD,
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 201
data = res.json()
assert "id" in data
assert data["duration_minutes"] == 30
assert "password" not in data
# --- Validación de cuerpo ---
def test_returns_422_with_empty_body(self, client, patient_token):
res = client.post(
self.URL,
json={},
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 422
assert len(res.json()["detail"]) > 0
def test_returns_422_missing_clinic_id(self, client, patient_token):
payload = {k: v for k, v in VALID_APPOINTMENT_PAYLOAD.items() if k != "clinic_id"}
res = client.post(
self.URL,
json=payload,
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 422
fields = [e["loc"][-1] for e in res.json()["detail"]]
assert "clinic_id" in fields
def test_returns_422_missing_slot_id(self, client, patient_token):
payload = {k: v for k, v in VALID_APPOINTMENT_PAYLOAD.items() if k != "slot_id"}
res = client.post(
self.URL,
json=payload,
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 422
# --- Enum duración ---
@pytest.mark.parametrize("duration", [15, 30, 45, 60])
def test_accepts_valid_durations(self, client, patient_token, duration):
payload = {**VALID_APPOINTMENT_PAYLOAD, "duration_minutes": duration}
res = client.post(
self.URL,
json=payload,
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 201
@pytest.mark.parametrize("duration", [0, 10, 20, 90, 120, -30])
def test_rejects_invalid_duration_values(self, client, patient_token, duration):
payload = {**VALID_APPOINTMENT_PAYLOAD, "duration_minutes": duration}
res = client.post(
self.URL,
json=payload,
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 422
def test_returns_422_with_string_as_duration(self, client, patient_token):
payload = {**VALID_APPOINTMENT_PAYLOAD, "duration_minutes": "treinta"}
res = client.post(
self.URL,
json=payload,
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 422
# --- Precio: boundary values ---
@pytest.mark.parametrize("price", [0, 0.01, 9999.99])
def test_accepts_valid_price_boundaries(self, client, patient_token, price):
payload = {**VALID_APPOINTMENT_PAYLOAD, "price": price}
res = client.post(
self.URL,
json=payload,
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 201
@pytest.mark.parametrize("price", [-0.01, -1, 10000, 10000.01])
def test_rejects_price_out_of_range(self, client, patient_token, price):
payload = {**VALID_APPOINTMENT_PAYLOAD, "price": price}
res = client.post(
self.URL,
json=payload,
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 422
# --- Inyección ---
def test_rejects_sql_injection_in_notes(self, client, patient_token):
payload = {**VALID_APPOINTMENT_PAYLOAD, "notes": "' OR '1'='1; DROP TABLE appointments;--"}
res = client.post(
self.URL,
json=payload,
headers={"Authorization": f"Bearer {patient_token}"},
)
# Debe sanitizar o rechazar, nunca ejecutar
assert res.status_code in (400, 422, 201) # 201 solo si sanitiza correctamente
if res.status_code == 201:
# Si lo acepta, verifica que no ejecutó nada peligroso
assert res.json()["notes"] is not None
def test_rejects_xss_payload_in_notes(self, client, patient_token):
payload = {**VALID_APPOINTMENT_PAYLOAD, "notes": "<script>alert('xss')</script>"}
res = client.post(
self.URL,
json=payload,
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code in (400, 422, 201)
if res.status_code == 201:
stored = res.json()["notes"]
assert "<script>" not in stored
# --- Conflicto de slot ---
def test_returns_409_for_already_booked_slot(self, client, patient_token):
# Intentar reservar el mismo slot dos veces
payload = {**VALID_APPOINTMENT_PAYLOAD, "slot_id": "slot-uuid-already-booked"}
res = client.post(
self.URL,
json=payload,
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 409
=============================================================================
GET /api/v1/appointments/{id} — detalle
=============================================================================
class TestGetAppointmentDetail: def url(self, appt_id: str) -> str: return f"/api/v1/appointments/{appt_id}"
def test_returns_401_without_auth(self, client, existing_appointment_id):
res = client.get(self.url(existing_appointment_id))
assert res.status_code == 401
def test_returns_401_with_expired_token(self, client, expired_token, existing_appointment_id):
res = client.get(
self.url(existing_appointment_id),
headers={"Authorization": f"Bearer {expired_token}"},
)
assert res.status_code == 401
def test_returns_403_when_accessing_other_patient_appointment(
self, client, other_patient_token, existing_appointment_id
):
res = client.get(
self.url(existing_appointment_id),
headers={"Authorization": f"Bearer {other_patient_token}"},
)
assert res.status_code == 403
def test_returns_200_for_owner(self, client, patient_token, existing_appointment_id):
res = client.get(
self.url(existing_appointment_id),
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 200
data = res.json()
assert data["id"] == existing_appointment_id
# Nunca exponer datos sensibles
assert "payment_token" not in data
def test_returns_200_for_admin(self, client, admin_token, existing_appointment_id):
res = client.get(
self.url(existing_appointment_id),
headers={"Authorization": f"Bearer {admin_token}"},
)
assert res.status_code == 200
def test_returns_404_for_nonexistent_id(self, client, admin_token):
res = client.get(
self.url("appt-uuid-00000000-does-not-exist"),
headers={"Authorization": f"Bearer {admin_token}"},
)
assert res.status_code == 404
def test_returns_422_for_malformed_id_format(self, client, admin_token):
res = client.get(
self.url("not-a-valid-uuid!!!!"),
headers={"Authorization": f"Bearer {admin_token}"},
)
assert res.status_code in (400, 422)
=============================================================================
PATCH /api/v1/appointments/{id} — modificar cita
=============================================================================
class TestUpdateAppointment: def url(self, appt_id: str) -> str: return f"/api/v1/appointments/{appt_id}"
def test_returns_401_without_auth(self, client, existing_appointment_id):
res = client.patch(self.url(existing_appointment_id), json={"notes": "updated"})
assert res.status_code == 401
def test_returns_403_for_other_patient(self, client, other_patient_token, existing_appointment_id):
res = client.patch(
self.url(existing_appointment_id),
json={"notes": "intento modificar cita ajena"},
headers={"Authorization": f"Bearer {other_patient_token}"},
)
assert res.status_code == 403
def test_returns_200_for_owner_updating_notes(self, client, patient_token, existing_appointment_id):
res = client.patch(
self.url(existing_appointment_id),
json={"notes": "Traer historial médico"},
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 200
assert res.json()["notes"] == "Traer historial médico"
@pytest.mark.parametrize("invalid_duration", [5, 25, 75, -15])
def test_returns_422_for_invalid_duration_update(
self, client, patient_token, existing_appointment_id, invalid_duration
):
res = client.patch(
self.url(existing_appointment_id),
json={"duration_minutes": invalid_duration},
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 422
def test_returns_404_for_nonexistent_appointment(self, client, admin_token):
res = client.patch(
self.url("appt-uuid-no-existe"),
json={"notes": "test"},
headers={"Authorization": f"Bearer {admin_token}"},
)
assert res.status_code == 404
=============================================================================
DELETE /api/v1/appointments/{id} — cancelar cita
=============================================================================
class TestDeleteAppointment: def url(self, appt_id: str) -> str: return f"/api/v1/appointments/{appt_id}"
def test_returns_401_without_auth(self, client, future_appointment_id):
res = client.delete(self.url(future_appointment_id))
assert res.status_code == 401
def test_returns_403_for_other_patient(self, client, other_patient_token, future_appointment_id):
res = client.delete(
self.url(future_appointment_id),
headers={"Authorization": f"Bearer {other_patient_token}"},
)
assert res.status_code == 403
def test_returns_200_cancelling_future_appointment(self, client, patient_token, future_appointment_id):
"""Cita con start_time > now + 24h — cancelación válida."""
res = client.delete(
self.url(future_appointment_id),
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 200
def test_returns_409_cancelling_imminent_appointment(
self, client, patient_token, imminent_appointment_id
):
"""Cita a menos de 24h — no se puede cancelar."""
res = client.delete(
self.url(imminent_appointment_id),
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 409
assert "24" in res.json().get("detail", "")
def test_returns_404_for_nonexistent_appointment(self, client, admin_token):
res = client.delete(
self.url("appt-uuid-no-existe-delete"),
headers={"Authorization": f"Bearer {admin_token}"},
)
assert res.status_code == 404
def test_admin_can_cancel_any_appointment(self, client, admin_token, future_appointment_id):
res = client.delete(
self.url(future_appointment_id),
headers={"Authorization": f"Bearer {admin_token}"},
)
# Ya cancelada por el paciente en test anterior — 404 o 200 según fixture isolation
assert res.status_code in (200, 404)
=============================================================================
POST /api/v1/appointments/{id}/documents — upload fichero
=============================================================================
class TestUploadDocument: def url(self, appt_id: str) -> str: return f"/api/v1/appointments/{appt_id}/documents"
def test_returns_401_without_auth(self, client, existing_appointment_id):
file_content = b"%PDF-1.4 minimal pdf content"
res = client.post(
self.url(existing_appointment_id),
files={"file": ("informe.pdf", io.BytesIO(file_content), "application/pdf")},
)
assert res.status_code == 401
def test_returns_400_with_no_file(self, client, patient_token, existing_appointment_id):
res = client.post(
self.url(existing_appointment_id),
headers={"Authorization": f"Bearer {patient_token}"},
)
assert res.status_code == 400
assert "file" in res.json().get("detail", "").lower()
def test_returns_200_with_valid_pdf(self, client, patient_token, existing_appointment_id):
pdf_content = b"%PDF-1.4 test document content"
res = client.post(
self.url(existing_appointment_id),
headers={"Authorization": f"Bearer {patient_token}"},
files={"file": ("historial.pdf", io.BytesIO(pdf_content), "application/pdf")},
)
assert res.status_code == 200
data = res.json()
assert "url" in data
assert "id" in data
def test_returns_200_with_valid_jpg_image(self, client, patient_token, existing_appointment_id):
# JPEG magic bytes mínimos
jpg_content = bytes([0xFF, 0xD8, 0xFF, 0xE0]) + b"fake jpeg content"
res = client.post(
self.url(existing_appointment_id),
headers={"Authorization": f"Bearer {patient_token}"},
files={"file": ("radiografia.jpg", io.BytesIO(jpg_content), "image/jpeg")},
)
assert res.status_code == 200
def test_returns_200_with_valid_png_image(self, client, patient_token, existing_appointment_id):
# PNG magic bytes
png_content = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + b"fake png"
res = client.post(
self.url(existing_appointment_id),
headers={"Authorization": f"Bearer {patient_token}"},
files={"file": ("foto.png", io.BytesIO(png_content), "image/png")},
)
assert res.status_code == 200
def test_returns_400_for_unsupported_file_type_docx(
self, client, patient_token, existing_appointment_id
):
docx_content = b"PK\x03\x04fake docx content" # ZIP magic bytes (docx)
res = client.post(
self.url(existing_appointment_id),
headers={"Authorization": f"Bearer {patient_token}"},
files={
"file": (
"documento.docx",
io.BytesIO(docx_content),
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
)
},
)
assert res.status_code == 400
assert any(
kw in res.json().get("detail", "").lower()
for kw in ("type", "format", "allowed", "permitted")
)
def test_returns_400_for_executable_file(self, client, patient_token, existing_appointment_id):
exe_content = bytes([0x4D, 0x5A]) + b"fake windows executable MZ header"
res = client.post(
self.url(existing_appointment_id),
headers={"Authorization": f"Bearer {patient_token}"},
files={"file": ("malware.exe", io.BytesIO(exe_content), "application/octet-stream")},
)
assert res.status_code == 400
def test_returns_413_for_oversized_file(self, client, patient_token, existing_appointment_id):
"""Fichero de 6 MB — supera el límite de 5 MB."""
large_content = b"0" * (6 * 1024 * 1024)
res = client.post(
self.url(existing_appointment_id),
headers={"Authorization": f"Bearer {patient_token}"},
files={"file": ("enorme.pdf", io.BytesIO(large_content), "application/pdf")},
)
assert res.status_code == 413
def test_accepts_file_at_size_boundary_5mb(self, client, patient_token, existing_appointment_id):
"""Fichero exactamente en el límite — debe aceptarse."""
boundary_content = b"%PDF-1.4 " + b"x" * (5 * 1024 * 1024 - 9)
res = client.post(
self.url(existing_appointment_id),
headers={"Authorization": f"Bearer {patient_token}"},
files={"file": ("limite.pdf", io.BytesIO(boundary_content), "application/pdf")},
)
assert res.status_code == 200
def test_returns_400_for_empty_file(self, client, patient_token, existing_appointment_id):
res = client.post(
self.url(existing_appointment_id),
headers={"Authorization": f"Bearer {patient_token}"},
files={"file": ("vacio.pdf", io.BytesIO(b""), "application/pdf")},
)
assert res.status_code == 400
def test_rejects_mime_type_spoofing_exe_as_pdf(
self, client, patient_token, existing_appointment_id
):
"""Extensión .pdf pero magic bytes de ejecutable — debe detectarse."""
exe_magic = bytes([0x4D, 0x5A, 0x90, 0x00]) + b"fake exe disguised as pdf"
res = client.post(
self.url(existing_appointment_id),
headers={"Authorization": f"Bearer {patient_token}"},
files={"file": ("trampa.pdf", io.BytesIO(exe_magic), "application/pdf")},
)
assert res.status_code in (400, 415), "Debe detectar magic bytes incorrectos"
def test_returns_403_other_patient_cannot_upload(
self, client, other_patient_token, existing_appointment_id
):
pdf_content = b"%PDF-1.4 test"
res = client.post(
self.url(existing_appointment_id),
headers={"Authorization": f"Bearer {other_patient_token}"},
files={"file": ("intruso.pdf", io.BytesIO(pdf_content), "application/pdf")},
)
assert res.status_code == 403
=============================================================================
RATE LIMITING — /api/v1/appointments
=============================================================================
class TestRateLimiting: """ Nota: ejecutar estos tests en aislamiento o al final para evitar interferencia con el resto de la suite. Usar pytest -m ratelimit para ejecutarlos solos. """
@pytest.mark.ratelimit
def test_rate_limit_triggered_after_burst(self, client, patient_token):
"""Superar el límite típico de 60 req/min."""
responses = []
for _ in range(70):
res = client.get(
"/api/v1/appointments",
headers={"Authorization": f"Bearer {patient_token}"},
)
responses.append(res.status_code)
if res.status_code == 429:
break
assert 429 in responses, "Rate limit no se activó tras ráfaga de 70 requests"
@pytest.mark.ratelimit
def test_rate_limit_response_includes_retry_after(self, client, patient_token):
"""La respuesta 429 debe incluir Retry-After header o campo en body."""
for _ in range(70):
res = client.get(
"/api/v1/appointments",
headers={"Authorization": f"Bearer {patient_token}"},
)
if res.status_code == 429:
has_header = "Retry-After" in res.headers
has_body = "retry_after" in res.json() or "retryAfter" in res.json()
assert has_header or has_body, "Falta Retry-After en respuesta 429"
break
@pytest.mark.ratelimit
def test_unauthenticated_rate_limit_on_public_endpoint(self, client):
"""El endpoint público /slots también tiene rate limiting."""
responses = []
for _ in range(120):
res = client.get(
"/api/v1/slots",
params={"clinic_id": "clinic-001", "date": "2026-07-01"},
)
responses.append(res.status_code)
if res.status_code == 429:
break
assert 429 in responses, "Endpoint público no tiene rate limiting"
// qué_hace
Genera automáticamente suites de tests para APIs REST cubriendo auth, validación, errores, paginación, uploads y rate limiting.
// cómo_lo_hace
Escanea las rutas del código fuente con comandos bash específicos por framework y aplica matrices de casos de test predefinidas para cada tipo de endpoint.
// ejemplo_de_uso
Úsala justo después de crear o refactorizar una API REST para generar los tests de regresión antes de que llegue a producción. Ej.: escanea los endpoints de tu API de pedidos y genera la suite completa de tests cubriendo autenticación, errores 4xx y paginación.
// plataformas
// opiniones_de_la_comunidad
Opiniones
Cargando opiniones…