diff --git a/src/animaltrack/events/__init__.py b/src/animaltrack/events/__init__.py index d79dd54..b551df5 100644 --- a/src/animaltrack/events/__init__.py +++ b/src/animaltrack/events/__init__.py @@ -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", ] diff --git a/src/animaltrack/events/enums.py b/src/animaltrack/events/enums.py new file mode 100644 index 0000000..1a0cba4 --- /dev/null +++ b/src/animaltrack/events/enums.py @@ -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" diff --git a/src/animaltrack/events/payloads.py b/src/animaltrack/events/payloads.py new file mode 100644 index 0000000..0b947e8 --- /dev/null +++ b/src/animaltrack/events/payloads.py @@ -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) diff --git a/src/animaltrack/events/validation.py b/src/animaltrack/events/validation.py new file mode 100644 index 0000000..1ac1d61 --- /dev/null +++ b/src/animaltrack/events/validation.py @@ -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 diff --git a/tests/test_event_payloads.py b/tests/test_event_payloads.py new file mode 100644 index 0000000..be509d2 --- /dev/null +++ b/tests/test_event_payloads.py @@ -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"