feat: add event payload models with tag normalization
Implements Step 2.3 of the plan: - 17 Pydantic payload models for all event types per spec §6 - 6 enums: Sex, ReproStatus, LifeStage, AnimalStatus, Origin, Outcome - Tag normalization per spec §20 (lowercase, spaces→hyphens, max 32 chars) - ULID validation utilities - 38 tests covering payloads, enums, and tag normalization 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,36 @@
|
|||||||
# ABOUTME: Events package for event sourcing infrastructure.
|
# ABOUTME: Events package for event sourcing infrastructure.
|
||||||
# ABOUTME: Provides event types, store, and related exceptions.
|
# ABOUTME: Provides event types, store, payloads, and related exceptions.
|
||||||
|
|
||||||
|
from animaltrack.events.enums import (
|
||||||
|
AnimalStatus,
|
||||||
|
LifeStage,
|
||||||
|
Origin,
|
||||||
|
Outcome,
|
||||||
|
ReproStatus,
|
||||||
|
Sex,
|
||||||
|
)
|
||||||
from animaltrack.events.exceptions import ClockSkewError, DuplicateNonceError
|
from animaltrack.events.exceptions import ClockSkewError, DuplicateNonceError
|
||||||
|
from animaltrack.events.payloads import (
|
||||||
|
AnimalAttributesUpdatedPayload,
|
||||||
|
AnimalCohortCreatedPayload,
|
||||||
|
AnimalMergedPayload,
|
||||||
|
AnimalMovedPayload,
|
||||||
|
AnimalOutcomePayload,
|
||||||
|
AnimalPromotedPayload,
|
||||||
|
AnimalStatusCorrectedPayload,
|
||||||
|
AnimalTagEndedPayload,
|
||||||
|
AnimalTaggedPayload,
|
||||||
|
AttributeSet,
|
||||||
|
FeedGivenPayload,
|
||||||
|
FeedPurchasedPayload,
|
||||||
|
HatchRecordedPayload,
|
||||||
|
LocationArchivedPayload,
|
||||||
|
LocationCreatedPayload,
|
||||||
|
LocationRenamedPayload,
|
||||||
|
ProductCollectedPayload,
|
||||||
|
ProductSoldPayload,
|
||||||
|
YieldItem,
|
||||||
|
)
|
||||||
from animaltrack.events.store import EventStore
|
from animaltrack.events.store import EventStore
|
||||||
from animaltrack.events.types import (
|
from animaltrack.events.types import (
|
||||||
ALL_EVENT_TYPES,
|
ALL_EVENT_TYPES,
|
||||||
@@ -23,6 +52,7 @@ from animaltrack.events.types import (
|
|||||||
PRODUCT_COLLECTED,
|
PRODUCT_COLLECTED,
|
||||||
PRODUCT_SOLD,
|
PRODUCT_SOLD,
|
||||||
)
|
)
|
||||||
|
from animaltrack.events.validation import normalize_tag, validate_ulid, validate_ulid_list
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Event types
|
# Event types
|
||||||
@@ -44,9 +74,44 @@ __all__ = [
|
|||||||
"FEED_PURCHASED",
|
"FEED_PURCHASED",
|
||||||
"FEED_GIVEN",
|
"FEED_GIVEN",
|
||||||
"ALL_EVENT_TYPES",
|
"ALL_EVENT_TYPES",
|
||||||
|
# Enums
|
||||||
|
"Sex",
|
||||||
|
"ReproStatus",
|
||||||
|
"LifeStage",
|
||||||
|
"AnimalStatus",
|
||||||
|
"Origin",
|
||||||
|
"Outcome",
|
||||||
# Exceptions
|
# Exceptions
|
||||||
"ClockSkewError",
|
"ClockSkewError",
|
||||||
"DuplicateNonceError",
|
"DuplicateNonceError",
|
||||||
# Store
|
# Store
|
||||||
"EventStore",
|
"EventStore",
|
||||||
|
# Validation
|
||||||
|
"normalize_tag",
|
||||||
|
"validate_ulid",
|
||||||
|
"validate_ulid_list",
|
||||||
|
# Payloads - Location
|
||||||
|
"LocationCreatedPayload",
|
||||||
|
"LocationRenamedPayload",
|
||||||
|
"LocationArchivedPayload",
|
||||||
|
# Payloads - Animal
|
||||||
|
"AnimalCohortCreatedPayload",
|
||||||
|
"AnimalPromotedPayload",
|
||||||
|
"AnimalMovedPayload",
|
||||||
|
"AnimalAttributesUpdatedPayload",
|
||||||
|
"AnimalTaggedPayload",
|
||||||
|
"AnimalTagEndedPayload",
|
||||||
|
"HatchRecordedPayload",
|
||||||
|
"AnimalOutcomePayload",
|
||||||
|
"AnimalMergedPayload",
|
||||||
|
"AnimalStatusCorrectedPayload",
|
||||||
|
# Payloads - Product
|
||||||
|
"ProductCollectedPayload",
|
||||||
|
"ProductSoldPayload",
|
||||||
|
# Payloads - Feed
|
||||||
|
"FeedPurchasedPayload",
|
||||||
|
"FeedGivenPayload",
|
||||||
|
# Payloads - Helper models
|
||||||
|
"YieldItem",
|
||||||
|
"AttributeSet",
|
||||||
]
|
]
|
||||||
|
|||||||
59
src/animaltrack/events/enums.py
Normal file
59
src/animaltrack/events/enums.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
# ABOUTME: Enums for event payloads and animal attributes.
|
||||||
|
# ABOUTME: Defines Sex, ReproStatus, LifeStage, AnimalStatus, Origin, and Outcome.
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class Sex(str, Enum):
|
||||||
|
"""Biological sex of an animal."""
|
||||||
|
|
||||||
|
MALE = "male"
|
||||||
|
FEMALE = "female"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class ReproStatus(str, Enum):
|
||||||
|
"""Reproductive status of an animal."""
|
||||||
|
|
||||||
|
INTACT = "intact"
|
||||||
|
WETHER = "wether"
|
||||||
|
SPAYED = "spayed"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class LifeStage(str, Enum):
|
||||||
|
"""Life stage of an animal."""
|
||||||
|
|
||||||
|
HATCHLING = "hatchling"
|
||||||
|
JUVENILE = "juvenile"
|
||||||
|
SUBADULT = "subadult"
|
||||||
|
ADULT = "adult"
|
||||||
|
|
||||||
|
|
||||||
|
class AnimalStatus(str, Enum):
|
||||||
|
"""Current status of an animal."""
|
||||||
|
|
||||||
|
ALIVE = "alive"
|
||||||
|
DEAD = "dead"
|
||||||
|
HARVESTED = "harvested"
|
||||||
|
SOLD = "sold"
|
||||||
|
MERGED_INTO = "merged_into"
|
||||||
|
|
||||||
|
|
||||||
|
class Origin(str, Enum):
|
||||||
|
"""Origin of an animal (how it came to the farm)."""
|
||||||
|
|
||||||
|
HATCHED = "hatched"
|
||||||
|
PURCHASED = "purchased"
|
||||||
|
RESCUED = "rescued"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class Outcome(str, Enum):
|
||||||
|
"""Outcome type for animal lifecycle events."""
|
||||||
|
|
||||||
|
DEATH = "death"
|
||||||
|
HARVEST = "harvest"
|
||||||
|
SOLD = "sold"
|
||||||
|
PREDATOR_LOSS = "predator_loss"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
370
src/animaltrack/events/payloads.py
Normal file
370
src/animaltrack/events/payloads.py
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
# ABOUTME: Pydantic models for all 17 event payload types.
|
||||||
|
# ABOUTME: Validates event data before storage per spec §6.
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
from animaltrack.events.enums import (
|
||||||
|
AnimalStatus,
|
||||||
|
LifeStage,
|
||||||
|
Origin,
|
||||||
|
Outcome,
|
||||||
|
ReproStatus,
|
||||||
|
Sex,
|
||||||
|
)
|
||||||
|
from animaltrack.events.validation import normalize_tag, validate_ulid
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Helper Models
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class YieldItem(BaseModel):
|
||||||
|
"""A product yield from an animal outcome (harvest)."""
|
||||||
|
|
||||||
|
product_code: str
|
||||||
|
unit: Literal["piece", "kg"]
|
||||||
|
quantity: int = Field(..., ge=1)
|
||||||
|
weight_kg: float | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AttributeSet(BaseModel):
|
||||||
|
"""Set of animal attributes that can be updated."""
|
||||||
|
|
||||||
|
sex: Sex | None = None
|
||||||
|
life_stage: LifeStage | None = None
|
||||||
|
repro_status: ReproStatus | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Location Event Payloads (3)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class LocationCreatedPayload(BaseModel):
|
||||||
|
"""Payload for LocationCreated event."""
|
||||||
|
|
||||||
|
location_id: str = Field(..., min_length=26, max_length=26)
|
||||||
|
name: str
|
||||||
|
|
||||||
|
@field_validator("location_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_location_ulid(cls, v: str) -> str:
|
||||||
|
"""Validate location_id is a valid ULID."""
|
||||||
|
return validate_ulid(v)
|
||||||
|
|
||||||
|
|
||||||
|
class LocationRenamedPayload(BaseModel):
|
||||||
|
"""Payload for LocationRenamed event."""
|
||||||
|
|
||||||
|
location_id: str = Field(..., min_length=26, max_length=26)
|
||||||
|
new_name: str
|
||||||
|
|
||||||
|
@field_validator("location_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_location_ulid(cls, v: str) -> str:
|
||||||
|
"""Validate location_id is a valid ULID."""
|
||||||
|
return validate_ulid(v)
|
||||||
|
|
||||||
|
|
||||||
|
class LocationArchivedPayload(BaseModel):
|
||||||
|
"""Payload for LocationArchived event."""
|
||||||
|
|
||||||
|
location_id: str = Field(..., min_length=26, max_length=26)
|
||||||
|
|
||||||
|
@field_validator("location_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_location_ulid(cls, v: str) -> str:
|
||||||
|
"""Validate location_id is a valid ULID."""
|
||||||
|
return validate_ulid(v)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Animal Event Payloads (10)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class AnimalCohortCreatedPayload(BaseModel):
|
||||||
|
"""Payload for AnimalCohortCreated event."""
|
||||||
|
|
||||||
|
species: Literal["duck", "goose"]
|
||||||
|
count: int = Field(..., ge=1)
|
||||||
|
life_stage: LifeStage
|
||||||
|
sex: Sex = Sex.UNKNOWN
|
||||||
|
location_id: str = Field(..., min_length=26, max_length=26)
|
||||||
|
origin: Origin
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("location_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_location_ulid(cls, v: str) -> str:
|
||||||
|
"""Validate location_id is a valid ULID."""
|
||||||
|
return validate_ulid(v)
|
||||||
|
|
||||||
|
|
||||||
|
class AnimalPromotedPayload(BaseModel):
|
||||||
|
"""Payload for AnimalPromoted event."""
|
||||||
|
|
||||||
|
animal_id: str = Field(..., min_length=26, max_length=26)
|
||||||
|
nickname: str | None = None
|
||||||
|
sex: Sex | None = None
|
||||||
|
repro_status: ReproStatus | None = None
|
||||||
|
distinguishing_traits: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("animal_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_animal_ulid(cls, v: str) -> str:
|
||||||
|
"""Validate animal_id is a valid ULID."""
|
||||||
|
return validate_ulid(v)
|
||||||
|
|
||||||
|
|
||||||
|
class AnimalMovedPayload(BaseModel):
|
||||||
|
"""Payload for AnimalMoved event."""
|
||||||
|
|
||||||
|
to_location_id: str = Field(..., min_length=26, max_length=26)
|
||||||
|
resolved_ids: list[str] = Field(..., min_length=1)
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("to_location_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_location_ulid(cls, v: str) -> str:
|
||||||
|
"""Validate to_location_id is a valid ULID."""
|
||||||
|
return validate_ulid(v)
|
||||||
|
|
||||||
|
@field_validator("resolved_ids")
|
||||||
|
@classmethod
|
||||||
|
def validate_resolved_ulids(cls, v: list[str]) -> list[str]:
|
||||||
|
"""Validate all resolved_ids are valid ULIDs."""
|
||||||
|
for i, ulid in enumerate(v):
|
||||||
|
if len(ulid) != 26:
|
||||||
|
msg = f"resolved_ids[{i}] must be 26 characters"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class AnimalAttributesUpdatedPayload(BaseModel):
|
||||||
|
"""Payload for AnimalAttributesUpdated event."""
|
||||||
|
|
||||||
|
resolved_ids: list[str] = Field(..., min_length=1)
|
||||||
|
set: AttributeSet
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("resolved_ids")
|
||||||
|
@classmethod
|
||||||
|
def validate_resolved_ulids(cls, v: list[str]) -> list[str]:
|
||||||
|
"""Validate all resolved_ids are valid ULIDs."""
|
||||||
|
for i, ulid in enumerate(v):
|
||||||
|
if len(ulid) != 26:
|
||||||
|
msg = f"resolved_ids[{i}] must be 26 characters"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class AnimalTaggedPayload(BaseModel):
|
||||||
|
"""Payload for AnimalTagged event."""
|
||||||
|
|
||||||
|
resolved_ids: list[str] = Field(..., min_length=1)
|
||||||
|
tag: str
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("resolved_ids")
|
||||||
|
@classmethod
|
||||||
|
def validate_resolved_ulids(cls, v: list[str]) -> list[str]:
|
||||||
|
"""Validate all resolved_ids are valid ULIDs."""
|
||||||
|
for i, ulid in enumerate(v):
|
||||||
|
if len(ulid) != 26:
|
||||||
|
msg = f"resolved_ids[{i}] must be 26 characters"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("tag")
|
||||||
|
@classmethod
|
||||||
|
def normalize_tag_value(cls, v: str) -> str:
|
||||||
|
"""Normalize the tag per spec §20."""
|
||||||
|
return normalize_tag(v)
|
||||||
|
|
||||||
|
|
||||||
|
class AnimalTagEndedPayload(BaseModel):
|
||||||
|
"""Payload for AnimalTagEnded event."""
|
||||||
|
|
||||||
|
resolved_ids: list[str] = Field(..., min_length=1)
|
||||||
|
tag: str
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("resolved_ids")
|
||||||
|
@classmethod
|
||||||
|
def validate_resolved_ulids(cls, v: list[str]) -> list[str]:
|
||||||
|
"""Validate all resolved_ids are valid ULIDs."""
|
||||||
|
for i, ulid in enumerate(v):
|
||||||
|
if len(ulid) != 26:
|
||||||
|
msg = f"resolved_ids[{i}] must be 26 characters"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return v
|
||||||
|
|
||||||
|
@field_validator("tag")
|
||||||
|
@classmethod
|
||||||
|
def normalize_tag_value(cls, v: str) -> str:
|
||||||
|
"""Normalize the tag per spec §20."""
|
||||||
|
return normalize_tag(v)
|
||||||
|
|
||||||
|
|
||||||
|
class HatchRecordedPayload(BaseModel):
|
||||||
|
"""Payload for HatchRecorded event."""
|
||||||
|
|
||||||
|
species: Literal["duck", "goose"]
|
||||||
|
location_id: str = Field(..., min_length=26, max_length=26)
|
||||||
|
assigned_brood_location_id: str | None = None
|
||||||
|
hatched_live: int = Field(..., ge=1)
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("location_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_location_ulid(cls, v: str) -> str:
|
||||||
|
"""Validate location_id is a valid ULID."""
|
||||||
|
return validate_ulid(v)
|
||||||
|
|
||||||
|
@field_validator("assigned_brood_location_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_brood_location_ulid(cls, v: str | None) -> str | None:
|
||||||
|
"""Validate assigned_brood_location_id is a valid ULID if provided."""
|
||||||
|
if v is not None:
|
||||||
|
return validate_ulid(v)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class AnimalOutcomePayload(BaseModel):
|
||||||
|
"""Payload for AnimalOutcome event."""
|
||||||
|
|
||||||
|
outcome: Outcome
|
||||||
|
resolved_ids: list[str] = Field(..., min_length=1)
|
||||||
|
reason: str | None = None
|
||||||
|
yield_items: list[YieldItem] | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("resolved_ids")
|
||||||
|
@classmethod
|
||||||
|
def validate_resolved_ulids(cls, v: list[str]) -> list[str]:
|
||||||
|
"""Validate all resolved_ids are valid ULIDs."""
|
||||||
|
for i, ulid in enumerate(v):
|
||||||
|
if len(ulid) != 26:
|
||||||
|
msg = f"resolved_ids[{i}] must be 26 characters"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class AnimalMergedPayload(BaseModel):
|
||||||
|
"""Payload for AnimalMerged event."""
|
||||||
|
|
||||||
|
survivor_animal_id: str = Field(..., min_length=26, max_length=26)
|
||||||
|
merged_animal_ids: list[str] = Field(..., min_length=1)
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("survivor_animal_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_survivor_ulid(cls, v: str) -> str:
|
||||||
|
"""Validate survivor_animal_id is a valid ULID."""
|
||||||
|
return validate_ulid(v)
|
||||||
|
|
||||||
|
@field_validator("merged_animal_ids")
|
||||||
|
@classmethod
|
||||||
|
def validate_merged_ulids(cls, v: list[str]) -> list[str]:
|
||||||
|
"""Validate all merged_animal_ids are valid ULIDs."""
|
||||||
|
for i, ulid in enumerate(v):
|
||||||
|
if len(ulid) != 26:
|
||||||
|
msg = f"merged_animal_ids[{i}] must be 26 characters"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class AnimalStatusCorrectedPayload(BaseModel):
|
||||||
|
"""Payload for AnimalStatusCorrected event (admin-only)."""
|
||||||
|
|
||||||
|
resolved_ids: list[str] = Field(..., min_length=1)
|
||||||
|
new_status: AnimalStatus
|
||||||
|
reason: str # Required for audit trail
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("resolved_ids")
|
||||||
|
@classmethod
|
||||||
|
def validate_resolved_ulids(cls, v: list[str]) -> list[str]:
|
||||||
|
"""Validate all resolved_ids are valid ULIDs."""
|
||||||
|
for i, ulid in enumerate(v):
|
||||||
|
if len(ulid) != 26:
|
||||||
|
msg = f"resolved_ids[{i}] must be 26 characters"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Product Event Payloads (2)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ProductCollectedPayload(BaseModel):
|
||||||
|
"""Payload for ProductCollected event."""
|
||||||
|
|
||||||
|
location_id: str = Field(..., min_length=26, max_length=26)
|
||||||
|
product_code: str
|
||||||
|
quantity: int = Field(..., ge=1)
|
||||||
|
resolved_ids: list[str] = Field(..., min_length=1)
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("location_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_location_ulid(cls, v: str) -> str:
|
||||||
|
"""Validate location_id is a valid ULID."""
|
||||||
|
return validate_ulid(v)
|
||||||
|
|
||||||
|
@field_validator("resolved_ids")
|
||||||
|
@classmethod
|
||||||
|
def validate_resolved_ulids(cls, v: list[str]) -> list[str]:
|
||||||
|
"""Validate all resolved_ids are valid ULIDs."""
|
||||||
|
for i, ulid in enumerate(v):
|
||||||
|
if len(ulid) != 26:
|
||||||
|
msg = f"resolved_ids[{i}] must be 26 characters"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
class ProductSoldPayload(BaseModel):
|
||||||
|
"""Payload for ProductSold event."""
|
||||||
|
|
||||||
|
product_code: str
|
||||||
|
quantity: int = Field(..., ge=1)
|
||||||
|
total_price_cents: int = Field(..., ge=0)
|
||||||
|
buyer: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Feed Event Payloads (2)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class FeedPurchasedPayload(BaseModel):
|
||||||
|
"""Payload for FeedPurchased event."""
|
||||||
|
|
||||||
|
feed_type_code: str
|
||||||
|
bag_size_kg: int = Field(..., ge=1)
|
||||||
|
bags_count: int = Field(..., ge=1)
|
||||||
|
bag_price_cents: int = Field(..., ge=0)
|
||||||
|
vendor: str | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FeedGivenPayload(BaseModel):
|
||||||
|
"""Payload for FeedGiven event."""
|
||||||
|
|
||||||
|
location_id: str = Field(..., min_length=26, max_length=26)
|
||||||
|
feed_type_code: str
|
||||||
|
amount_kg: int = Field(..., ge=1)
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
@field_validator("location_id")
|
||||||
|
@classmethod
|
||||||
|
def validate_location_ulid(cls, v: str) -> str:
|
||||||
|
"""Validate location_id is a valid ULID."""
|
||||||
|
return validate_ulid(v)
|
||||||
76
src/animaltrack/events/validation.py
Normal file
76
src/animaltrack/events/validation.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# ABOUTME: Validation utilities for event payloads.
|
||||||
|
# ABOUTME: Includes tag normalization per spec §20 and ULID validators.
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_tag(tag: str) -> str:
|
||||||
|
"""Normalize a tag per spec §20.
|
||||||
|
|
||||||
|
Normalization rules:
|
||||||
|
- Convert to lowercase
|
||||||
|
- Trim leading/trailing whitespace
|
||||||
|
- Replace spaces with hyphens
|
||||||
|
- Keep only valid characters [a-z0-9:_-]
|
||||||
|
- Truncate to max 32 characters
|
||||||
|
- Raise ValueError if empty after normalization
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tag: The raw tag string to normalize.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The normalized tag string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the tag is empty after normalization.
|
||||||
|
"""
|
||||||
|
# Trim and lowercase
|
||||||
|
tag = tag.strip().lower()
|
||||||
|
# Spaces to hyphens
|
||||||
|
tag = tag.replace(" ", "-")
|
||||||
|
# Keep only valid chars: a-z, 0-9, colon, underscore, hyphen
|
||||||
|
tag = re.sub(r"[^a-z0-9:_-]", "", tag)
|
||||||
|
# Max 32 chars
|
||||||
|
tag = tag[:32]
|
||||||
|
# Check for empty
|
||||||
|
if not tag:
|
||||||
|
msg = "Tag is empty after normalization"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return tag
|
||||||
|
|
||||||
|
|
||||||
|
def validate_ulid(value: str) -> str:
|
||||||
|
"""Validate that a value is a 26-character ULID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The string to validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the value is not exactly 26 characters.
|
||||||
|
"""
|
||||||
|
if len(value) != 26:
|
||||||
|
msg = f"ULID must be exactly 26 characters, got {len(value)}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_ulid_list(values: list[str]) -> list[str]:
|
||||||
|
"""Validate that all values in a list are 26-character ULIDs.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
values: The list of strings to validate.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The validated list.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If any value is not exactly 26 characters.
|
||||||
|
"""
|
||||||
|
for i, value in enumerate(values):
|
||||||
|
if len(value) != 26:
|
||||||
|
msg = f"ULID at index {i} must be exactly 26 characters, got {len(value)}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return values
|
||||||
420
tests/test_event_payloads.py
Normal file
420
tests/test_event_payloads.py
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
# ABOUTME: Tests for event payload models and tag normalization.
|
||||||
|
# ABOUTME: Validates all 17 event payload types and the normalize_tag function.
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from animaltrack.events.enums import (
|
||||||
|
AnimalStatus,
|
||||||
|
LifeStage,
|
||||||
|
Origin,
|
||||||
|
Outcome,
|
||||||
|
ReproStatus,
|
||||||
|
Sex,
|
||||||
|
)
|
||||||
|
from animaltrack.events.validation import normalize_tag
|
||||||
|
|
||||||
|
|
||||||
|
class TestTagNormalization:
|
||||||
|
"""Tests for tag normalization per spec §20."""
|
||||||
|
|
||||||
|
def test_lowercase_conversion(self):
|
||||||
|
"""Tags are converted to lowercase."""
|
||||||
|
assert normalize_tag("ABC") == "abc"
|
||||||
|
assert normalize_tag("MyTag") == "mytag"
|
||||||
|
|
||||||
|
def test_trim_whitespace(self):
|
||||||
|
"""Leading and trailing whitespace is trimmed."""
|
||||||
|
assert normalize_tag(" tag ") == "tag"
|
||||||
|
assert normalize_tag("\ttag\n") == "tag"
|
||||||
|
|
||||||
|
def test_spaces_to_hyphens(self):
|
||||||
|
"""Internal spaces are converted to hyphens."""
|
||||||
|
assert normalize_tag("my tag") == "my-tag"
|
||||||
|
assert normalize_tag("my tag") == "my--tag"
|
||||||
|
|
||||||
|
def test_removes_invalid_chars(self):
|
||||||
|
"""Invalid characters are removed."""
|
||||||
|
assert normalize_tag("tag@#$!") == "tag"
|
||||||
|
assert normalize_tag("hello.world") == "helloworld"
|
||||||
|
assert normalize_tag("tag(1)") == "tag1"
|
||||||
|
|
||||||
|
def test_keeps_valid_chars(self):
|
||||||
|
"""Valid characters [a-z0-9:_-] are preserved."""
|
||||||
|
assert normalize_tag("tag:sub_item-1") == "tag:sub_item-1"
|
||||||
|
assert normalize_tag("layer:duck_2024") == "layer:duck_2024"
|
||||||
|
|
||||||
|
def test_max_length_32(self):
|
||||||
|
"""Tags are truncated to 32 characters."""
|
||||||
|
long_tag = "a" * 50
|
||||||
|
result = normalize_tag(long_tag)
|
||||||
|
assert len(result) == 32
|
||||||
|
assert result == "a" * 32
|
||||||
|
|
||||||
|
def test_empty_after_normalization_raises(self):
|
||||||
|
"""Empty tag after normalization raises ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="empty after normalization"):
|
||||||
|
normalize_tag("")
|
||||||
|
with pytest.raises(ValueError, match="empty after normalization"):
|
||||||
|
normalize_tag(" ")
|
||||||
|
with pytest.raises(ValueError, match="empty after normalization"):
|
||||||
|
normalize_tag("@#$%")
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnums:
|
||||||
|
"""Tests for event-related enums."""
|
||||||
|
|
||||||
|
def test_sex_values(self):
|
||||||
|
"""Sex enum has correct values."""
|
||||||
|
assert Sex.MALE.value == "male"
|
||||||
|
assert Sex.FEMALE.value == "female"
|
||||||
|
assert Sex.UNKNOWN.value == "unknown"
|
||||||
|
|
||||||
|
def test_repro_status_values(self):
|
||||||
|
"""ReproStatus enum has correct values."""
|
||||||
|
assert ReproStatus.INTACT.value == "intact"
|
||||||
|
assert ReproStatus.WETHER.value == "wether"
|
||||||
|
assert ReproStatus.SPAYED.value == "spayed"
|
||||||
|
assert ReproStatus.UNKNOWN.value == "unknown"
|
||||||
|
|
||||||
|
def test_life_stage_values(self):
|
||||||
|
"""LifeStage enum has correct values."""
|
||||||
|
assert LifeStage.HATCHLING.value == "hatchling"
|
||||||
|
assert LifeStage.JUVENILE.value == "juvenile"
|
||||||
|
assert LifeStage.SUBADULT.value == "subadult"
|
||||||
|
assert LifeStage.ADULT.value == "adult"
|
||||||
|
|
||||||
|
def test_animal_status_values(self):
|
||||||
|
"""AnimalStatus enum has correct values."""
|
||||||
|
assert AnimalStatus.ALIVE.value == "alive"
|
||||||
|
assert AnimalStatus.DEAD.value == "dead"
|
||||||
|
assert AnimalStatus.HARVESTED.value == "harvested"
|
||||||
|
assert AnimalStatus.SOLD.value == "sold"
|
||||||
|
assert AnimalStatus.MERGED_INTO.value == "merged_into"
|
||||||
|
|
||||||
|
def test_origin_values(self):
|
||||||
|
"""Origin enum has correct values."""
|
||||||
|
assert Origin.HATCHED.value == "hatched"
|
||||||
|
assert Origin.PURCHASED.value == "purchased"
|
||||||
|
assert Origin.RESCUED.value == "rescued"
|
||||||
|
assert Origin.UNKNOWN.value == "unknown"
|
||||||
|
|
||||||
|
def test_outcome_values(self):
|
||||||
|
"""Outcome enum has correct values."""
|
||||||
|
assert Outcome.DEATH.value == "death"
|
||||||
|
assert Outcome.HARVEST.value == "harvest"
|
||||||
|
assert Outcome.SOLD.value == "sold"
|
||||||
|
assert Outcome.PREDATOR_LOSS.value == "predator_loss"
|
||||||
|
assert Outcome.UNKNOWN.value == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# Import payloads after validation is created
|
||||||
|
# These tests will initially fail until payloads.py is implemented
|
||||||
|
|
||||||
|
|
||||||
|
class TestLocationPayloads:
|
||||||
|
"""Tests for location event payloads."""
|
||||||
|
|
||||||
|
def test_location_created_valid(self):
|
||||||
|
"""Valid LocationCreatedPayload is accepted."""
|
||||||
|
from animaltrack.events.payloads import LocationCreatedPayload
|
||||||
|
|
||||||
|
payload = LocationCreatedPayload(
|
||||||
|
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
name="Strip 1",
|
||||||
|
)
|
||||||
|
assert payload.location_id == "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||||
|
assert payload.name == "Strip 1"
|
||||||
|
|
||||||
|
def test_location_created_invalid_ulid(self):
|
||||||
|
"""LocationCreatedPayload rejects invalid ULID."""
|
||||||
|
from animaltrack.events.payloads import LocationCreatedPayload
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
LocationCreatedPayload(
|
||||||
|
location_id="short",
|
||||||
|
name="Strip 1",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_location_renamed_valid(self):
|
||||||
|
"""Valid LocationRenamedPayload is accepted."""
|
||||||
|
from animaltrack.events.payloads import LocationRenamedPayload
|
||||||
|
|
||||||
|
payload = LocationRenamedPayload(
|
||||||
|
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
new_name="Strip 1 - Renamed",
|
||||||
|
)
|
||||||
|
assert payload.new_name == "Strip 1 - Renamed"
|
||||||
|
|
||||||
|
def test_location_archived_valid(self):
|
||||||
|
"""Valid LocationArchivedPayload is accepted."""
|
||||||
|
from animaltrack.events.payloads import LocationArchivedPayload
|
||||||
|
|
||||||
|
payload = LocationArchivedPayload(
|
||||||
|
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
)
|
||||||
|
assert payload.location_id == "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnimalCohortPayload:
|
||||||
|
"""Tests for AnimalCohortCreatedPayload."""
|
||||||
|
|
||||||
|
def test_valid_cohort(self):
|
||||||
|
"""Valid AnimalCohortCreatedPayload is accepted."""
|
||||||
|
from animaltrack.events.payloads import AnimalCohortCreatedPayload
|
||||||
|
|
||||||
|
payload = AnimalCohortCreatedPayload(
|
||||||
|
species="duck",
|
||||||
|
count=10,
|
||||||
|
life_stage=LifeStage.ADULT,
|
||||||
|
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
origin=Origin.PURCHASED,
|
||||||
|
)
|
||||||
|
assert payload.count == 10
|
||||||
|
assert payload.sex == Sex.UNKNOWN # Default
|
||||||
|
|
||||||
|
def test_count_must_be_positive(self):
|
||||||
|
"""count must be >= 1."""
|
||||||
|
from animaltrack.events.payloads import AnimalCohortCreatedPayload
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AnimalCohortCreatedPayload(
|
||||||
|
species="duck",
|
||||||
|
count=0,
|
||||||
|
life_stage=LifeStage.ADULT,
|
||||||
|
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
origin=Origin.PURCHASED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_species_rejected(self):
|
||||||
|
"""Invalid species value is rejected."""
|
||||||
|
from animaltrack.events.payloads import AnimalCohortCreatedPayload
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AnimalCohortCreatedPayload(
|
||||||
|
species="cow", # Not duck or goose
|
||||||
|
count=10,
|
||||||
|
life_stage=LifeStage.ADULT,
|
||||||
|
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
origin=Origin.PURCHASED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sex_defaults_to_unknown(self):
|
||||||
|
"""sex defaults to unknown when not provided."""
|
||||||
|
from animaltrack.events.payloads import AnimalCohortCreatedPayload
|
||||||
|
|
||||||
|
payload = AnimalCohortCreatedPayload(
|
||||||
|
species="goose",
|
||||||
|
count=5,
|
||||||
|
life_stage=LifeStage.JUVENILE,
|
||||||
|
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
origin=Origin.HATCHED,
|
||||||
|
)
|
||||||
|
assert payload.sex == Sex.UNKNOWN
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnimalMovedPayload:
|
||||||
|
"""Tests for AnimalMovedPayload."""
|
||||||
|
|
||||||
|
def test_valid_move(self):
|
||||||
|
"""Valid AnimalMovedPayload is accepted."""
|
||||||
|
from animaltrack.events.payloads import AnimalMovedPayload
|
||||||
|
|
||||||
|
payload = AnimalMovedPayload(
|
||||||
|
to_location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
resolved_ids=["01ARZ3NDEKTSV4RRFFQ69G5FAV", "01ARZ3NDEKTSV4RRFFQ69G5FAW"],
|
||||||
|
)
|
||||||
|
assert len(payload.resolved_ids) == 2
|
||||||
|
|
||||||
|
def test_empty_resolved_ids_rejected(self):
|
||||||
|
"""resolved_ids must not be empty."""
|
||||||
|
from animaltrack.events.payloads import AnimalMovedPayload
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AnimalMovedPayload(
|
||||||
|
to_location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
resolved_ids=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invalid_location_rejected(self):
|
||||||
|
"""Invalid location ULID is rejected."""
|
||||||
|
from animaltrack.events.payloads import AnimalMovedPayload
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AnimalMovedPayload(
|
||||||
|
to_location_id="short",
|
||||||
|
resolved_ids=["01ARZ3NDEKTSV4RRFFQ69G5FAV"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnimalTaggedPayload:
|
||||||
|
"""Tests for AnimalTaggedPayload."""
|
||||||
|
|
||||||
|
def test_valid_tag(self):
|
||||||
|
"""Valid AnimalTaggedPayload is accepted."""
|
||||||
|
from animaltrack.events.payloads import AnimalTaggedPayload
|
||||||
|
|
||||||
|
payload = AnimalTaggedPayload(
|
||||||
|
resolved_ids=["01ARZ3NDEKTSV4RRFFQ69G5FAV"],
|
||||||
|
tag="layer:prime",
|
||||||
|
)
|
||||||
|
assert payload.tag == "layer:prime"
|
||||||
|
|
||||||
|
def test_tag_normalized_on_validation(self):
|
||||||
|
"""Tag is normalized during validation."""
|
||||||
|
from animaltrack.events.payloads import AnimalTaggedPayload
|
||||||
|
|
||||||
|
payload = AnimalTaggedPayload(
|
||||||
|
resolved_ids=["01ARZ3NDEKTSV4RRFFQ69G5FAV"],
|
||||||
|
tag=" Layer Prime ",
|
||||||
|
)
|
||||||
|
assert payload.tag == "layer-prime"
|
||||||
|
|
||||||
|
|
||||||
|
class TestProductPayloads:
|
||||||
|
"""Tests for product event payloads."""
|
||||||
|
|
||||||
|
def test_product_collected_valid(self):
|
||||||
|
"""Valid ProductCollectedPayload is accepted."""
|
||||||
|
from animaltrack.events.payloads import ProductCollectedPayload
|
||||||
|
|
||||||
|
payload = ProductCollectedPayload(
|
||||||
|
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
product_code="egg.duck",
|
||||||
|
quantity=12,
|
||||||
|
resolved_ids=["01ARZ3NDEKTSV4RRFFQ69G5FAV"],
|
||||||
|
)
|
||||||
|
assert payload.quantity == 12
|
||||||
|
|
||||||
|
def test_quantity_must_be_positive(self):
|
||||||
|
"""quantity must be >= 1."""
|
||||||
|
from animaltrack.events.payloads import ProductCollectedPayload
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ProductCollectedPayload(
|
||||||
|
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
product_code="egg.duck",
|
||||||
|
quantity=0,
|
||||||
|
resolved_ids=["01ARZ3NDEKTSV4RRFFQ69G5FAV"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_product_sold_valid(self):
|
||||||
|
"""Valid ProductSoldPayload is accepted."""
|
||||||
|
from animaltrack.events.payloads import ProductSoldPayload
|
||||||
|
|
||||||
|
payload = ProductSoldPayload(
|
||||||
|
product_code="egg.duck",
|
||||||
|
quantity=30,
|
||||||
|
total_price_cents=1500, # €15.00
|
||||||
|
)
|
||||||
|
assert payload.total_price_cents == 1500
|
||||||
|
|
||||||
|
def test_price_cents_non_negative(self):
|
||||||
|
"""total_price_cents must be >= 0."""
|
||||||
|
from animaltrack.events.payloads import ProductSoldPayload
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
ProductSoldPayload(
|
||||||
|
product_code="egg.duck",
|
||||||
|
quantity=30,
|
||||||
|
total_price_cents=-100,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFeedPayloads:
|
||||||
|
"""Tests for feed event payloads."""
|
||||||
|
|
||||||
|
def test_feed_purchased_valid(self):
|
||||||
|
"""Valid FeedPurchasedPayload is accepted."""
|
||||||
|
from animaltrack.events.payloads import FeedPurchasedPayload
|
||||||
|
|
||||||
|
payload = FeedPurchasedPayload(
|
||||||
|
feed_type_code="layer",
|
||||||
|
bag_size_kg=20,
|
||||||
|
bags_count=5,
|
||||||
|
bag_price_cents=2500, # €25.00 per bag
|
||||||
|
)
|
||||||
|
assert payload.bags_count == 5
|
||||||
|
|
||||||
|
def test_feed_given_valid(self):
|
||||||
|
"""Valid FeedGivenPayload is accepted."""
|
||||||
|
from animaltrack.events.payloads import FeedGivenPayload
|
||||||
|
|
||||||
|
payload = FeedGivenPayload(
|
||||||
|
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
feed_type_code="layer",
|
||||||
|
amount_kg=2,
|
||||||
|
)
|
||||||
|
assert payload.amount_kg == 2
|
||||||
|
|
||||||
|
def test_amount_kg_must_be_positive(self):
|
||||||
|
"""amount_kg must be >= 1."""
|
||||||
|
from animaltrack.events.payloads import FeedGivenPayload
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
FeedGivenPayload(
|
||||||
|
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||||
|
feed_type_code="layer",
|
||||||
|
amount_kg=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnimalOutcomePayload:
|
||||||
|
"""Tests for AnimalOutcomePayload."""
|
||||||
|
|
||||||
|
def test_valid_outcome(self):
|
||||||
|
"""Valid AnimalOutcomePayload is accepted."""
|
||||||
|
from animaltrack.events.payloads import AnimalOutcomePayload
|
||||||
|
|
||||||
|
payload = AnimalOutcomePayload(
|
||||||
|
outcome=Outcome.HARVEST,
|
||||||
|
resolved_ids=["01ARZ3NDEKTSV4RRFFQ69G5FAV"],
|
||||||
|
)
|
||||||
|
assert payload.outcome == Outcome.HARVEST
|
||||||
|
|
||||||
|
def test_yield_items_optional(self):
|
||||||
|
"""yield_items is optional."""
|
||||||
|
from animaltrack.events.payloads import AnimalOutcomePayload
|
||||||
|
|
||||||
|
payload = AnimalOutcomePayload(
|
||||||
|
outcome=Outcome.DEATH,
|
||||||
|
resolved_ids=["01ARZ3NDEKTSV4RRFFQ69G5FAV"],
|
||||||
|
reason="Natural causes",
|
||||||
|
)
|
||||||
|
assert payload.yield_items is None
|
||||||
|
|
||||||
|
def test_invalid_outcome_rejected(self):
|
||||||
|
"""Invalid outcome value is rejected."""
|
||||||
|
from animaltrack.events.payloads import AnimalOutcomePayload
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AnimalOutcomePayload(
|
||||||
|
outcome="invalid",
|
||||||
|
resolved_ids=["01ARZ3NDEKTSV4RRFFQ69G5FAV"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnimalStatusCorrectedPayload:
|
||||||
|
"""Tests for AnimalStatusCorrectedPayload."""
|
||||||
|
|
||||||
|
def test_reason_required(self):
|
||||||
|
"""reason is required for status corrections."""
|
||||||
|
from animaltrack.events.payloads import AnimalStatusCorrectedPayload
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
AnimalStatusCorrectedPayload(
|
||||||
|
resolved_ids=["01ARZ3NDEKTSV4RRFFQ69G5FAV"],
|
||||||
|
new_status=AnimalStatus.ALIVE,
|
||||||
|
# Missing reason
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_valid_status_correction(self):
|
||||||
|
"""Valid AnimalStatusCorrectedPayload is accepted."""
|
||||||
|
from animaltrack.events.payloads import AnimalStatusCorrectedPayload
|
||||||
|
|
||||||
|
payload = AnimalStatusCorrectedPayload(
|
||||||
|
resolved_ids=["01ARZ3NDEKTSV4RRFFQ69G5FAV"],
|
||||||
|
new_status=AnimalStatus.ALIVE,
|
||||||
|
reason="Animal was incorrectly marked as dead",
|
||||||
|
)
|
||||||
|
assert payload.reason == "Animal was incorrectly marked as dead"
|
||||||
Reference in New Issue
Block a user