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:
2025-12-28 18:59:24 +00:00
parent d8259f4371
commit 739b7bfe32
4 changed files with 901 additions and 0 deletions

View 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,
)