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

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