# 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.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_zero_is_valid(self): """quantity=0 is valid (checked but found none).""" from animaltrack.events.payloads import ProductCollectedPayload payload = ProductCollectedPayload( location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", product_code="egg.duck", quantity=0, resolved_ids=["01ARZ3NDEKTSV4RRFFQ69G5FAV"], ) assert payload.quantity == 0 def test_quantity_cannot_be_negative(self): """quantity must be >= 0.""" from animaltrack.events.payloads import ProductCollectedPayload with pytest.raises(ValidationError): ProductCollectedPayload( location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", product_code="egg.duck", quantity=-1, 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"