major restructure
This commit is contained in:
25
cfg/amar/station/tools/databrowse/depot/scenarios.json
Normal file
25
cfg/amar/station/tools/databrowse/depot/scenarios.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
217
cfg/amar/station/tools/databrowse/depot/schema.json
Normal file
217
cfg/amar/station/tools/databrowse/depot/schema.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
178
cfg/amar/station/tools/databrowse/depot/views.json
Normal file
178
cfg/amar/station/tools/databrowse/depot/views.json
Normal 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"]
|
||||
}
|
||||
]
|
||||
}
|
||||
255
cfg/amar/station/tools/datagen/amar.py
Normal file
255
cfg/amar/station/tools/datagen/amar.py
Normal 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
|
||||
73
cfg/amar/station/tools/tester/tests/README.md
Normal file
73
cfg/amar/station/tools/tester/tests/README.md
Normal 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/
|
||||
```
|
||||
2
cfg/amar/station/tools/tester/tests/__init__.py
Normal file
2
cfg/amar/station/tools/tester/tests/__init__.py
Normal 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
|
||||
164
cfg/amar/station/tools/tester/tests/base.py
Normal file
164
cfg/amar/station/tools/tester/tests/base.py
Normal 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"]
|
||||
29
cfg/amar/station/tools/tester/tests/conftest.py
Normal file
29
cfg/amar/station/tools/tester/tests/conftest.py
Normal 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")
|
||||
38
cfg/amar/station/tools/tester/tests/endpoints.py
Normal file
38
cfg/amar/station/tools/tester/tests/endpoints.py
Normal 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/"
|
||||
44
cfg/amar/station/tools/tester/tests/helpers.py
Normal file
44
cfg/amar/station/tools/tester/tests/helpers.py
Normal 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,
|
||||
}
|
||||
1
cfg/amar/station/tools/tester/tests/mascotas/__init__.py
Normal file
1
cfg/amar/station/tools/tester/tests/mascotas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Contract tests for mascotas app endpoints
|
||||
@@ -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])
|
||||
171
cfg/amar/station/tools/tester/tests/mascotas/test_pet_owners.py
Normal file
171
cfg/amar/station/tools/tester/tests/mascotas/test_pet_owners.py
Normal 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"])
|
||||
171
cfg/amar/station/tools/tester/tests/mascotas/test_pets.py
Normal file
171
cfg/amar/station/tools/tester/tests/mascotas/test_pets.py
Normal 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)
|
||||
@@ -0,0 +1 @@
|
||||
# Contract tests for productos app endpoints
|
||||
149
cfg/amar/station/tools/tester/tests/productos/test_cart.py
Normal file
149
cfg/amar/station/tools/tester/tests/productos/test_cart.py
Normal 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)
|
||||
112
cfg/amar/station/tools/tester/tests/productos/test_categories.py
Normal file
112
cfg/amar/station/tools/tester/tests/productos/test_categories.py
Normal 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)
|
||||
122
cfg/amar/station/tools/tester/tests/productos/test_services.py
Normal file
122
cfg/amar/station/tools/tester/tests/productos/test_services.py
Normal 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)
|
||||
@@ -0,0 +1 @@
|
||||
# Contract tests for solicitudes app endpoints
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
# Contract tests for frontend workflows (compositions of endpoint tests)
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user