- Fix CSRF token handling for production: generate tokens with HMAC, set cookie via afterware, inject into HTMX requests via JS - Improve registry page: filter at top with better proportions, compact horizontal pill layout for facets - Add phonetic ID encoding (e.g., "tobi-kafu-meli") for animal display instead of truncated ULIDs - Remove "subadult" life stage (migration converts to juvenile) - Change "Death (natural)" outcome label to just "Death" - Show sex/life stage in animal picker alongside species/location 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
420 lines
14 KiB
Python
420 lines
14 KiB
Python
# 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_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"
|