spr migrated books, and tester

This commit is contained in:
buenosairesam
2025-12-31 09:07:27 -03:00
parent 21b8eab3cb
commit cccc6b5a93
136 changed files with 15763 additions and 472 deletions

156
cfg/amar/models/__init__.py Normal file
View File

@@ -0,0 +1,156 @@
"""
Pawprint Models - Platform Agnostic Definitions
Portable to: TypeScript, Pydantic, Django, SQLAlchemy, etc.
Hierarchy:
pawprint (abstract)
├── artery → Pulse = Vein + Nest + Larder
├── album → Book = Template + Larder
└── ward → Table = Tools + Nest + Larder
Shared components: Nest, Larder
System-specific: Vein (artery), Template (album), Tools (ward)
Rules:
- Larder in album generated from Template = "Book (written)"
- Same Larder exists independently in ward/artery
- Nest contains runtime configs, credentials, targets
- Larder contains data, provisions, stored content
"""
from dataclasses import dataclass, field
from typing import Optional, List
from enum import Enum
class Status(Enum):
PENDING = "pending"
PLANNED = "planned"
BUILDING = "building"
DEV = "dev"
LIVE = "live"
READY = "ready"
class System(Enum):
ARTERY = "artery"
ALBUM = "album"
WARD = "ward"
# === Shared Components ===
@dataclass
class Nest:
"""Runtime environment configuration.
Contains: credentials, targets, runtime configs.
Shared across: artery, ward
"""
name: str
status: Status = Status.PENDING
# References to actual config files/secrets
config_path: Optional[str] = None
@dataclass
class Larder:
"""Data storage / provisions.
Contains: data, transforms, parsers, dumps.
Shared across: artery, album, ward
Note: When generated from Template in album, appears as "Book (written)"
but exists as independent Larder in ward/artery.
"""
name: str
status: Status = Status.PENDING
# Optional source template (if generated)
source_template: Optional[str] = None
# Path to data
data_path: Optional[str] = None
# === System-Specific Components ===
@dataclass
class Vein:
"""Connector (artery-specific).
Single responsibility data connector.
Examples: jira, google, slack, whatsapp, cash, vnc
"""
name: str
status: Status = Status.PENDING
system: System = field(default=System.ARTERY, init=False)
@dataclass
class Template:
"""Documentation template (album-specific).
Gherkin, BDD patterns, generators.
Examples: feature-form, gherkin
"""
name: str
status: Status = Status.PENDING
system: System = field(default=System.ALBUM, init=False)
@dataclass
class Tool:
"""Execution tool (ward-specific).
Test runners, seeders, scripts.
"""
name: str
status: Status = Status.PENDING
system: System = field(default=System.WARD, init=False)
# === Composed Types ===
@dataclass
class Pulse:
"""Composed data flow (artery).
Pulse = Vein + Nest + Larder
"""
name: str
status: Status = Status.PENDING
vein: Optional[Vein] = None
nest: Optional[Nest] = None
larder: Optional[Larder] = None
system: System = field(default=System.ARTERY, init=False)
@dataclass
class Book:
"""Composed documentation (album).
Book = Template + Larder
Note: Output larder can be referenced independently in other systems.
"""
name: str
status: Status = Status.PENDING
template: Optional[Template] = None
larder: Optional[Larder] = None
# If this book produces a larder, it's tracked here
output_larder: Optional[Larder] = None
system: System = field(default=System.ALBUM, init=False)
@dataclass
class Table:
"""Composed execution bundle (ward).
Table = Tools + Nest + Larder
"""
name: str
status: Status = Status.PENDING
tools: List[Tool] = field(default_factory=list)
nest: Optional[Nest] = None
larder: Optional[Larder] = None
system: System = field(default=System.WARD, init=False)

View File

