soleprint init commit

This commit is contained in:
buenosairesam
2025-12-24 05:38:37 -03:00
commit 329c401ff5
96 changed files with 11564 additions and 0 deletions

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 @@
# Development tests - minimal tests for tester development

View File

@@ -0,0 +1,29 @@
"""
Development Test: Health Check
Minimal test to verify tester is working when backend tests aren't available.
Tests basic HTTP connectivity and authentication flow.
"""
from ..base import ContractTestCase
from ..endpoints import Endpoints
class TestHealth(ContractTestCase):
"""Basic health and connectivity tests"""
def test_can_connect_to_base_url(self):
"""Verify we can connect to the configured URL"""
# This just ensures httpx and base URL work
try:
response = self.get("/health/")
except Exception as e:
self.skipTest(f"Cannot connect to {self.base_url}: {e}")
# If we got here, connection worked
self.assertIsNotNone(response)
def test_token_authentication(self):
"""Verify token authentication is configured"""
# Just checks that we have a token (either from env or fetch)
self.assertIsNotNone(self.token, "No authentication token available")

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.