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:
2025-12-28 18:34:41 +00:00
parent 81eb0b8243
commit 80784ff636
5 changed files with 991 additions and 1 deletions

View File

@@ -1,7 +1,36 @@
# 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.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.types import (
ALL_EVENT_TYPES,
@@ -23,6 +52,7 @@ from animaltrack.events.types import (
PRODUCT_COLLECTED,
PRODUCT_SOLD,
)
from animaltrack.events.validation import normalize_tag, validate_ulid, validate_ulid_list
__all__ = [
# Event types
@@ -44,9 +74,44 @@ __all__ = [
"FEED_PURCHASED",
"FEED_GIVEN",
"ALL_EVENT_TYPES",
# Enums
"Sex",
"ReproStatus",
"LifeStage",
"AnimalStatus",
"Origin",
"Outcome",
# Exceptions
"ClockSkewError",
"DuplicateNonceError",
# Store
"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",
]

View 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"

View 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)

View 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

View 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"