@@ -0,0 +1,191 @@
"""
Django models - Generated from schema.json
DO NOT EDIT MANUALLY - Regenerate from schema.json
"""
from django.db import models
class Status(models.TextChoices):
PENDING = "pending", "Pending"
PLANNED = "planned", "Planned"
BUILDING = "building", "Building"
DEV = "dev", "Dev"
LIVE = "live", "Live"
READY = "ready", "Ready"
class System(models.TextChoices):
ARTERY = "artery", "Artery"
ALBUM = "album", "Album"
WARD = "ward", "Ward"
# === Shared Components ===
class Nest(models.Model):
"""Runtime environment configuration. Shared across artery, ward."""
name = models.CharField(max_length=255, help_text="Unique identifier")
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
title = models.CharField(max_length=255, help_text="Display title for UI")
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="nests")
config_path = models.CharField(max_length=255, blank=True, null=True)
class Meta:
db_table = "pawprint_nest"
def __str__(self):
return self.title
class Larder(models.Model):
"""Data storage. When generated from Template = 'Book (written)'. Independent in ward/artery."""
name = models.CharField(max_length=255, help_text="Unique identifier")
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
title = models.CharField(max_length=255, help_text="Display title for UI")
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="larders")
source_template = models.CharField(max_length=255, blank=True, null=True, help_text="Template name if generated")
data_path = models.CharField(max_length=255, blank=True, null=True, help_text="Path to data files")
class Meta:
db_table = "pawprint_larder"
def __str__(self):
return self.title
# === System-Specific Components ===
class Vein(models.Model):
"""Connector (artery). Single responsibility."""
name = models.CharField(max_length=255, help_text="Unique identifier")
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
title = models.CharField(max_length=255, help_text="Display title for UI")
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="veins")
system = models.CharField(max_length=20, default="artery", editable=False)
class Meta:
db_table = "pawprint_vein"
def __str__(self):
return self.title
class Template(models.Model):
"""Documentation template (album). Gherkin, BDD patterns."""
name = models.CharField(max_length=255, help_text="Unique identifier")
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
title = models.CharField(max_length=255, help_text="Display title for UI")
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="templates")
template_path = models.CharField(max_length=255, blank=True, null=True, help_text="Path to template files")
system = models.CharField(max_length=20, default="album", editable=False)
class Meta:
db_table = "pawprint_template"
def __str__(self):
return self.title
class Tool(models.Model):
"""Execution tool (ward). Test runners, seeders."""
name = models.CharField(max_length=255, help_text="Unique identifier")
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
title = models.CharField(max_length=255, help_text="Display title for UI")
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="tools")
system = models.CharField(max_length=20, default="ward", editable=False)
class Meta:
db_table = "pawprint_tool"
def __str__(self):
return self.title
class Monitor(models.Model):
"""Service monitor (ward). Health checks, status watchers."""
name = models.CharField(max_length=255, help_text="Unique identifier")
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
title = models.CharField(max_length=255, help_text="Display title for UI")
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="monitors")
system = models.CharField(max_length=20, default="ward", editable=False)
class Meta:
db_table = "pawprint_monitor"
def __str__(self):
return self.title
class Cabinet(models.Model):
"""Tool cabinet (ward). Contains 0+ tools."""
name = models.CharField(max_length=255, help_text="Unique identifier")
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
title = models.CharField(max_length=255, help_text="Display title for UI")
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="cabinets")
tools = models.ManyToManyField(Tool, blank=True)
system = models.CharField(max_length=20, default="ward", editable=False)
class Meta:
db_table = "pawprint_cabinet"
def __str__(self):
return self.title
# === Composed Types ===
class Pulse(models.Model):
"""Composed data flow (artery). Pulse = Vein + Nest + Larder."""
name = models.CharField(max_length=255, help_text="Unique identifier")
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
title = models.CharField(max_length=255, help_text="Display title for UI")
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="pulses")
vein = models.ForeignKey(Vein, on_delete=models.SET_NULL, blank=True, null=True, related_name="pulses")
nest = models.ForeignKey(Nest, on_delete=models.SET_NULL, blank=True, null=True, related_name="pulses")
larder = models.ForeignKey(Larder, on_delete=models.SET_NULL, blank=True, null=True, related_name="pulses")
system = models.CharField(max_length=20, default="artery", editable=False)
class Meta:
db_table = "pawprint_pulse"
def __str__(self):
return self.title
class Book(models.Model):
"""Composed documentation (album). Book = Template + Larder."""
name = models.CharField(max_length=255, help_text="Unique identifier")
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
title = models.CharField(max_length=255, help_text="Display title for UI")
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="books")
template = models.ForeignKey(Template, on_delete=models.SET_NULL, blank=True, null=True, related_name="books")
larder = models.ForeignKey(Larder, on_delete=models.SET_NULL, blank=True, null=True, related_name="books")
output_larder = models.ForeignKey(Larder, on_delete=models.SET_NULL, blank=True, null=True, related_name="books")
system = models.CharField(max_length=20, default="album", editable=False)
class Meta:
db_table = "pawprint_book"
def __str__(self):
return self.title
class Table(models.Model):
"""Composed execution bundle (ward). Table = Cabinet + Nest + Larders."""
name = models.CharField(max_length=255, help_text="Unique identifier")
slug = models.SlugField(max_length=255, unique=True, help_text="URL-friendly identifier")
title = models.CharField(max_length=255, help_text="Display title for UI")
status = models.ForeignKey(Status, on_delete=models.SET_NULL, blank=True, null=True, related_name="tables")
cabinet = models.ForeignKey(Cabinet, on_delete=models.SET_NULL, blank=True, null=True, related_name="tables")
nest = models.ForeignKey(Nest, on_delete=models.SET_NULL, blank=True, null=True, related_name="tables")
larders = models.ManyToManyField(Larder, blank=True)
system = models.CharField(max_length=20, default="ward", editable=False)
class Meta:
db_table = "pawprint_table"
def __str__(self):
return self.title

