major restructure

This commit is contained in:
buenosairesam
2026-01-20 05:31:26 -03:00
parent 27b32deba4
commit e4052374db
328 changed files with 1018 additions and 10018 deletions

View File

@@ -0,0 +1,6 @@
"""
Turnos Monitor - At-a-glance view of request/turno pipeline.
Shows the flow: Request -> Vet Accept -> Payment -> Turno
Color-coded by state, minimal info (vet - pet owner).
"""

View File

@@ -0,0 +1,244 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="30">
<title>Turnos · {{ nest_name }}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #111827;
color: #f3f4f6;
min-height: 100vh;
padding: 1rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0 1rem;
border-bottom: 1px solid #374151;
margin-bottom: 1rem;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nest-badge {
font-size: 0.7rem;
background: #374151;
padding: 0.2rem 0.5rem;
border-radius: 4px;
color: #9ca3af;
}
.total {
font-size: 2rem;
font-weight: 700;
color: #60a5fa;
}
.total small {
font-size: 0.8rem;
color: #9ca3af;
font-weight: 400;
}
/* Pipeline columns */
.pipeline {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.column {
background: #1f2937;
border-radius: 8px;
overflow: hidden;
}
.column-header {
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
font-weight: 600;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid;
}
.column-count {
font-size: 1rem;
font-weight: 700;
}
.column-items {
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 70vh;
overflow-y: auto;
}
/* Individual request card */
.card {
background: #111827;
border-radius: 6px;
padding: 0.5rem 0.6rem;
font-size: 0.75rem;
border-left: 3px solid;
}
.card-id {
font-weight: 600;
color: #9ca3af;
font-size: 0.65rem;
}
.card-vet {
color: #60a5fa;
font-weight: 500;
}
.card-petowner {
color: #d1d5db;
}
.card-meta {
font-size: 0.65rem;
color: #6b7280;
margin-top: 0.25rem;
}
/* Age indicators */
.card.old {
background: #450a0a;
}
.card.warn {
background: #422006;
}
/* Indicators */
.indicator {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
margin-right: 0.25rem;
}
.indicator.paid { background: #22c55e; }
.indicator.scheduled { background: #3b82f6; }
/* Empty state */
.empty {
text-align: center;
color: #6b7280;
padding: 2rem;
font-size: 0.8rem;
}
/* Footer */
footer {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #374151;
font-size: 0.7rem;
color: #6b7280;
display: flex;
justify-content: space-between;
}
.refresh-notice {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.view-toggle a {
color: #6b7280;
text-decoration: none;
font-size: 0.8rem;
padding: 0.3rem 0.6rem;
border-radius: 4px;
}
.view-toggle a:hover { background: #374151; }
.view-toggle a.active { background: #374151; color: #f3f4f6; }
</style>
</head>
<body>
<header>
<h1>
Turnos
<span class="nest-badge">{{ nest_name }}</span>
</h1>
<div class="header-right">
<div class="view-toggle">
<a href="?view=pipeline" class="active">Pipeline</a>
<a href="?view=list">List</a>
</div>
<div class="total">
{{ total }}
<small>activos</small>
</div>
</div>
</header>
{% if total == 0 %}
<div class="empty">
No hay solicitudes activas
</div>
{% else %}
<div class="pipeline">
{% for state in active_states %}
{% set info = states.get(state, ('?', '#888', 99)) %}
{% set items = by_state.get(state, []) %}
<div class="column">
<div class="column-header" style="border-color: {{ info[1] }}; color: {{ info[1] }};">
<span>{{ info[0] }}</span>
<span class="column-count">{{ items|length }}</span>
</div>
<div class="column-items">
{% for item in items %}
<div class="card {{ item.age_class }}" style="border-color: {{ info[1] }};">
<div class="card-id">#{{ item.id }}</div>
<div class="card-vet">{{ item.vet }}</div>
<div class="card-petowner">{{ item.petowner }}</div>
<div class="card-meta">
{% if item.is_paid %}<span class="indicator paid" title="Pagado"></span>{% endif %}
{% if item.is_scheduled %}<span class="indicator scheduled" title="Coordinado"></span>{% endif %}
{{ item.age_hours }}h
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
{% endif %}
<footer>
<span class="refresh-notice">Auto-refresh 30s</span>
<span><a href="/health" style="color: #6b7280;">/health</a></span>
</footer>
</body>
</html>

View File

@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="refresh" content="30">
<title>Turnos List · {{ nest_name }}</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: #111827;
color: #f3f4f6;
min-height: 100vh;
padding: 1rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0 1rem;
border-bottom: 1px solid #374151;
margin-bottom: 1rem;
}
h1 {
font-size: 1.25rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.nest-badge {
font-size: 0.7rem;
background: #374151;
padding: 0.2rem 0.5rem;
border-radius: 4px;
color: #9ca3af;
}
.header-right {
display: flex;
align-items: center;
gap: 1rem;
}
.total {
font-size: 1.5rem;
font-weight: 700;
color: #60a5fa;
}
.view-toggle a {
color: #6b7280;
text-decoration: none;
font-size: 0.8rem;
padding: 0.3rem 0.6rem;
border-radius: 4px;
}
.view-toggle a:hover { background: #374151; }
.view-toggle a.active { background: #374151; color: #f3f4f6; }
/* Table */
table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
th {
text-align: left;
padding: 0.5rem;
color: #9ca3af;
font-weight: 500;
border-bottom: 1px solid #374151;
}
td {
padding: 0.5rem;
border-bottom: 1px solid #1f2937;
}
tr:hover td {
background: #1f2937;
}
.state-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.vet { color: #60a5fa; }
.petowner { color: #d1d5db; }
.id { color: #6b7280; font-family: monospace; }
.age { color: #6b7280; }
.age.warn { color: #fbbf24; }
.age.old { color: #f87171; }
.indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 0.25rem;
}
.indicator.paid { background: #22c55e; }
.indicator.scheduled { background: #3b82f6; }
.empty {
text-align: center;
color: #6b7280;
padding: 3rem;
}
footer {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #374151;
font-size: 0.7rem;
color: #6b7280;
display: flex;
justify-content: space-between;
}
</style>
</head>
<body>
<header>
<h1>
Turnos
<span class="nest-badge">{{ nest_name }}</span>
</h1>
<div class="header-right">
<div class="view-toggle">
<a href="?view=pipeline">Pipeline</a>
<a href="?view=list" class="active">List</a>
</div>
<div class="total">{{ total }}</div>
</div>
</header>
{% if total == 0 %}
<div class="empty">No hay solicitudes activas</div>
{% else %}
<table>
<thead>
<tr>
<th>#</th>
<th>Estado</th>
<th>Veterinario</th>
<th>Cliente</th>
<th>Flags</th>
<th>Edad</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td class="id">{{ item.id }}</td>
<td>
<span class="state-badge" style="background: {{ item.state_color }}20; color: {{ item.state_color }};">
{{ item.state_label }}
</span>
</td>
<td class="vet">{{ item.vet }}</td>
<td class="petowner">{{ item.petowner }}</td>
<td>
{% if item.is_paid %}<span class="indicator paid" title="Pagado"></span>{% endif %}
{% if item.is_scheduled %}<span class="indicator scheduled" title="Coordinado"></span>{% endif %}
</td>
<td class="age {{ item.age_class }}">{{ item.age_hours }}h</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<footer>
<span>Auto-refresh 30s</span>
<span><a href="/health" style="color: #6b7280;">/health</a></span>
</footer>
</body>
</html>

View File

@@ -0,0 +1,270 @@
"""
Turnos Monitor - At-a-glance view of the request/turno pipeline.
Request -> Vet Accept + Payment -> Turno (VetVisit)
Run standalone:
python -m ward.tools.monitors.turnos.main
Or use uvicorn:
uvicorn ward.tools.monitors.turnos.main:app --port 12010 --reload
"""
import os
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy import create_engine, text
from sqlalchemy.engine import Engine
app = FastAPI(title="Turnos Monitor", version="0.1.0")
templates = Jinja2Templates(directory=Path(__file__).parent)
# =============================================================================
# NEST CONFIG - Pluggable environment targeting
# =============================================================================
# Default nest: local development database
# Override with env vars or future nest selector UI
NEST_CONFIG = {
"name": os.getenv("NEST_NAME", "local"),
"db": {
"host": os.getenv("DB_HOST", "localhost"),
"port": os.getenv("DB_PORT", "5433"), # local default
"name": os.getenv("DB_NAME", "amarback"),
"user": os.getenv("DB_USER", "mariano"),
"password": os.getenv("DB_PASSWORD", ""),
}
}
def get_db_url() -> str:
"""Build database URL from nest config."""
db = NEST_CONFIG["db"]
return f"postgresql://{db['user']}:{db['password']}@{db['host']}:{db['port']}/{db['name']}"
# =============================================================================
# STATE DEFINITIONS
# =============================================================================
# Pipeline states with labels, colors, and order
STATES = {
"pending": ("Sin Atender", "#fbbf24", 1), # amber
"in_progress_vet": ("Buscando Vet", "#f97316", 2), # orange
"vet_asked": ("Esperando Vet", "#fb923c", 3), # orange light
"vet_accepted": ("Vet OK", "#4ade80", 4), # green
"in_progress_pay": ("Esp. Pago", "#60a5fa", 5), # blue
"payed": ("Pagado", "#2dd4bf", 6), # teal
"coordinated": ("Coordinado", "#22c55e", 7), # green
"not_coordinated": ("Sin Coord.", "#facc15", 8), # yellow
"completed": ("Turno", "#059669", 9), # emerald
"rejected": ("Rechazado", "#f87171", 10), # red
}
# States to show in the active pipeline (exclude end states)
ACTIVE_STATES = [
"pending",
"in_progress_vet",
"vet_asked",
"vet_accepted",
"in_progress_pay",
"payed",
"not_coordinated",
"coordinated",
]
# =============================================================================
# DATABASE
# =============================================================================
_engine: Optional[Engine] = None
def get_engine() -> Optional[Engine]:
"""Get or create database engine (lazy singleton)."""
global _engine
if _engine is None:
try:
_engine = create_engine(get_db_url(), pool_pre_ping=True)
except Exception as e:
print(f"[turnos] DB engine error: {e}")
return _engine
def fetch_active_requests() -> list[dict]:
"""Fetch active service requests with vet and petowner info."""
engine = get_engine()
if not engine:
return []
query = text("""
SELECT
sr.id,
sr.state,
sr.created_at,
sr.date_coordinated,
sr.hour_coordinated,
sr.pay_number,
po.first_name || ' ' || COALESCE(po.last_name, '') as petowner_name,
COALESCE(v.first_name || ' ' || v.last_name, '') as vet_name
FROM solicitudes_servicerequest sr
JOIN mascotas_petowner po ON sr.petowner_id = po.id
LEFT JOIN mascotas_veterinarian v ON sr.veterinarian_id = v.id
WHERE sr.state = ANY(:states)
ORDER BY
CASE sr.state
WHEN 'pending' THEN 1
WHEN 'in_progress_vet' THEN 2
WHEN 'vet_asked' THEN 3
WHEN 'vet_accepted' THEN 4
WHEN 'in_progress_pay' THEN 5
WHEN 'payed' THEN 6
WHEN 'not_coordinated' THEN 7
WHEN 'coordinated' THEN 8
ELSE 9
END,
sr.created_at DESC
""")
try:
with engine.connect() as conn:
result = conn.execute(query, {"states": ACTIVE_STATES})
rows = result.fetchall()
requests = []
for row in rows:
state_info = STATES.get(row.state, ("?", "#888", 99))
age_h = _hours_since(row.created_at)
requests.append({
"id": row.id,
"state": row.state,
"state_label": state_info[0],
"state_color": state_info[1],
"petowner": row.petowner_name.strip(),
"vet": row.vet_name.strip() if row.vet_name else "-",
"is_paid": bool(row.pay_number),
"is_scheduled": bool(row.date_coordinated and row.hour_coordinated),
"age_hours": age_h,
"age_class": "old" if age_h > 48 else ("warn" if age_h > 24 else ""),
})
return requests
except Exception as e:
print(f"[turnos] Query error: {e}")
return []
def fetch_counts() -> dict[str, int]:
"""Fetch count per state."""
engine = get_engine()
if not engine:
return {}
query = text("""
SELECT state, COUNT(*) as cnt
FROM solicitudes_servicerequest
WHERE state = ANY(:states)
GROUP BY state
""")
try:
with engine.connect() as conn:
result = conn.execute(query, {"states": ACTIVE_STATES})
return {row.state: row.cnt for row in result.fetchall()}
except Exception as e:
print(f"[turnos] Count error: {e}")
return {}
def _hours_since(dt: Optional[datetime]) -> int:
"""Hours since datetime."""
if not dt:
return 0
try:
now = datetime.now(dt.tzinfo) if dt.tzinfo else datetime.now()
return int((now - dt).total_seconds() / 3600)
except:
return 0
# =============================================================================
# ROUTES
# =============================================================================
@app.get("/health")
def health():
"""Health check."""
engine = get_engine()
db_ok = False
if engine:
try:
with engine.connect() as conn:
conn.execute(text("SELECT 1"))
db_ok = True
except:
pass
return {
"status": "ok" if db_ok else "degraded",
"service": "turnos-monitor",
"nest": NEST_CONFIG["name"],
"database": "connected" if db_ok else "disconnected",
}
@app.get("/", response_class=HTMLResponse)
def index(request: Request, view: str = "pipeline"):
"""Main monitor view. ?view=list for list view."""
requests_data = fetch_active_requests()
counts = fetch_counts()
# Group by state
by_state = {s: [] for s in ACTIVE_STATES}
for req in requests_data:
if req["state"] in by_state:
by_state[req["state"]].append(req)
template = "list.html" if view == "list" else "index.html"
return templates.TemplateResponse(template, {
"request": request,
"items": requests_data,
"by_state": by_state,
"counts": counts,
"states": STATES,
"active_states": ACTIVE_STATES,
"total": len(requests_data),
"nest_name": NEST_CONFIG["name"],
"view": view,
})
@app.get("/api/data")
def api_data():
"""JSON API endpoint."""
return {
"nest": NEST_CONFIG["name"],
"requests": fetch_active_requests(),
"counts": fetch_counts(),
}
# =============================================================================
# MAIN
# =============================================================================
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"main:app",
host="0.0.0.0",
port=int(os.getenv("PORT", "12010")),
reload=True,
)

View File

@@ -0,0 +1,25 @@
{
"$comment": "Test scenarios emerge from actual usage and conversations. This is just the format.",
"scenarios": [
{
"_example": "This is an example scenario structure - real scenarios will be added as needed",
"name": "Example Scenario",
"slug": "example-scenario",
"description": "Description of what this scenario tests",
"role": "USER",
"entity": "PetOwner",
"view": "petowners_by_state",
"filters": {
"has_pets": true,
"has_requests": false
},
"test_cases": [
"Test case 1",
"Test case 2"
],
"priority": "medium",
"complexity": "medium",
"notes": "Additional notes about this scenario"
}
]
}

View File

@@ -0,0 +1,217 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AMAR Data Model",
"description": "Test-oriented data model for AMAR Mascotas. Focused on test scenarios and user navigation.",
"version": "0.1.0",
"meta": {
"purpose": "Enable quick navigation to appropriate test users/scenarios",
"modes": ["sql", "api"],
"graph_generators": ["graphviz", "mermaid", "d3", "custom"]
},
"definitions": {
"UserRole": {
"type": "string",
"enum": ["USER", "VET", "ADMIN"],
"description": "User roles detected from: is_staff → ADMIN, linked to Veterinarian → VET, else USER"
},
"RequestState": {
"type": "string",
"enum": [
"pending",
"in_progress_vet",
"vet_asked",
"vet_accepted",
"in_progress_pay",
"payed",
"coordinated",
"not_coordinated",
"completed",
"rejected"
],
"description": "Service request lifecycle states"
},
"User": {
"type": "object",
"description": "Django auth.User - central identity",
"table": "auth_user",
"properties": {
"id": {"type": "integer", "column": "id"},
"username": {"type": "string", "column": "username"},
"email": {"type": "string", "column": "email"},
"first_name": {"type": "string", "column": "first_name"},
"last_name": {"type": "string", "column": "last_name"},
"is_staff": {"type": "boolean", "column": "is_staff"},
"is_active": {"type": "boolean", "column": "is_active"},
"date_joined": {"type": "string", "format": "date-time", "column": "date_joined"}
},
"computed": {
"role": {
"description": "Derived from is_staff, veterinarian link, or default USER",
"sql": "CASE WHEN is_staff THEN 'ADMIN' WHEN EXISTS (SELECT 1 FROM mascotas_veterinarian WHERE user_id = auth_user.id) THEN 'VET' ELSE 'USER' END"
}
},
"required": ["id", "username"]
},
"PetOwner": {
"type": "object",
"description": "Pet owner (client)",
"table": "mascotas_petowner",
"properties": {
"id": {"type": "integer", "column": "id"},
"user_id": {"type": "integer", "column": "user_id"},
"first_name": {"type": "string", "column": "first_name"},
"last_name": {"type": "string", "column": "last_name"},
"email": {"type": "string", "column": "email"},
"phone": {"type": "string", "column": "phone"},
"dni": {"type": "string", "column": "dni"},
"address": {"type": "string", "column": "address"},
"created_at": {"type": "string", "format": "date-time", "column": "created_at"}
},
"computed": {
"has_pets": {
"description": "Has at least one pet",
"sql": "EXISTS (SELECT 1 FROM mascotas_pet WHERE petowner_id = mascotas_petowner.id AND deleted = false)"
},
"has_coverage": {
"description": "Has active coverage",
"sql": "EXISTS (SELECT 1 FROM mascotas_coverage WHERE petowner_id = mascotas_petowner.id AND active = true AND deleted = false)"
},
"has_requests": {
"description": "Has any service requests",
"sql": "EXISTS (SELECT 1 FROM solicitudes_servicerequest WHERE petowner_id = mascotas_petowner.id)"
},
"has_turnos": {
"description": "Has scheduled turnos",
"sql": "EXISTS (SELECT 1 FROM mascotas_vetvisit WHERE petowner_id = mascotas_petowner.id)"
}
},
"required": ["id", "first_name"]
},
"Veterinarian": {
"type": "object",
"description": "Veterinarian service provider",
"table": "mascotas_veterinarian",
"properties": {
"id": {"type": "integer", "column": "id"},
"user_id": {"type": "integer", "column": "user_id"},
"first_name": {"type": "string", "column": "first_name"},
"last_name": {"type": "string", "column": "last_name"},
"email": {"type": "string", "column": "email"},
"phone": {"type": "string", "column": "phone"},
"matricula": {"type": "string", "column": "matricula"},
"created_at": {"type": "string", "format": "date-time", "column": "created_at"}
},
"computed": {
"has_availability": {
"description": "Has configured availability",
"sql": "EXISTS (SELECT 1 FROM mascotas_availability WHERE veterinarian_id = mascotas_veterinarian.id AND deleted = false)"
},
"has_specialties": {
"description": "Has assigned specialties",
"sql": "EXISTS (SELECT 1 FROM mascotas_veterinarian_specialties WHERE veterinarian_id = mascotas_veterinarian.id)"
},
"has_coverage_areas": {
"description": "Has coverage neighborhoods",
"sql": "EXISTS (SELECT 1 FROM mascotas_veterinarian_neighborhoods WHERE veterinarian_id = mascotas_veterinarian.id)"
},
"active_requests": {
"description": "Count of active service requests",
"sql": "(SELECT COUNT(*) FROM solicitudes_servicerequest WHERE veterinarian_id = mascotas_veterinarian.id AND state NOT IN ('completed', 'rejected'))"
},
"completed_visits": {
"description": "Count of completed visits",
"sql": "(SELECT COUNT(*) FROM mascotas_vetvisit WHERE veterinarian_id = mascotas_veterinarian.id)"
}
},
"required": ["id", "first_name"]
},
"Pet": {
"type": "object",
"description": "Pet belonging to PetOwner",
"table": "mascotas_pet",
"properties": {
"id": {"type": "integer", "column": "id"},
"petowner_id": {"type": "integer", "column": "petowner_id"},
"name": {"type": "string", "column": "name"},
"pet_type": {"type": "string", "column": "pet_type"},
"breed_id": {"type": "integer", "column": "breed_id"},
"age_years": {"type": "integer", "column": "age_years"},
"created_at": {"type": "string", "format": "date-time", "column": "created_at"}
},
"computed": {
"has_vaccines": {
"description": "Has vaccine records",
"sql": "EXISTS (SELECT 1 FROM mascotas_petvaccination WHERE pet_id = mascotas_pet.id)"
},
"has_studies": {
"description": "Has study records",
"sql": "EXISTS (SELECT 1 FROM mascotas_petstudy WHERE pet_id = mascotas_pet.id)"
}
},
"required": ["id", "petowner_id", "name"]
},
"ServiceRequest": {
"type": "object",
"description": "Service request (order) - main workflow entity",
"table": "solicitudes_servicerequest",
"properties": {
"id": {"type": "integer", "column": "id"},
"petowner_id": {"type": "integer", "column": "petowner_id"},
"veterinarian_id": {"type": "integer", "column": "veterinarian_id"},
"state": {"$ref": "#/definitions/RequestState", "column": "state"},
"pay_number": {"type": "string", "column": "pay_number"},
"date_coordinated": {"type": "string", "format": "date", "column": "date_coordinated"},
"hour_coordinated": {"type": "string", "format": "time", "column": "hour_coordinated"},
"created_at": {"type": "string", "format": "date-time", "column": "created_at"}
},
"computed": {
"has_cart": {
"description": "Has associated cart",
"sql": "EXISTS (SELECT 1 FROM productos_servicecart WHERE service_request_id = solicitudes_servicerequest.id)"
},
"has_payment": {
"description": "Has payment record",
"sql": "pay_number IS NOT NULL AND pay_number != ''"
},
"has_turno": {
"description": "Has created turno",
"sql": "EXISTS (SELECT 1 FROM mascotas_vetvisit WHERE service_request_id = solicitudes_servicerequest.id)"
},
"age_hours": {
"description": "Hours since creation",
"sql": "EXTRACT(EPOCH FROM (NOW() - created_at)) / 3600"
}
},
"required": ["id", "petowner_id", "state"]
},
"VetVisit": {
"type": "object",
"description": "Scheduled veterinary visit (turno)",
"table": "mascotas_vetvisit",
"properties": {
"id": {"type": "integer", "column": "id"},
"service_request_id": {"type": "integer", "column": "service_request_id"},
"veterinarian_id": {"type": "integer", "column": "veterinarian_id"},
"petowner_id": {"type": "integer", "column": "petowner_id"},
"visit_date": {"type": "string", "format": "date", "column": "visit_date"},
"visit_hour": {"type": "string", "format": "time", "column": "visit_hour"},
"created_at": {"type": "string", "format": "date-time", "column": "created_at"}
},
"computed": {
"has_report": {
"description": "Has post-visit report",
"sql": "EXISTS (SELECT 1 FROM mascotas_vetvisitreport WHERE vet_visit_id = mascotas_vetvisit.id)"
},
"has_invoice": {
"description": "Has generated invoice",
"sql": "invoice_number IS NOT NULL"
},
"is_completed": {
"description": "Visit has been completed",
"sql": "visit_date < CURRENT_DATE OR (visit_date = CURRENT_DATE AND visit_hour < CURRENT_TIME)"
}
},
"required": ["id", "service_request_id"]
}
}
}

View File

@@ -0,0 +1,178 @@
{
"views": [
{
"name": "users_by_role",
"title": "Users by Role",
"slug": "users-by-role",
"description": "All users grouped by role (USER/VET/ADMIN) for quick login selection",
"mode": "sql",
"entity": "User",
"group_by": "role",
"order_by": "username ASC",
"fields": ["id", "username", "email", "first_name", "last_name", "is_staff", "is_active", "date_joined"],
"display_fields": {
"id": {"label": "ID", "width": "60px"},
"username": {"label": "Username", "width": "150px", "primary": true},
"email": {"label": "Email", "width": "200px"},
"first_name": {"label": "First Name", "width": "120px"},
"last_name": {"label": "Last Name", "width": "120px"},
"is_active": {"label": "Active", "width": "80px", "type": "boolean"}
},
"actions": {
"login_as": {
"label": "Login as this user",
"type": "command",
"command": "echo 'Login as {{username}}' | pbcopy"
},
"copy_credentials": {
"label": "Copy credentials",
"type": "copy",
"template": "{{username}} / Amar2025!"
}
}
},
{
"name": "petowners_by_state",
"title": "Pet Owners by Data State",
"slug": "petowners-by-state",
"description": "Pet owners grouped by data state (has_pets, has_coverage, has_requests, has_turnos)",
"mode": "sql",
"entity": "PetOwner",
"group_by": "state_category",
"order_by": "created_at DESC",
"fields": ["id", "user_id", "first_name", "last_name", "email", "phone", "has_pets", "has_coverage", "has_requests", "has_turnos"],
"computed_group": {
"state_category": {
"sql": "CASE WHEN has_turnos THEN 'with_turnos' WHEN has_requests THEN 'with_requests' WHEN has_pets THEN 'with_pets' WHEN has_coverage THEN 'with_coverage' ELSE 'new' END",
"labels": {
"new": "New Users (Empty)",
"with_coverage": "With Coverage Only",
"with_pets": "With Pets Only",
"with_requests": "With Requests",
"with_turnos": "With Scheduled Turnos"
}
}
},
"display_fields": {
"id": {"label": "ID", "width": "60px"},
"first_name": {"label": "First Name", "width": "120px"},
"last_name": {"label": "Last Name", "width": "120px", "primary": true},
"email": {"label": "Email", "width": "200px"},
"phone": {"label": "Phone", "width": "120px"},
"has_pets": {"label": "Pets", "width": "60px", "type": "icon"},
"has_coverage": {"label": "Coverage", "width": "80px", "type": "icon"},
"has_requests": {"label": "Requests", "width": "80px", "type": "icon"},
"has_turnos": {"label": "Turnos", "width": "70px", "type": "icon"}
},
"actions": {
"login_as": {
"label": "Login as petowner",
"type": "link",
"template": "/login?user_id={{user_id}}"
}
}
},
{
"name": "vets_by_availability",
"title": "Veterinarians by Availability",
"slug": "vets-by-availability",
"description": "Vets grouped by availability and active work status",
"mode": "sql",
"entity": "Veterinarian",
"group_by": "availability_status",
"order_by": "active_requests DESC, completed_visits DESC",
"fields": ["id", "user_id", "first_name", "last_name", "email", "phone", "matricula", "has_availability", "has_specialties", "has_coverage_areas", "active_requests", "completed_visits"],
"computed_group": {
"availability_status": {
"sql": "CASE WHEN NOT has_availability THEN 'no_availability' WHEN active_requests > 3 THEN 'very_busy' WHEN active_requests > 0 THEN 'busy' ELSE 'available' END",
"labels": {
"no_availability": "No Availability Configured",
"available": "Available (No Active Requests)",
"busy": "Busy (1-3 Active Requests)",
"very_busy": "Very Busy (4+ Active Requests)"
}
}
},
"display_fields": {
"id": {"label": "ID", "width": "60px"},
"first_name": {"label": "First Name", "width": "120px"},
"last_name": {"label": "Last Name", "width": "120px", "primary": true},
"matricula": {"label": "Matricula", "width": "100px"},
"phone": {"label": "Phone", "width": "120px"},
"has_availability": {"label": "Avail", "width": "60px", "type": "icon"},
"has_specialties": {"label": "Spec", "width": "60px", "type": "icon"},
"has_coverage_areas": {"label": "Areas", "width": "60px", "type": "icon"},
"active_requests": {"label": "Active", "width": "70px", "type": "number"},
"completed_visits": {"label": "Completed", "width": "90px", "type": "number"}
},
"actions": {
"login_as": {
"label": "Login as vet",
"type": "link",
"template": "/login?user_id={{user_id}}"
}
}
},
{
"name": "requests_pipeline",
"title": "Service Requests Pipeline",
"slug": "requests-pipeline",
"description": "Active service requests grouped by state (like turnos monitor)",
"mode": "sql",
"entity": "ServiceRequest",
"group_by": "state",
"order_by": "created_at DESC",
"fields": ["id", "petowner_id", "veterinarian_id", "state", "pay_number", "date_coordinated", "hour_coordinated", "has_cart", "has_payment", "has_turno", "age_hours", "created_at"],
"filter": {
"state": ["pending", "in_progress_vet", "vet_asked", "vet_accepted", "in_progress_pay", "payed", "coordinated", "not_coordinated"]
},
"display_fields": {
"id": {"label": "ID", "width": "60px", "primary": true},
"petowner_id": {"label": "Owner", "width": "70px", "type": "link"},
"veterinarian_id": {"label": "Vet", "width": "60px", "type": "link"},
"state": {"label": "State", "width": "120px", "type": "badge"},
"has_cart": {"label": "Cart", "width": "50px", "type": "icon"},
"has_payment": {"label": "Pay", "width": "50px", "type": "icon"},
"has_turno": {"label": "Turno", "width": "50px", "type": "icon"},
"age_hours": {"label": "Age (h)", "width": "70px", "type": "number"}
},
"state_colors": {
"pending": "#fbbf24",
"in_progress_vet": "#f97316",
"vet_asked": "#fb923c",
"vet_accepted": "#4ade80",
"in_progress_pay": "#60a5fa",
"payed": "#2dd4bf",
"coordinated": "#22c55e",
"not_coordinated": "#facc15"
},
"actions": {
"view_details": {
"label": "View request",
"type": "link",
"template": "/admin/solicitudes/servicerequest/{{id}}/change/"
}
}
},
{
"name": "full_graph",
"title": "Full Data Graph",
"slug": "full-graph",
"description": "Complete data model graph showing all entities and relationships",
"mode": "graph",
"graph_type": "erd",
"entities": ["User", "PetOwner", "Veterinarian", "Pet", "ServiceRequest", "VetVisit"],
"relationships": [
{"from": "User", "to": "PetOwner", "type": "1:1", "via": "user_id"},
{"from": "User", "to": "Veterinarian", "type": "1:1", "via": "user_id"},
{"from": "PetOwner", "to": "Pet", "type": "1:N", "via": "petowner_id"},
{"from": "PetOwner", "to": "ServiceRequest", "type": "1:N", "via": "petowner_id"},
{"from": "Veterinarian", "to": "ServiceRequest", "type": "1:N", "via": "veterinarian_id"},
{"from": "ServiceRequest", "to": "VetVisit", "type": "1:1", "via": "service_request_id"},
{"from": "Veterinarian", "to": "VetVisit", "type": "1:N", "via": "veterinarian_id"}
},
"layout": "hierarchical",
"generators": ["graphviz", "mermaid", "d3"]
}
]
}

View File

@@ -0,0 +1,255 @@
"""Data generator for Amar domain models - can be plugged into any nest."""
import random
from datetime import datetime, timedelta
from typing import Dict, List, Any, Optional
class AmarDataGenerator:
"""Generates realistic test data for Amar domain models."""
# Sample data pools
FIRST_NAMES = ["Lucas", "María", "Juan", "Carolina", "Diego", "Valentina", "Martín", "Sofía", "Mateo", "Emma"]
LAST_NAMES = ["González", "Rodríguez", "Pérez", "García", "Martínez", "López", "Fernández", "Sánchez"]
PET_NAMES = ["Luna", "Max", "Bella", "Rocky", "Coco", "Toby", "Mia", "Charlie", "Lola", "Simba"]
PET_SPECIES = [
{"id": 1, "name": "Perro", "code": "DOG"},
{"id": 2, "name": "Gato", "code": "CAT"}
]
NEIGHBORHOODS = [
{"id": 1, "name": "Palermo", "has_coverage": True, "zone": "CABA"},
{"id": 2, "name": "Recoleta", "has_coverage": True, "zone": "CABA"},
{"id": 3, "name": "Belgrano", "has_coverage": True, "zone": "CABA"},
{"id": 4, "name": "Caballito", "has_coverage": True, "zone": "CABA"},
{"id": 5, "name": "Mataderos", "has_coverage": False, "zone": "CABA"},
{"id": 6, "name": "Villa Urquiza", "has_coverage": True, "zone": "CABA"},
]
SERVICE_CATEGORIES = [
{"id": 1, "name": "Consultas", "description": "Consultas veterinarias"},
{"id": 2, "name": "Vacunación", "description": "Vacunas y antiparasitarios"},
{"id": 3, "name": "Estudios", "description": "Análisis y estudios clínicos"},
{"id": 4, "name": "Videollamada", "description": "Consultas por videollamada"},
]
SERVICES = [
{"id": 1, "category_id": 1, "name": "Consulta general clínica programada", "price": 95000, "species": ["DOG", "CAT"]},
{"id": 2, "category_id": 2, "name": "Vacuna Antirrábica", "price": 7000, "species": ["DOG", "CAT"]},
{"id": 3, "category_id": 2, "name": "Sextuple", "price": 12000, "species": ["DOG"]},
{"id": 4, "category_id": 2, "name": "Triple Felina", "price": 11000, "species": ["CAT"]},
{"id": 5, "category_id": 3, "name": "Análisis de sangre", "price": 25000, "species": ["DOG", "CAT"]},
{"id": 6, "category_id": 3, "name": "Ecografía", "price": 35000, "species": ["DOG", "CAT"]},
{"id": 7, "category_id": 4, "name": "Consulta por videollamada", "price": 15000, "species": ["DOG", "CAT"]},
]
@classmethod
def petowner(cls, address: str = None, is_guest: bool = True, **overrides) -> Dict[str, Any]:
"""Generate a petowner.
Args:
address: Owner address
is_guest: Whether this is a guest user
**overrides: Override any fields
"""
owner_id = overrides.get("id", random.randint(1000, 9999))
first_name = overrides.get("first_name", random.choice(cls.FIRST_NAMES) if not is_guest else "")
last_name = overrides.get("last_name", random.choice(cls.LAST_NAMES) if not is_guest else "")
address = address or f"{random.choice(['Av.', 'Calle'])} {random.choice(['Corrientes', 'Santa Fe', 'Córdoba', 'Rivadavia'])} {random.randint(1000, 5000)}"
# Determine neighborhood from address or random
neighborhood = overrides.get("neighborhood", random.choice([n for n in cls.NEIGHBORHOODS if n["has_coverage"]]))
data = {
"id": owner_id,
"first_name": first_name,
"last_name": last_name,
"email": f"guest_{owner_id}@amarmascotas.ar" if is_guest else f"{first_name.lower()}.{last_name.lower()}@example.com",
"phone": f"+54911{random.randint(10000000, 99999999)}",
"address": address,
"neighborhood": neighborhood,
"is_guest": is_guest,
"created_at": datetime.now().isoformat(),
}
# Apply overrides
data.update(overrides)
return data
@classmethod
def pet(cls, owner_id: int, name: str = None, species: str = "DOG", age_value: int = None, age_unit: str = "years", **overrides) -> Dict[str, Any]:
"""Generate a pet.
Args:
owner_id: Owner ID
name: Pet name
species: Pet species code (DOG, CAT)
age_value: Age value
age_unit: Age unit (years, months)
**overrides: Override any fields
"""
species_data = next((s for s in cls.PET_SPECIES if s["code"] == species.upper()), cls.PET_SPECIES[0])
age = age_value or random.randint(1, 10)
name = name or random.choice(cls.PET_NAMES)
data = {
"id": random.randint(1000, 9999),
"owner_id": owner_id,
"name": name,
"species": species_data,
"age": age,
"age_unit": age_unit,
"age_in_months": age if age_unit == "months" else age * 12,
"created_at": datetime.now().isoformat(),
}
data.update(overrides)
return data
@classmethod
def cart(cls, owner_id: int, **overrides) -> Dict[str, Any]:
"""Generate an empty cart.
Args:
owner_id: Owner ID
**overrides: Override any fields
"""
cart_id = overrides.get("id", random.randint(10000, 99999))
data = {
"id": cart_id,
"owner_id": owner_id,
"items": [],
"resume": {
"subtotal": 0.0,
"discounts": 0.0,
"total": 0.0,
},
"resume_items": [],
"created_at": datetime.now().isoformat(),
}
data.update(overrides)
return data
@classmethod
def filter_services(cls, species: str = None, neighborhood_id: int = None) -> List[Dict[str, Any]]:
"""Filter services by species and neighborhood coverage.
Args:
species: Species code to filter by (DOG, CAT)
neighborhood_id: Neighborhood ID for coverage check
"""
services = cls.SERVICES.copy()
if species:
species_code = species.upper()
services = [s for s in services if species_code in s["species"]]
# Neighborhood coverage - only videollamada if no coverage
if neighborhood_id:
neighborhood = next((n for n in cls.NEIGHBORHOODS if n["id"] == neighborhood_id), None)
if not neighborhood or not neighborhood.get("has_coverage"):
services = [s for s in services if s["category_id"] == 4] # Only videollamada
return [
{
"id": s["id"],
"name": s["name"],
"category_id": s["category_id"],
"price": s["price"],
"currency": "ARS",
"available": True,
}
for s in services
]
@classmethod
def filter_categories(cls, species: str = None, neighborhood_id: int = None) -> List[Dict[str, Any]]:
"""Filter categories that have available services.
Args:
species: Species code to filter by
neighborhood_id: Neighborhood ID for coverage check
"""
available_services = cls.filter_services(species, neighborhood_id)
service_category_ids = {s["category_id"] for s in available_services}
return [
{
"id": cat["id"],
"name": cat["name"],
"description": cat["description"],
"service_count": len([s for s in available_services if s["category_id"] == cat["id"]]),
}
for cat in cls.SERVICE_CATEGORIES
if cat["id"] in service_category_ids
]
@classmethod
def calculate_cart_summary(cls, cart: Dict[str, Any], items: List[Dict[str, Any]]) -> Dict[str, Any]:
"""Calculate cart totals with discounts and splits.
Args:
cart: Cart data
items: List of cart items with price and quantity
"""
subtotal = sum(item.get("price", 0) * item.get("quantity", 1) for item in items)
# Multi-pet discount
pet_count = len({item.get("pet_id") for item in items if item.get("pet_id")})
discount_rate = 0.0
if pet_count >= 2:
discount_rate = 0.10 # 10% for 2+ pets
discount_amount = subtotal * discount_rate
total = subtotal - discount_amount
resume_items = [
{"concept": "SUBTOTAL", "amount": subtotal},
]
if discount_amount > 0:
resume_items.append({"concept": "DESCUENTO MULTIMASCOTA", "amount": -discount_amount})
# Calculate vet honorarios (52% of total in real system)
honorarios = total * 0.52
resume_items.append({"concept": "HONORARIOS", "amount": honorarios})
resume_items.append({"concept": "TOTAL", "amount": total})
return {
**cart,
"items": items,
"resume": {
"subtotal": round(subtotal, 2),
"discounts": round(discount_amount, 2),
"total": round(total, 2),
},
"resume_items": resume_items,
"updated_at": datetime.now().isoformat(),
}
@classmethod
def service_request(cls, cart_id: int, requested_date: str = None, **overrides) -> Dict[str, Any]:
"""Generate a service request.
Args:
cart_id: Cart ID
requested_date: ISO format date string
**overrides: Override any fields
"""
request_id = overrides.get("id", random.randint(100000, 999999))
requested_date = requested_date or (datetime.now() + timedelta(days=random.randint(1, 7))).isoformat()
data = {
"id": request_id,
"cart_id": cart_id,
"requested_date": requested_date,
"state": "PENDING",
"veterinarian": None,
"created_at": datetime.now().isoformat(),
}
data.update(overrides)
return data

View File

@@ -0,0 +1,73 @@
# Contract Tests
API contract tests organized by Django app, with optional workflow tests.
## Testing Modes
Two modes via `CONTRACT_TEST_MODE` environment variable:
| Mode | Command | Description |
|------|---------|-------------|
| **api** (default) | `pytest tests/contracts/` | Fast, Django test client, test DB |
| **live** | `CONTRACT_TEST_MODE=live pytest tests/contracts/` | Real HTTP, LiveServerTestCase, test DB |
### Mode Comparison
| | `api` (default) | `live` |
|---|---|---|
| **Base class** | `APITestCase` | `LiveServerTestCase` |
| **HTTP** | In-process (Django test client) | Real HTTP via `requests` |
| **Auth** | `force_authenticate()` | JWT tokens via API |
| **Database** | Django test DB (isolated) | Django test DB (isolated) |
| **Speed** | ~3-5 sec | ~15-30 sec |
| **Server** | None (in-process) | Auto-started by Django |
### Key Point: Both Modes Use Test Database
Neither mode touches your real database. Django automatically:
1. Creates a test database (prefixed with `test_`)
2. Runs migrations
3. Destroys it after tests complete
## File Structure
```
tests/contracts/
├── base.py # Mode switcher (imports from base_api or base_live)
├── base_api.py # APITestCase implementation
├── base_live.py # LiveServerTestCase implementation
├── conftest.py # pytest-django configuration
├── endpoints.py # API paths (single source of truth)
├── helpers.py # Shared test data helpers
├── mascotas/ # Django app: mascotas
│ ├── test_pet_owners.py
│ ├── test_pets.py
│ └── test_coverage.py
├── productos/ # Django app: productos
│ ├── test_services.py
│ └── test_cart.py
├── solicitudes/ # Django app: solicitudes
│ └── test_service_requests.py
└── workflows/ # Multi-step API sequences (e.g., turnero booking flow)
└── test_turnero_general.py
```
## Running Tests
```bash
# All contract tests
pytest tests/contracts/
# Single app
pytest tests/contracts/mascotas/
# Single file
pytest tests/contracts/mascotas/test_pet_owners.py
# Live mode (real HTTP)
CONTRACT_TEST_MODE=live pytest tests/contracts/
```

View File

@@ -0,0 +1,2 @@
# Contract tests - black-box HTTP tests that validate API contracts
# These tests are decoupled from Django and can run against any implementation

View File

@@ -0,0 +1,164 @@
"""
Pure HTTP Contract Tests - Base Class
Framework-agnostic: works against ANY backend implementation.
Does NOT manage database - expects a ready environment.
Requirements:
- Server running at CONTRACT_TEST_URL
- Database migrated and seeded
- Test user exists OR CONTRACT_TEST_TOKEN provided
Usage:
CONTRACT_TEST_URL=http://127.0.0.1:8000 pytest
CONTRACT_TEST_TOKEN=your_jwt_token pytest
"""
import os
import unittest
import httpx
from .endpoints import Endpoints
def get_base_url():
"""Get base URL from environment (required)"""
url = os.environ.get("CONTRACT_TEST_URL", "")
if not url:
raise ValueError("CONTRACT_TEST_URL environment variable required")
return url.rstrip("/")
class ContractTestCase(unittest.TestCase):
"""
Base class for pure HTTP contract tests.
Features:
- Framework-agnostic (works with Django, FastAPI, Node, etc.)
- Pure HTTP via requests library
- No database access - all data through API
- JWT authentication
"""
# Auth credentials - override via environment
TEST_USER_EMAIL = os.environ.get("CONTRACT_TEST_USER", "contract_test@example.com")
TEST_USER_PASSWORD = os.environ.get("CONTRACT_TEST_PASSWORD", "testpass123")
# Class-level cache
_base_url = None
_token = None
@classmethod
def setUpClass(cls):
"""Set up once per test class"""
super().setUpClass()
cls._base_url = get_base_url()
# Use provided token or fetch one
cls._token = os.environ.get("CONTRACT_TEST_TOKEN", "")
if not cls._token:
cls._token = cls._fetch_token()
@classmethod
def _fetch_token(cls):
"""Get JWT token for authentication"""
url = f"{cls._base_url}{Endpoints.TOKEN}"
try:
response = httpx.post(url, json={
"username": cls.TEST_USER_EMAIL,
"password": cls.TEST_USER_PASSWORD,
}, timeout=10)
if response.status_code == 200:
return response.json().get("access", "")
else:
print(f"Warning: Token request failed with {response.status_code}")
except httpx.RequestError as e:
print(f"Warning: Token request failed: {e}")
return ""
@property
def base_url(self):
return self._base_url
@property
def token(self):
return self._token
def _auth_headers(self):
"""Get authorization headers"""
if self.token:
return {"Authorization": f"Bearer {self.token}"}
return {}
# =========================================================================
# HTTP helpers
# =========================================================================
def get(self, path: str, params: dict = None, **kwargs):
"""GET request"""
url = f"{self.base_url}{path}"
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
response = httpx.get(url, params=params, headers=headers, timeout=30, **kwargs)
return self._wrap_response(response)
def post(self, path: str, data: dict = None, **kwargs):
"""POST request with JSON"""
url = f"{self.base_url}{path}"
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
response = httpx.post(url, json=data, headers=headers, timeout=30, **kwargs)
return self._wrap_response(response)
def put(self, path: str, data: dict = None, **kwargs):
"""PUT request with JSON"""
url = f"{self.base_url}{path}"
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
response = httpx.put(url, json=data, headers=headers, timeout=30, **kwargs)
return self._wrap_response(response)
def patch(self, path: str, data: dict = None, **kwargs):
"""PATCH request with JSON"""
url = f"{self.base_url}{path}"
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
response = httpx.patch(url, json=data, headers=headers, timeout=30, **kwargs)
return self._wrap_response(response)
def delete(self, path: str, **kwargs):
"""DELETE request"""
url = f"{self.base_url}{path}"
headers = {**self._auth_headers(), **kwargs.pop("headers", {})}
response = httpx.delete(url, headers=headers, timeout=30, **kwargs)
return self._wrap_response(response)
def _wrap_response(self, response):
"""Add .data attribute for consistency with DRF responses"""
try:
response.data = response.json()
except Exception:
response.data = None
return response
# =========================================================================
# Assertion helpers
# =========================================================================
def assert_status(self, response, expected_status: int):
"""Assert response has expected status code"""
self.assertEqual(
response.status_code,
expected_status,
f"Expected {expected_status}, got {response.status_code}. "
f"Response: {response.data if hasattr(response, 'data') else response.content[:500]}"
)
def assert_has_fields(self, data: dict, *fields: str):
"""Assert dictionary has all specified fields"""
missing = [f for f in fields if f not in data]
self.assertEqual(missing, [], f"Missing fields: {missing}. Got: {list(data.keys())}")
def assert_is_list(self, data, min_length: int = 0):
"""Assert data is a list with minimum length"""
self.assertIsInstance(data, list)
self.assertGreaterEqual(len(data), min_length)
__all__ = ["ContractTestCase"]

View File

@@ -0,0 +1,29 @@
"""
Contract Tests Configuration
Supports two testing modes via CONTRACT_TEST_MODE environment variable:
# Fast mode (default) - Django test client, test DB
pytest tests/contracts/
# Live mode - Real HTTP with LiveServerTestCase, test DB
CONTRACT_TEST_MODE=live pytest tests/contracts/
"""
import os
import pytest
# Let pytest-django handle Django setup via pytest.ini DJANGO_SETTINGS_MODULE
def pytest_configure(config):
"""Register custom markers"""
config.addinivalue_line(
"markers", "workflow: marks test as a workflow/flow test (runs endpoint tests in sequence)"
)
@pytest.fixture(scope="session")
def contract_test_mode():
"""Return current test mode"""
return os.environ.get("CONTRACT_TEST_MODE", "api")

View File

@@ -0,0 +1,38 @@
"""
API Endpoints - Single source of truth for contract tests.
If API paths or versioning changes, update here only.
"""
class Endpoints:
"""API endpoint paths"""
# ==========================================================================
# Mascotas
# ==========================================================================
PET_OWNERS = "/mascotas/api/v1/pet-owners/"
PET_OWNER_DETAIL = "/mascotas/api/v1/pet-owners/{id}/"
PETS = "/mascotas/api/v1/pets/"
PET_DETAIL = "/mascotas/api/v1/pets/{id}/"
COVERAGE_CHECK = "/mascotas/api/v1/coverage/check/"
# ==========================================================================
# Productos
# ==========================================================================
SERVICES = "/productos/api/v1/services/"
CATEGORIES = "/productos/api/v1/categories/"
CART = "/productos/api/v1/cart/"
CART_DETAIL = "/productos/api/v1/cart/{id}/"
# ==========================================================================
# Solicitudes
# ==========================================================================
SERVICE_REQUESTS = "/solicitudes/service-requests/"
SERVICE_REQUEST_DETAIL = "/solicitudes/service-requests/{id}/"
# ==========================================================================
# Auth
# ==========================================================================
TOKEN = "/api/token/"
TOKEN_REFRESH = "/api/token/refresh/"

View File

@@ -0,0 +1,44 @@
"""
Contract Tests - Shared test data helpers.
Used across all endpoint tests to generate consistent test data.
"""
import time
def unique_email(prefix="test"):
"""Generate unique email for test data"""
return f"{prefix}_{int(time.time() * 1000)}@contract-test.local"
def sample_pet_owner(email=None):
"""Generate sample pet owner data"""
return {
"first_name": "Test",
"last_name": "Usuario",
"email": email or unique_email("owner"),
"phone": "1155667788",
"address": "Av. Santa Fe 1234",
"geo_latitude": -34.5955,
"geo_longitude": -58.4166,
}
SAMPLE_CAT = {
"name": "TestCat",
"pet_type": "CAT",
"is_neutered": False,
}
SAMPLE_DOG = {
"name": "TestDog",
"pet_type": "DOG",
"is_neutered": False,
}
SAMPLE_NEUTERED_CAT = {
"name": "NeuteredCat",
"pet_type": "CAT",
"is_neutered": True,
}

View File

@@ -0,0 +1 @@
# Contract tests for mascotas app endpoints

View File

@@ -0,0 +1,53 @@
"""
Contract Tests: Coverage Check API
Endpoint: /mascotas/api/v1/coverage/check/
App: mascotas
Used to check if a location has veterinary coverage before proceeding with turnero.
"""
from ..base import ContractTestCase
from ..endpoints import Endpoints
class TestCoverageCheck(ContractTestCase):
"""GET /mascotas/api/v1/coverage/check/"""
def test_with_coordinates_returns_200(self):
"""Coverage check should accept lat/lng parameters"""
response = self.get(Endpoints.COVERAGE_CHECK, params={
"lat": -34.6037,
"lng": -58.3816,
})
self.assert_status(response, 200)
def test_returns_coverage_boolean(self):
"""Coverage check should return coverage boolean"""
response = self.get(Endpoints.COVERAGE_CHECK, params={
"lat": -34.6037,
"lng": -58.3816,
})
self.assert_status(response, 200)
self.assert_has_fields(response.data, "coverage")
self.assertIsInstance(response.data["coverage"], bool)
def test_returns_vet_count(self):
"""Coverage check should return number of available vets"""
response = self.get(Endpoints.COVERAGE_CHECK, params={
"lat": -34.6037,
"lng": -58.3816,
})
self.assert_status(response, 200)
self.assert_has_fields(response.data, "vet_count")
self.assertIsInstance(response.data["vet_count"], int)
def test_without_coordinates_fails(self):
"""Coverage check without coordinates should fail"""
response = self.get(Endpoints.COVERAGE_CHECK)
# Should return 400 or similar error
self.assertIn(response.status_code, [400, 422])

View File

@@ -0,0 +1,171 @@
"""
Contract Tests: Pet Owners API
Endpoint: /mascotas/api/v1/pet-owners/
App: mascotas
Related Tickets:
- VET-536: Paso 0 - Test creación del petowner invitado
- VET-535: Establecer y definir test para las apis vinculadas al procesos de solicitar turno general
Context: In the turnero general flow (guest booking), a "guest" pet owner is created
with a mock email (e.g., invitado-1759415377297@example.com). This user is fundamental
for subsequent steps as it provides the address used to filter available services.
TBD: PetOwnerViewSet needs pagination - currently loads all records on list().
See mascotas/views/api/v1/views/petowner_views.py:72
Using email filter in tests to avoid loading 14k+ records.
"""
import time
from ..base import ContractTestCase
from ..endpoints import Endpoints
from ..helpers import sample_pet_owner
class TestPetOwnerCreate(ContractTestCase):
"""POST /mascotas/api/v1/pet-owners/
VET-536: Tests for guest petowner creation (Step 0 of turnero flow)
"""
def test_create_returns_201(self):
"""
Creating a pet owner returns 201 with the created resource.
Request (from production turnero):
POST /mascotas/api/v1/pet-owners/
{
"first_name": "Juan",
"last_name": "Pérez",
"email": "invitado-1733929847293@example.com",
"phone": "1155667788",
"address": "Av. Santa Fe 1234, Buenos Aires",
"geo_latitude": -34.5955,
"geo_longitude": -58.4166
}
Response (201):
{
"id": 12345,
"first_name": "Juan",
"last_name": "Pérez",
"email": "invitado-1733929847293@example.com",
"phone": "1155667788",
"address": "Av. Santa Fe 1234, Buenos Aires",
"geo_latitude": -34.5955,
"geo_longitude": -58.4166,
"pets": [],
"created_at": "2024-12-11T15:30:47.293Z"
}
"""
data = sample_pet_owner()
response = self.post(Endpoints.PET_OWNERS, data)
self.assert_status(response, 201)
self.assert_has_fields(response.data, "id", "email", "first_name", "last_name")
self.assertEqual(response.data["email"], data["email"])
def test_requires_email(self):
"""
Pet owner creation requires email (current behavior).
Note: The turnero guest flow uses a mock email created by frontend
(e.g., invitado-1759415377297@example.com). The API always requires email.
This test ensures the contract enforcement - no petowner without email.
"""
data = {
"address": "Av. Corrientes 1234",
"first_name": "Invitado",
"last_name": str(int(time.time())),
}
response = self.post(Endpoints.PET_OWNERS, data)
self.assert_status(response, 400)
def test_duplicate_email_returns_existing(self):
"""
Creating pet owner with existing email returns the existing record.
Note: API has upsert behavior - returns 200 with existing record,
not 400 error. This allows frontend to "create or get" in one call.
Important for guest flow - if user refreshes/retries, we don't create duplicates.
"""
data = sample_pet_owner()
first_response = self.post(Endpoints.PET_OWNERS, data)
first_id = first_response.data["id"]
response = self.post(Endpoints.PET_OWNERS, data) # Same email
# Returns 200 with existing record (upsert behavior)
self.assert_status(response, 200)
self.assertEqual(response.data["id"], first_id)
def test_address_and_geolocation_persisted(self):
"""
Pet owner address and geolocation coordinates are persisted correctly.
The address is critical for the turnero flow - it's used to filter available
services by location. Geolocation (lat/lng) may be obtained from Google Maps API.
"""
data = sample_pet_owner()
response = self.post(Endpoints.PET_OWNERS, data)
self.assert_status(response, 201)
self.assert_has_fields(response.data, "address", "geo_latitude", "geo_longitude")
self.assertEqual(response.data["address"], data["address"])
# Verify geolocation fields are numeric (not null/empty)
self.assertIsNotNone(response.data.get("geo_latitude"))
self.assertIsNotNone(response.data.get("geo_longitude"))
class TestPetOwnerRetrieve(ContractTestCase):
"""GET /mascotas/api/v1/pet-owners/{id}/"""
def test_get_by_id_returns_200(self):
"""GET pet owner by ID returns owner details"""
# Create owner first
data = sample_pet_owner()
create_response = self.post(Endpoints.PET_OWNERS, data)
owner_id = create_response.data["id"]
response = self.get(Endpoints.PET_OWNER_DETAIL.format(id=owner_id))
self.assert_status(response, 200)
self.assertEqual(response.data["id"], owner_id)
self.assert_has_fields(response.data, "id", "first_name", "last_name", "address", "pets")
def test_nonexistent_returns_404(self):
"""GET non-existent owner returns 404"""
response = self.get(Endpoints.PET_OWNER_DETAIL.format(id=999999))
self.assert_status(response, 404)
class TestPetOwnerList(ContractTestCase):
"""GET /mascotas/api/v1/pet-owners/"""
def test_list_with_email_filter_returns_200(self):
"""GET pet owners filtered by email returns 200"""
# Filter by email to avoid loading 14k+ records (no pagination on this endpoint)
response = self.get(Endpoints.PET_OWNERS, params={"email": "nonexistent@test.com"})
self.assert_status(response, 200)
def test_list_filter_by_email_works(self):
"""Can filter pet owners by email"""
# Create a pet owner first
data = sample_pet_owner()
self.post(Endpoints.PET_OWNERS, data)
# Filter by that email
response = self.get(Endpoints.PET_OWNERS, params={"email": data["email"]})
self.assert_status(response, 200)
# Should find exactly one
results = response.data if isinstance(response.data, list) else response.data.get("results", [])
self.assertEqual(len(results), 1)
self.assertEqual(results[0]["email"], data["email"])

View File

@@ -0,0 +1,171 @@
"""
Contract Tests: Pets API
Endpoint: /mascotas/api/v1/pets/
App: mascotas
Related Tickets:
- VET-537: Paso 1 - Test creación de la mascota vinculada al petowner invitado
- VET-535: Establecer y definir test para las apis vinculadas al procesos de solicitar turno general
Context: In the turnero general flow (Step 1), a pet is created and linked to the guest
pet owner. The pet data (type, name, neutered status) combined with the owner's address
is used to filter available services and veterinarians.
"""
from ..base import ContractTestCase
from ..endpoints import Endpoints
from ..helpers import (
sample_pet_owner,
unique_email,
SAMPLE_CAT,
SAMPLE_DOG,
SAMPLE_NEUTERED_CAT,
)
class TestPetCreate(ContractTestCase):
"""POST /mascotas/api/v1/pets/
VET-537: Tests for pet creation linked to guest petowner (Step 1 of turnero flow)
"""
def _create_owner(self):
"""Helper to create a pet owner"""
data = sample_pet_owner(unique_email("pet_owner"))
response = self.post(Endpoints.PET_OWNERS, data)
return response.data["id"]
def test_create_cat_returns_201(self):
"""
Creating a cat returns 201 with pet_type CAT.
Request (from production turnero):
POST /mascotas/api/v1/pets/
{
"name": "Luna",
"pet_type": "CAT",
"is_neutered": false,
"owner": 12345
}
Response (201):
{
"id": 67890,
"name": "Luna",
"pet_type": "CAT",
"is_neutered": false,
"owner": 12345,
"breed": null,
"birth_date": null,
"created_at": "2024-12-11T15:31:15.123Z"
}
"""
owner_id = self._create_owner()
data = {**SAMPLE_CAT, "owner": owner_id}
response = self.post(Endpoints.PETS, data)
self.assert_status(response, 201)
self.assert_has_fields(response.data, "id", "name", "pet_type", "owner")
self.assertEqual(response.data["pet_type"], "CAT")
self.assertEqual(response.data["name"], "TestCat")
def test_create_dog_returns_201(self):
"""
Creating a dog returns 201 with pet_type DOG.
Validates that both major pet types (CAT/DOG) are supported in the contract.
"""
owner_id = self._create_owner()
data = {**SAMPLE_DOG, "owner": owner_id}
response = self.post(Endpoints.PETS, data)
self.assert_status(response, 201)
self.assertEqual(response.data["pet_type"], "DOG")
def test_neutered_status_persisted(self):
"""
Neutered status is persisted correctly.
This is important business data that may affect service recommendations
or veterinarian assignments.
"""
owner_id = self._create_owner()
data = {**SAMPLE_NEUTERED_CAT, "owner": owner_id}
response = self.post(Endpoints.PETS, data)
self.assert_status(response, 201)
self.assertTrue(response.data["is_neutered"])
def test_requires_owner(self):
"""
Pet creation without owner should fail.
Enforces the required link between pet and petowner - critical for the
turnero flow where pets must be associated with the guest user.
"""
data = SAMPLE_CAT.copy()
response = self.post(Endpoints.PETS, data)
self.assert_status(response, 400)
def test_invalid_pet_type_rejected(self):
"""
Invalid pet_type should be rejected.
Currently only CAT and DOG are supported. This test ensures the contract
validates pet types correctly.
"""
owner_id = self._create_owner()
data = {
"name": "InvalidPet",
"pet_type": "HAMSTER",
"owner": owner_id,
}
response = self.post(Endpoints.PETS, data)
self.assert_status(response, 400)
class TestPetRetrieve(ContractTestCase):
"""GET /mascotas/api/v1/pets/{id}/"""
def _create_owner_with_pet(self):
"""Helper to create owner and pet"""
owner_data = sample_pet_owner(unique_email("pet_owner"))
owner_response = self.post(Endpoints.PET_OWNERS, owner_data)
owner_id = owner_response.data["id"]
pet_data = {**SAMPLE_CAT, "owner": owner_id}
pet_response = self.post(Endpoints.PETS, pet_data)
return pet_response.data["id"]
def test_get_by_id_returns_200(self):
"""GET pet by ID returns pet details"""
pet_id = self._create_owner_with_pet()
response = self.get(Endpoints.PET_DETAIL.format(id=pet_id))
self.assert_status(response, 200)
self.assertEqual(response.data["id"], pet_id)
def test_nonexistent_returns_404(self):
"""GET non-existent pet returns 404"""
response = self.get(Endpoints.PET_DETAIL.format(id=999999))
self.assert_status(response, 404)
class TestPetList(ContractTestCase):
"""GET /mascotas/api/v1/pets/"""
def test_list_returns_200(self):
"""GET pets list returns 200 (with pagination)"""
response = self.get(Endpoints.PETS, params={"page_size": 1})
self.assert_status(response, 200)

View File

@@ -0,0 +1 @@
# Contract tests for productos app endpoints

View File

@@ -0,0 +1,149 @@
"""
Contract Tests: Cart API
Endpoint: /productos/api/v1/cart/
App: productos
Related Tickets:
- VET-538: Test creación de cart vinculado al petowner
- VET-535: Establecer y definir test para las apis vinculadas al procesos de solicitar turno general
Context: In the turnero general flow (Step 2), a cart is created for the guest petowner.
The cart holds selected services and calculates price summary (subtotals, discounts, total).
TBD: CartViewSet needs pagination/filtering - list endpoint hangs on large dataset.
See productos/api/v1/viewsets.py:93
"""
import pytest
from ..base import ContractTestCase
from ..endpoints import Endpoints
from ..helpers import sample_pet_owner, unique_email
class TestCartCreate(ContractTestCase):
"""POST /productos/api/v1/cart/
VET-538: Tests for cart creation linked to petowner (Step 2 of turnero flow)
"""
def _create_petowner(self):
"""Helper to create a pet owner"""
data = sample_pet_owner(unique_email("cart_owner"))
response = self.post(Endpoints.PET_OWNERS, data)
return response.data["id"]
def test_create_cart_for_petowner(self):
"""
Creating a cart returns 201 and links to petowner.
Request (from production turnero):
POST /productos/api/v1/cart/
{
"petowner": 12345,
"services": []
}
Response (201):
{
"id": 789,
"petowner": 12345,
"veterinarian": null,
"items": [],
"resume": [
{"concept": "SUBTOTAL", "amount": "0.00", "order": 1},
{"concept": "COSTO_SERVICIO", "amount": "0.00", "order": 2},
{"concept": "DESCUENTO", "amount": "0.00", "order": 3},
{"concept": "TOTAL", "amount": "0.00", "order": 4},
{"concept": "ADELANTO", "amount": "0.00", "order": 5}
],
"extra_details": "",
"pets": [],
"pet_reasons": []
}
"""
owner_id = self._create_petowner()
data = {
"petowner": owner_id,
"services": []
}
response = self.post(Endpoints.CART, data)
self.assert_status(response, 201)
self.assert_has_fields(response.data, "id", "petowner", "items")
self.assertEqual(response.data["petowner"], owner_id)
def test_cart_has_price_summary_fields(self):
"""
Cart response includes price summary fields.
These fields are critical for turnero flow - user needs to see:
- resume: array with price breakdown (SUBTOTAL, DESCUENTO, TOTAL, etc)
- items: cart items with individual pricing
"""
owner_id = self._create_petowner()
data = {"petowner": owner_id, "services": []}
response = self.post(Endpoints.CART, data)
self.assert_status(response, 201)
# Price fields should exist (may be 0 for empty cart)
self.assert_has_fields(response.data, "resume", "items")
def test_empty_cart_has_zero_totals(self):
"""
Empty cart (no services) has zero price totals.
Validates initial state before services are added.
"""
owner_id = self._create_petowner()
data = {"petowner": owner_id, "services": []}
response = self.post(Endpoints.CART, data)
self.assert_status(response, 201)
# Empty cart should have resume with zero amounts
self.assertIn("resume", response.data)
# Find TOTAL concept in resume
total_item = next((item for item in response.data["resume"] if item["concept"] == "TOTAL"), None)
self.assertIsNotNone(total_item)
self.assertEqual(total_item["amount"], "0.00")
class TestCartRetrieve(ContractTestCase):
"""GET /productos/api/v1/cart/{id}/"""
def _create_petowner_with_cart(self):
"""Helper to create petowner and cart"""
owner_data = sample_pet_owner(unique_email("cart_owner"))
owner_response = self.post(Endpoints.PET_OWNERS, owner_data)
owner_id = owner_response.data["id"]
cart_data = {"petowner": owner_id, "services": []}
cart_response = self.post(Endpoints.CART, cart_data)
return cart_response.data["id"]
def test_get_cart_by_id_returns_200(self):
"""GET cart by ID returns cart details"""
cart_id = self._create_petowner_with_cart()
response = self.get(Endpoints.CART_DETAIL.format(id=cart_id))
self.assert_status(response, 200)
self.assertEqual(response.data["id"], cart_id)
def test_detail_returns_404_for_nonexistent(self):
"""GET /cart/{id}/ returns 404 for non-existent cart"""
response = self.get(Endpoints.CART_DETAIL.format(id=999999))
self.assert_status(response, 404)
class TestCartList(ContractTestCase):
"""GET /productos/api/v1/cart/"""
@pytest.mark.skip(reason="TBD: Cart list hangs - needs pagination/filtering. Checking if dead code.")
def test_list_returns_200(self):
"""GET /cart/ returns 200"""
response = self.get(Endpoints.CART)
self.assert_status(response, 200)

View File

@@ -0,0 +1,112 @@
"""
Contract Tests: Categories API
Endpoint: /productos/api/v1/categories/
App: productos
Returns service categories filtered by location availability.
Categories without available services in location should be hidden.
"""
from ..base import ContractTestCase
from ..endpoints import Endpoints
class TestCategoriesList(ContractTestCase):
"""GET /productos/api/v1/categories/"""
def test_list_returns_200(self):
"""GET categories returns 200"""
response = self.get(Endpoints.CATEGORIES, params={"page_size": 10})
self.assert_status(response, 200)
def test_returns_list(self):
"""GET categories returns a list"""
response = self.get(Endpoints.CATEGORIES, params={"page_size": 10})
self.assert_status(response, 200)
data = response.data
# Handle paginated or non-paginated response
categories = data["results"] if isinstance(data, dict) and "results" in data else data
self.assertIsInstance(categories, list)
def test_categories_have_required_fields(self):
"""
Each category should have id, name, and description.
Request (from production turnero):
GET /productos/api/v1/categories/
Response (200):
[
{
"id": 1,
"name": "Consulta General",
"description": "Consultas veterinarias generales"
},
{
"id": 2,
"name": "Vacunación",
"description": "Servicios de vacunación"
}
]
"""
response = self.get(Endpoints.CATEGORIES, params={"page_size": 10})
data = response.data
categories = data["results"] if isinstance(data, dict) and "results" in data else data
if len(categories) > 0:
category = categories[0]
self.assert_has_fields(category, "id", "name", "description")
def test_only_active_categories_returned(self):
"""
Only active categories are returned in the list.
Business rule: Inactive categories should not be visible to users.
"""
response = self.get(Endpoints.CATEGORIES, params={"page_size": 50})
data = response.data
categories = data["results"] if isinstance(data, dict) and "results" in data else data
# All categories should be active (no 'active': False in response)
# This is enforced at queryset level in CategoryViewSet
self.assertIsInstance(categories, list)
class TestCategoryRetrieve(ContractTestCase):
"""GET /productos/api/v1/categories/{id}/"""
def test_get_category_by_id_returns_200(self):
"""
GET category by ID returns category details.
First fetch list to get a valid ID, then retrieve that category.
"""
# Get first category
list_response = self.get(Endpoints.CATEGORIES, params={"page_size": 1})
if list_response.status_code != 200:
self.skipTest("No categories available for testing")
data = list_response.data
categories = data["results"] if isinstance(data, dict) and "results" in data else data
if len(categories) == 0:
self.skipTest("No categories available for testing")
category_id = categories[0]["id"]
# Test detail endpoint
response = self.get(f"{Endpoints.CATEGORIES}{category_id}/")
self.assert_status(response, 200)
self.assertEqual(response.data["id"], category_id)
def test_nonexistent_category_returns_404(self):
"""GET non-existent category returns 404"""
response = self.get(f"{Endpoints.CATEGORIES}999999/")
self.assert_status(response, 404)

View File

@@ -0,0 +1,122 @@
"""
Contract Tests: Services API
Endpoint: /productos/api/v1/services/
App: productos
Returns available veterinary services filtered by pet type and location.
Critical for vet assignment automation.
"""
from ..base import ContractTestCase
from ..endpoints import Endpoints
from ..helpers import sample_pet_owner, unique_email, SAMPLE_CAT, SAMPLE_DOG
class TestServicesList(ContractTestCase):
"""GET /productos/api/v1/services/"""
def test_list_returns_200(self):
"""GET services returns 200"""
response = self.get(Endpoints.SERVICES, params={"page_size": 10})
self.assert_status(response, 200)
def test_returns_list(self):
"""GET services returns a list"""
response = self.get(Endpoints.SERVICES, params={"page_size": 10})
self.assert_status(response, 200)
data = response.data
# Handle paginated or non-paginated response
services = data["results"] if isinstance(data, dict) and "results" in data else data
self.assertIsInstance(services, list)
def test_services_have_required_fields(self):
"""Each service should have id and name"""
response = self.get(Endpoints.SERVICES, params={"page_size": 10})
data = response.data
services = data["results"] if isinstance(data, dict) and "results" in data else data
if len(services) > 0:
service = services[0]
self.assert_has_fields(service, "id", "name")
def test_accepts_pet_id_filter(self):
"""Services endpoint accepts pet_id parameter"""
response = self.get(Endpoints.SERVICES, params={"pet_id": 1})
# Should not error (even if pet doesn't exist, endpoint should handle gracefully)
self.assertIn(response.status_code, [200, 404])
class TestServicesFiltering(ContractTestCase):
"""GET /productos/api/v1/services/ with filters"""
def _create_owner_with_cat(self):
"""Helper to create owner and cat"""
owner_data = sample_pet_owner(unique_email("service_owner"))
owner_response = self.post(Endpoints.PET_OWNERS, owner_data)
owner_id = owner_response.data["id"]
pet_data = {**SAMPLE_CAT, "owner": owner_id}
pet_response = self.post(Endpoints.PETS, pet_data)
return pet_response.data["id"]
def _create_owner_with_dog(self):
"""Helper to create owner and dog"""
owner_data = sample_pet_owner(unique_email("service_owner"))
owner_response = self.post(Endpoints.PET_OWNERS, owner_data)
owner_id = owner_response.data["id"]
pet_data = {**SAMPLE_DOG, "owner": owner_id}
pet_response = self.post(Endpoints.PETS, pet_data)
return pet_response.data["id"]
def test_filter_services_by_cat(self):
"""
Services filtered by cat pet_id returns appropriate services.
Request (from production turnero):
GET /productos/api/v1/services/?pet_id=123
Response structure validates services available for CAT type.
"""
cat_id = self._create_owner_with_cat()
response = self.get(Endpoints.SERVICES, params={"pet_id": cat_id, "page_size": 10})
# Should return services or handle gracefully
self.assertIn(response.status_code, [200, 404])
if response.status_code == 200:
data = response.data
services = data["results"] if isinstance(data, dict) and "results" in data else data
self.assertIsInstance(services, list)
def test_filter_services_by_dog(self):
"""
Services filtered by dog pet_id returns appropriate services.
Different pet types may have different service availability.
"""
dog_id = self._create_owner_with_dog()
response = self.get(Endpoints.SERVICES, params={"pet_id": dog_id, "page_size": 10})
self.assertIn(response.status_code, [200, 404])
if response.status_code == 200:
data = response.data
services = data["results"] if isinstance(data, dict) and "results" in data else data
self.assertIsInstance(services, list)
def test_services_without_pet_returns_all(self):
"""
Services without pet filter returns all available services.
Used for initial service browsing before pet selection.
"""
response = self.get(Endpoints.SERVICES, params={"page_size": 10})
self.assert_status(response, 200)
data = response.data
services = data["results"] if isinstance(data, dict) and "results" in data else data
self.assertIsInstance(services, list)

View File

@@ -0,0 +1 @@
# Contract tests for solicitudes app endpoints

View File

@@ -0,0 +1,56 @@
"""
Contract Tests: Service Requests API
Endpoint: /solicitudes/service-requests/
App: solicitudes
Creates and manages service requests (appointment bookings).
"""
from ..base import ContractTestCase
from ..endpoints import Endpoints
class TestServiceRequestList(ContractTestCase):
"""GET /solicitudes/service-requests/"""
def test_list_returns_200(self):
"""GET should return list of service requests (with pagination)"""
response = self.get(Endpoints.SERVICE_REQUESTS, params={"page_size": 1})
self.assert_status(response, 200)
def test_returns_list(self):
"""GET should return a list (possibly paginated)"""
response = self.get(Endpoints.SERVICE_REQUESTS, params={"page_size": 10})
data = response.data
requests_list = data["results"] if isinstance(data, dict) and "results" in data else data
self.assertIsInstance(requests_list, list)
class TestServiceRequestFields(ContractTestCase):
"""Field validation for service requests"""
def test_has_state_field(self):
"""Service requests should have a state/status field"""
response = self.get(Endpoints.SERVICE_REQUESTS, params={"page_size": 1})
data = response.data
requests_list = data["results"] if isinstance(data, dict) and "results" in data else data
if len(requests_list) > 0:
req = requests_list[0]
has_state = "state" in req or "status" in req
self.assertTrue(has_state, "Service request should have state/status field")
class TestServiceRequestCreate(ContractTestCase):
"""POST /solicitudes/service-requests/"""
def test_create_requires_fields(self):
"""Creating service request with empty data should fail"""
response = self.post(Endpoints.SERVICE_REQUESTS, {})
# Should return 400 with validation errors
self.assert_status(response, 400)

View File

@@ -0,0 +1 @@
# Contract tests for frontend workflows (compositions of endpoint tests)

View File

@@ -0,0 +1,65 @@
"""
Workflow Test: General Turnero Flow
This is a COMPOSITION test that validates the full turnero flow
by calling endpoints in sequence. Use this to ensure the flow works
end-to-end, but individual endpoint behavior is tested in app folders.
Flow:
1. Check coverage at address
2. Create pet owner (guest with mock email)
3. Create pet for owner
4. Get available services for pet
5. Create service request
Frontend route: /turnos/
User type: Guest (invitado)
"""
from ..base import ContractTestCase
from ..endpoints import Endpoints
from ..helpers import sample_pet_owner, unique_email, SAMPLE_CAT
class TestTurneroGeneralFlow(ContractTestCase):
"""
End-to-end flow test for general turnero.
Note: This tests the SEQUENCE of calls, not individual endpoint behavior.
Individual endpoint tests are in mascotas/, productos/, solicitudes/.
"""
def test_full_flow_sequence(self):
"""
Complete turnero flow should work end-to-end.
This test validates that a guest user can complete the full
appointment booking flow.
"""
# Step 0: Check coverage at address
coverage_response = self.get(Endpoints.COVERAGE_CHECK, params={
"lat": -34.6037,
"lng": -58.3816,
})
self.assert_status(coverage_response, 200)
# Step 1: Create pet owner (frontend creates mock email for guest)
mock_email = unique_email("invitado")
owner_data = sample_pet_owner(mock_email)
owner_response = self.post(Endpoints.PET_OWNERS, owner_data)
self.assert_status(owner_response, 201)
owner_id = owner_response.data["id"]
# Step 2: Create pet for owner
pet_data = {**SAMPLE_CAT, "owner": owner_id}
pet_response = self.post(Endpoints.PETS, pet_data)
self.assert_status(pet_response, 201)
pet_id = pet_response.data["id"]
# Step 3: Get services (optionally filtered by pet)
services_response = self.get(Endpoints.SERVICES, params={"pet_id": pet_id})
# Services endpoint may return 200 even without pet filter
self.assertIn(services_response.status_code, [200, 404])
# Note: Steps 4-5 (select date/time, create service request) require
# more setup (available times, cart, etc.) and are tested separately.