feat: add animal registry schema and models
Add database tables for animal tracking: - animal_registry: main snapshot table with all animal attributes - live_animals_by_location: denormalized view for fast roster queries - animal_aliases: merge tracking for when animals are discovered to be same Includes Pydantic models and comprehensive tests for all constraints. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
250
tests/test_models_animals.py
Normal file
250
tests/test_models_animals.py
Normal file
@@ -0,0 +1,250 @@
|
||||
# ABOUTME: Tests for animal-related Pydantic models.
|
||||
# ABOUTME: Validates Animal, LiveAnimal, and AnimalAlias models.
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from animaltrack.events.enums import AnimalStatus, LifeStage, Origin, ReproStatus, Sex
|
||||
from animaltrack.models.animals import Animal, AnimalAlias, LiveAnimal
|
||||
|
||||
|
||||
class TestAnimal:
|
||||
"""Tests for the Animal model."""
|
||||
|
||||
def test_valid_animal(self):
|
||||
"""Animal with all required fields validates successfully."""
|
||||
animal = Animal(
|
||||
animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA",
|
||||
species_code="duck",
|
||||
sex=Sex.MALE,
|
||||
repro_status=ReproStatus.INTACT,
|
||||
life_stage=LifeStage.ADULT,
|
||||
status=AnimalStatus.ALIVE,
|
||||
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
origin=Origin.HATCHED,
|
||||
first_seen_utc=1704067200000,
|
||||
last_event_utc=1704067200000,
|
||||
)
|
||||
assert animal.animal_id == "01ARZ3NDEKTSV4RRFFQ69G5FAA"
|
||||
assert animal.species_code == "duck"
|
||||
assert animal.sex == Sex.MALE
|
||||
assert animal.status == AnimalStatus.ALIVE
|
||||
|
||||
def test_animal_id_must_be_26_chars(self):
|
||||
"""Animal with ID not exactly 26 chars raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Animal(
|
||||
animal_id="short",
|
||||
species_code="duck",
|
||||
sex=Sex.MALE,
|
||||
repro_status=ReproStatus.INTACT,
|
||||
life_stage=LifeStage.ADULT,
|
||||
status=AnimalStatus.ALIVE,
|
||||
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
origin=Origin.HATCHED,
|
||||
first_seen_utc=1704067200000,
|
||||
last_event_utc=1704067200000,
|
||||
)
|
||||
assert "at least 26 characters" in str(exc_info.value)
|
||||
|
||||
def test_location_id_must_be_26_chars(self):
|
||||
"""Animal with location_id not exactly 26 chars raises ValidationError."""
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
Animal(
|
||||
animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA",
|
||||
species_code="duck",
|
||||
sex=Sex.MALE,
|
||||
repro_status=ReproStatus.INTACT,
|
||||
life_stage=LifeStage.ADULT,
|
||||
status=AnimalStatus.ALIVE,
|
||||
location_id="short",
|
||||
origin=Origin.HATCHED,
|
||||
first_seen_utc=1704067200000,
|
||||
last_event_utc=1704067200000,
|
||||
)
|
||||
assert "at least 26 characters" in str(exc_info.value)
|
||||
|
||||
def test_identified_defaults_to_false(self):
|
||||
"""Identified defaults to False."""
|
||||
animal = Animal(
|
||||
animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA",
|
||||
species_code="duck",
|
||||
sex=Sex.MALE,
|
||||
repro_status=ReproStatus.INTACT,
|
||||
life_stage=LifeStage.ADULT,
|
||||
status=AnimalStatus.ALIVE,
|
||||
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
origin=Origin.HATCHED,
|
||||
first_seen_utc=1704067200000,
|
||||
last_event_utc=1704067200000,
|
||||
)
|
||||
assert animal.identified is False
|
||||
|
||||
def test_nickname_is_optional(self):
|
||||
"""Nickname is optional and defaults to None."""
|
||||
animal = Animal(
|
||||
animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA",
|
||||
species_code="duck",
|
||||
sex=Sex.MALE,
|
||||
repro_status=ReproStatus.INTACT,
|
||||
life_stage=LifeStage.ADULT,
|
||||
status=AnimalStatus.ALIVE,
|
||||
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
origin=Origin.HATCHED,
|
||||
first_seen_utc=1704067200000,
|
||||
last_event_utc=1704067200000,
|
||||
)
|
||||
assert animal.nickname is None
|
||||
|
||||
def test_born_or_hatched_at_is_optional(self):
|
||||
"""born_or_hatched_at is optional."""
|
||||
animal = Animal(
|
||||
animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA",
|
||||
species_code="duck",
|
||||
sex=Sex.MALE,
|
||||
repro_status=ReproStatus.INTACT,
|
||||
life_stage=LifeStage.ADULT,
|
||||
status=AnimalStatus.ALIVE,
|
||||
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
origin=Origin.HATCHED,
|
||||
born_or_hatched_at=1704067200000,
|
||||
first_seen_utc=1704067200000,
|
||||
last_event_utc=1704067200000,
|
||||
)
|
||||
assert animal.born_or_hatched_at == 1704067200000
|
||||
|
||||
def test_invalid_sex_raises_error(self):
|
||||
"""Invalid sex value raises ValidationError."""
|
||||
with pytest.raises(ValidationError):
|
||||
Animal(
|
||||
animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA",
|
||||
species_code="duck",
|
||||
sex="invalid",
|
||||
repro_status=ReproStatus.INTACT,
|
||||
life_stage=LifeStage.ADULT,
|
||||
status=AnimalStatus.ALIVE,
|
||||
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
origin=Origin.HATCHED,
|
||||
first_seen_utc=1704067200000,
|
||||
last_event_utc=1704067200000,
|
||||
)
|
||||
|
||||
def test_invalid_status_raises_error(self):
|
||||
"""Invalid status value raises ValidationError."""
|
||||
with pytest.raises(ValidationError):
|
||||
Animal(
|
||||
animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA",
|
||||
species_code="duck",
|
||||
sex=Sex.MALE,
|
||||
repro_status=ReproStatus.INTACT,
|
||||
life_stage=LifeStage.ADULT,
|
||||
status="invalid",
|
||||
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
origin=Origin.HATCHED,
|
||||
first_seen_utc=1704067200000,
|
||||
last_event_utc=1704067200000,
|
||||
)
|
||||
|
||||
|
||||
class TestLiveAnimal:
|
||||
"""Tests for the LiveAnimal model."""
|
||||
|
||||
def test_valid_live_animal(self):
|
||||
"""LiveAnimal with all required fields validates successfully."""
|
||||
live_animal = LiveAnimal(
|
||||
animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA",
|
||||
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
species_code="duck",
|
||||
sex=Sex.MALE,
|
||||
repro_status=ReproStatus.INTACT,
|
||||
life_stage=LifeStage.ADULT,
|
||||
first_seen_utc=1704067200000,
|
||||
)
|
||||
assert live_animal.animal_id == "01ARZ3NDEKTSV4RRFFQ69G5FAA"
|
||||
assert live_animal.location_id == "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
|
||||
def test_tags_defaults_to_empty_list(self):
|
||||
"""Tags defaults to empty list."""
|
||||
live_animal = LiveAnimal(
|
||||
animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA",
|
||||
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
species_code="duck",
|
||||
sex=Sex.MALE,
|
||||
repro_status=ReproStatus.INTACT,
|
||||
life_stage=LifeStage.ADULT,
|
||||
first_seen_utc=1704067200000,
|
||||
)
|
||||
assert live_animal.tags == []
|
||||
|
||||
def test_tags_can_be_set(self):
|
||||
"""Tags can be set to a list of strings."""
|
||||
live_animal = LiveAnimal(
|
||||
animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA",
|
||||
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
species_code="duck",
|
||||
sex=Sex.MALE,
|
||||
repro_status=ReproStatus.INTACT,
|
||||
life_stage=LifeStage.ADULT,
|
||||
first_seen_utc=1704067200000,
|
||||
tags=["friendly", "leader"],
|
||||
)
|
||||
assert live_animal.tags == ["friendly", "leader"]
|
||||
|
||||
def test_last_move_utc_is_optional(self):
|
||||
"""last_move_utc is optional."""
|
||||
live_animal = LiveAnimal(
|
||||
animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA",
|
||||
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
species_code="duck",
|
||||
sex=Sex.MALE,
|
||||
repro_status=ReproStatus.INTACT,
|
||||
life_stage=LifeStage.ADULT,
|
||||
first_seen_utc=1704067200000,
|
||||
)
|
||||
assert live_animal.last_move_utc is None
|
||||
|
||||
def test_animal_id_must_be_26_chars(self):
|
||||
"""LiveAnimal with ID not exactly 26 chars raises ValidationError."""
|
||||
with pytest.raises(ValidationError):
|
||||
LiveAnimal(
|
||||
animal_id="short",
|
||||
location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
||||
species_code="duck",
|
||||
sex=Sex.MALE,
|
||||
repro_status=ReproStatus.INTACT,
|
||||
life_stage=LifeStage.ADULT,
|
||||
first_seen_utc=1704067200000,
|
||||
)
|
||||
|
||||
|
||||
class TestAnimalAlias:
|
||||
"""Tests for the AnimalAlias model."""
|
||||
|
||||
def test_valid_alias(self):
|
||||
"""AnimalAlias with all required fields validates successfully."""
|
||||
alias = AnimalAlias(
|
||||
alias_animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA",
|
||||
survivor_animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAB",
|
||||
merged_at_utc=1704067200000,
|
||||
)
|
||||
assert alias.alias_animal_id == "01ARZ3NDEKTSV4RRFFQ69G5FAA"
|
||||
assert alias.survivor_animal_id == "01ARZ3NDEKTSV4RRFFQ69G5FAB"
|
||||
assert alias.merged_at_utc == 1704067200000
|
||||
|
||||
def test_alias_animal_id_must_be_26_chars(self):
|
||||
"""AnimalAlias with short alias_animal_id raises ValidationError."""
|
||||
with pytest.raises(ValidationError):
|
||||
AnimalAlias(
|
||||
alias_animal_id="short",
|
||||
survivor_animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAB",
|
||||
merged_at_utc=1704067200000,
|
||||
)
|
||||
|
||||
def test_survivor_animal_id_must_be_26_chars(self):
|
||||
"""AnimalAlias with short survivor_animal_id raises ValidationError."""
|
||||
with pytest.raises(ValidationError):
|
||||
AnimalAlias(
|
||||
alias_animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAA",
|
||||
survivor_animal_id="short",
|
||||
merged_at_utc=1704067200000,
|
||||
)
|
||||
Reference in New Issue
Block a user