View File

@@ -0,0 +1,213 @@
// Prisma schema - Generated from schema.json
//
// DO NOT EDIT MANUALLY - Regenerate from schema.json
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum Status {
PENDING
PLANNED
BUILDING
DEV
LIVE
READY
}
enum System {
ARTERY
ALBUM
WARD
}
// === Shared Components ===
/// Runtime environment configuration. Shared across artery, ward.
model Nest {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @unique
slug String @unique
title String
status Status? @relation(fields: [statusId], references: [id])
statusId Int?
config_path String?
@@map("pawprint_nest")
}
/// Data storage. When generated from Template = 'Book (written)'. Independent in ward/artery.
model Larder {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @unique
slug String @unique
title String
status Status? @relation(fields: [statusId], references: [id])
statusId Int?
source_template String?
data_path String?
@@map("pawprint_larder")
}
// === System-Specific Components ===
/// Connector (artery). Single responsibility.
model Vein {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @unique
slug String @unique
title String
status Status? @relation(fields: [statusId], references: [id])
statusId Int?
system String @default("artery")
@@map("pawprint_vein")
}
/// Documentation template (album). Gherkin, BDD patterns.
model Template {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @unique
slug String @unique
title String
status Status? @relation(fields: [statusId], references: [id])
statusId Int?
template_path String?
system String @default("album")
@@map("pawprint_template")
}
/// Execution tool (ward). Test runners, seeders.
model Tool {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @unique
slug String @unique
title String
status Status? @relation(fields: [statusId], references: [id])
statusId Int?
system String @default("ward")
@@map("pawprint_tool")
}
/// Service monitor (ward). Health checks, status watchers.
model Monitor {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @unique
slug String @unique
title String
status Status? @relation(fields: [statusId], references: [id])
statusId Int?
system String @default("ward")
@@map("pawprint_monitor")
}
/// Tool cabinet (ward). Contains 0+ tools.
model Cabinet {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @unique
slug String @unique
title String
status Status? @relation(fields: [statusId], references: [id])
statusId Int?
tools Tool[]
system String @default("ward")
@@map("pawprint_cabinet")
}
// === Composed Types ===
/// Composed data flow (artery). Pulse = Vein + Nest + Larder.
model Pulse {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @unique
slug String @unique
title String
status Status? @relation(fields: [statusId], references: [id])
statusId Int?
vein Vein? @relation(fields: [veinId], references: [id])
veinId Int?
nest Nest? @relation(fields: [nestId], references: [id])
nestId Int?
larder Larder? @relation(fields: [larderId], references: [id])
larderId Int?
system String @default("artery")
@@map("pawprint_pulse")
}
/// Composed documentation (album). Book = Template + Larder.
model Book {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @unique
slug String @unique
title String
status Status? @relation(fields: [statusId], references: [id])
statusId Int?
template Template? @relation(fields: [templateId], references: [id])
templateId Int?
larder Larder? @relation(fields: [larderId], references: [id])
larderId Int?
output_larder Larder? @relation(fields: [output_larderId], references: [id])
output_larderId Int?
system String @default("album")
@@map("pawprint_book")
}
/// Composed execution bundle (ward). Table = Cabinet + Nest + Larders.
model Table {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
name String @unique
slug String @unique
title String
status Status? @relation(fields: [statusId], references: [id])
statusId Int?
cabinet Cabinet? @relation(fields: [cabinetId], references: [id])
cabinetId Int?
nest Nest? @relation(fields: [nestId], references: [id])
nestId Int?
larders Larder[]
system String @default("ward")
@@map("pawprint_table")
}

View File

