major restructure
This commit is contained in:
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