@@ -0,0 +1,187 @@
"""
Pydantic models - Generated from schema.json
DO NOT EDIT MANUALLY - Regenerate from schema.json
"""
from enum import Enum
from typing import Optional, List, Literal
from pydantic import BaseModel, Field
class Status(str, Enum):
PENDING = "pending"
PLANNED = "planned"
BUILDING = "building"
DEV = "dev"
LIVE = "live"
READY = "ready"
class System(str, Enum):
ARTERY = "artery"
ALBUM = "album"
WARD = "ward"
class ToolType(str, Enum):
APP = "app"
CLI = "cli"
# === Shared Components ===
class Nest(BaseModel):
"""Runtime environment configuration. Shared across artery, ward."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
config_path: Optional[str] = None
class Larder(BaseModel):
"""Data storage. When generated from Template = 'Book (written)'. Independent in ward/artery."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
source_template: Optional[str] = None
data_path: Optional[str] = None
# === System-Specific Components ===
class Vein(BaseModel):
"""Connector (artery). Single responsibility."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
system: Literal["artery"] = "artery"
mock: Optional[bool] = None
description: Optional[str] = None
class Template(BaseModel):
"""Documentation template (album). Gherkin, BDD patterns."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
template_path: Optional[str] = None
system: Literal["album"] = "album"
class Tool(BaseModel):
"""Execution tool (ward). Test runners, seeders."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
system: Literal["ward"] = "ward"
type: Optional[ToolType] = None
description: Optional[str] = None
path: Optional[str] = None
url: Optional[str] = None
cli: Optional[str] = None
class Monitor(BaseModel):
"""Service monitor (ward). Health checks, status watchers."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
system: Literal["ward"] = "ward"
class Cabinet(BaseModel):
"""Tool cabinet (ward). Contains 0+ tools."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
tools: List[Tool] = Field(default_factory=list)
system: Literal["ward"] = "ward"
# === Composed Types ===
class Pulse(BaseModel):
"""Composed data flow (artery). Pulse = Vein + Nest + Larder."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
vein: Optional[Vein] = None
nest: Optional[Nest] = None
larder: Optional[Larder] = None
system: Literal["artery"] = "artery"
class Book(BaseModel):
"""Composed documentation (album). Book = Template + Larder."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
template: Optional[Template] = None
larder: Optional[Larder] = None
output_larder: Optional[Larder] = None
system: Literal["album"] = "album"
class Table(BaseModel):
"""Composed execution bundle (ward). Table = Cabinet + Nest + Larders."""
name: str # Unique identifier
slug: str # URL-friendly identifier
title: str # Display title for UI
status: Optional[Status] = None
cabinet: Optional[Cabinet] = None
nest: Optional[Nest] = None
larders: List[Larder] = Field(default_factory=list)
system: Literal["ward"] = "ward"
# === Collection wrappers for JSON files ===
class NestCollection(BaseModel):
items: List[Nest] = Field(default_factory=list)
class LarderCollection(BaseModel):
items: List[Larder] = Field(default_factory=list)
class VeinCollection(BaseModel):
items: List[Vein] = Field(default_factory=list)
class TemplateCollection(BaseModel):
items: List[Template] = Field(default_factory=list)
class ToolCollection(BaseModel):
items: List[Tool] = Field(default_factory=list)
class MonitorCollection(BaseModel):
items: List[Monitor] = Field(default_factory=list)
class CabinetCollection(BaseModel):
items: List[Cabinet] = Field(default_factory=list)
class PulseCollection(BaseModel):
items: List[Pulse] = Field(default_factory=list)
class BookCollection(BaseModel):
items: List[Book] = Field(default_factory=list)
class TableCollection(BaseModel):
items: List[Table] = Field(default_factory=list)

163
cfg/amar/models/schema.json Normal file
View File

@@ -0,0 +1,163 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Pawprint Models",
"description": "Platform-agnostic model definitions. Portable to TypeScript, Pydantic, Django, Prisma.",
"definitions": {
"Status": {
"type": "string",
"enum": ["pending", "planned", "building", "dev", "live", "ready"]
},
"System": {
"type": "string",
"enum": ["artery", "album", "ward"]
},
"Nest": {
"type": "object",
"description": "Runtime environment configuration. Shared across artery, ward.",
"properties": {
"name": { "type": "string", "description": "Unique identifier" },
"slug": { "type": "string", "description": "URL-friendly identifier" },
"title": { "type": "string", "description": "Display title for UI" },
"status": { "$ref": "#/definitions/Status" },
"config_path": { "type": "string" }
},
"required": ["name", "slug", "title"]
},
"Larder": {
"type": "object",
"description": "Data storage. When generated from Template = 'Book (written)'. Independent in ward/artery.",
"properties": {
"name": { "type": "string", "description": "Unique identifier" },
"slug": { "type": "string", "description": "URL-friendly identifier" },
"title": { "type": "string", "description": "Display title for UI" },
"status": { "$ref": "#/definitions/Status" },
"source_template": { "type": "string", "description": "Template name if generated" },
"data_path": { "type": "string", "description": "Path to data files" }
},
"required": ["name", "slug", "title"]
},
"Vein": {
"type": "object",
"description": "Connector (artery). Single responsibility.",
"properties": {
"name": { "type": "string", "description": "Unique identifier" },
"slug": { "type": "string", "description": "URL-friendly identifier" },
"title": { "type": "string", "description": "Display title for UI" },
"status": { "$ref": "#/definitions/Status" },
"system": { "const": "artery" }
},
"required": ["name", "slug", "title"]
},
"Template": {
"type": "object",
"description": "Documentation template (album). Gherkin, BDD patterns.",
"properties": {
"name": { "type": "string", "description": "Unique identifier" },
"slug": { "type": "string", "description": "URL-friendly identifier" },
"title": { "type": "string", "description": "Display title for UI" },
"status": { "$ref": "#/definitions/Status" },
"template_path": { "type": "string", "description": "Path to template files" },
"system": { "const": "album" }
},
"required": ["name", "slug", "title"]
},
"ToolType": {
"type": "string",
"enum": ["app", "cli"],
"description": "Type of tool: app (web UI) or cli (command line)"
},
"Tool": {
"type": "object",
"description": "Execution tool (ward). Test runners, seeders.",
"properties": {
"name": { "type": "string", "description": "Unique identifier" },
"slug": { "type": "string", "description": "URL-friendly identifier" },
"title": { "type": "string", "description": "Display title for UI" },
"status": { "$ref": "#/definitions/Status" },
"system": { "const": "ward" },
"type": { "$ref": "#/definitions/ToolType" },
"description": { "type": "string", "description": "Human-readable description" },
"path": { "type": "string", "description": "Path to tool source" },
"url": { "type": "string", "description": "URL path for app tools" },
"cli": { "type": "string", "description": "CLI command for cli tools" }
},
"required": ["name", "slug", "title"]
},
"Monitor": {
"type": "object",
"description": "Service monitor (ward). Health checks, status watchers.",
"properties": {
"name": { "type": "string", "description": "Unique identifier" },
"slug": { "type": "string", "description": "URL-friendly identifier" },
"title": { "type": "string", "description": "Display title for UI" },
"status": { "$ref": "#/definitions/Status" },
"system": { "const": "ward" }
},
"required": ["name", "slug", "title"]
},
"Cabinet": {
"type": "object",
"description": "Tool cabinet (ward). Contains 0+ tools.",
"properties": {
"name": { "type": "string", "description": "Unique identifier" },
"slug": { "type": "string", "description": "URL-friendly identifier" },
"title": { "type": "string", "description": "Display title for UI" },
"status": { "$ref": "#/definitions/Status" },
"tools": {
"type": "array",
"items": { "$ref": "#/definitions/Tool" }
},
"system": { "const": "ward" }
},
"required": ["name", "slug", "title"]
},
"Pulse": {
"type": "object",
"description": "Composed data flow (artery). Pulse = Vein + Nest + Larder.",
"properties": {
"name": { "type": "string", "description": "Unique identifier" },
"slug": { "type": "string", "description": "URL-friendly identifier" },
"title": { "type": "string", "description": "Display title for UI" },
"status": { "$ref": "#/definitions/Status" },
"vein": { "$ref": "#/definitions/Vein" },
"nest": { "$ref": "#/definitions/Nest" },
"larder": { "$ref": "#/definitions/Larder" },
"system": { "const": "artery" }
},
"required": ["name", "slug", "title"]
},
"Book": {
"type": "object",
"description": "Composed documentation (album). Book = Template + Larder.",
"properties": {
"name": { "type": "string", "description": "Unique identifier" },
"slug": { "type": "string", "description": "URL-friendly identifier" },
"title": { "type": "string", "description": "Display title for UI" },
"status": { "$ref": "#/definitions/Status" },
"template": { "$ref": "#/definitions/Template" },
"larder": { "$ref": "#/definitions/Larder" },
"output_larder": { "$ref": "#/definitions/Larder" },
"system": { "const": "album" }
},
"required": ["name", "slug", "title"]
},
"Table": {
"type": "object",
"description": "Composed execution bundle (ward). Table = Cabinet + Nest + Larders.",
"properties": {
"name": { "type": "string", "description": "Unique identifier" },
"slug": { "type": "string", "description": "URL-friendly identifier" },
"title": { "type": "string", "description": "Display title for UI" },
"status": { "$ref": "#/definitions/Status" },
"cabinet": { "$ref": "#/definitions/Cabinet" },
"nest": { "$ref": "#/definitions/Nest" },
"larders": {
"type": "array",
"items": { "$ref": "#/definitions/Larder" }
},
"system": { "const": "ward" }
},
"required": ["name", "slug", "title"]
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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