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:
532
tests/test_migration_animal_registry.py
Normal file
532
tests/test_migration_animal_registry.py
Normal file
@@ -0,0 +1,532 @@
|
||||
# ABOUTME: Tests for the animal registry migration (0003-animal-registry-schema.sql).
|
||||
# ABOUTME: Validates tables, constraints, and indexes for animal tracking.
|
||||
|
||||
import apsw
|
||||
import pytest
|
||||
|
||||
|
||||
# Helper to insert prerequisite data for FK tests
|
||||
def _insert_species(db, code="duck"):
|
||||
"""Insert a species for FK testing."""
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO species (code, name, active, created_at_utc, updated_at_utc)
|
||||
VALUES (?, 'Test Species', 1, 1704067200000, 1704067200000)
|
||||
""",
|
||||
(code,),
|
||||
)
|
||||
|
||||
|
||||
def _insert_location(db, location_id="01ARZ3NDEKTSV4RRFFQ69G5FAV"):
|
||||
"""Insert a location for FK testing."""
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO locations (id, name, active, created_at_utc, updated_at_utc)
|
||||
VALUES (?, 'Test Location', 1, 1704067200000, 1704067200000)
|
||||
""",
|
||||
(location_id,),
|
||||
)
|
||||
|
||||
|
||||
class TestMigrationCreatesAllTables:
|
||||
"""Tests that migration creates all animal tracking tables."""
|
||||
|
||||
def test_animal_registry_table_exists(self, migrated_db):
|
||||
"""Migration creates animal_registry table."""
|
||||
result = migrated_db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='animal_registry'"
|
||||
).fetchone()
|
||||
assert result is not None
|
||||
assert result[0] == "animal_registry"
|
||||
|
||||
def test_live_animals_by_location_table_exists(self, migrated_db):
|
||||
"""Migration creates live_animals_by_location table."""
|
||||
result = migrated_db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='live_animals_by_location'"
|
||||
).fetchone()
|
||||
assert result is not None
|
||||
assert result[0] == "live_animals_by_location"
|
||||
|
||||
def test_animal_aliases_table_exists(self, migrated_db):
|
||||
"""Migration creates animal_aliases table."""
|
||||
result = migrated_db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='animal_aliases'"
|
||||
).fetchone()
|
||||
assert result is not None
|
||||
assert result[0] == "animal_aliases"
|
||||
|
||||
|
||||
class TestAnimalRegistryTable:
|
||||
"""Tests for animal_registry table schema and constraints."""
|
||||
|
||||
def test_insert_valid_animal(self, migrated_db):
|
||||
"""Can insert valid animal data."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, identified, nickname, sex, repro_status,
|
||||
life_stage, status, location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAA", # animal_id
|
||||
"duck", # species_code
|
||||
1, # identified
|
||||
"Daffy", # nickname
|
||||
"male", # sex
|
||||
"intact", # repro_status
|
||||
"adult", # life_stage
|
||||
"alive", # status
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAV", # location_id
|
||||
"hatched", # origin
|
||||
1704067200000, # first_seen_utc
|
||||
1704067200000, # last_event_utc
|
||||
),
|
||||
)
|
||||
|
||||
result = migrated_db.execute(
|
||||
"SELECT animal_id, nickname, sex, status FROM animal_registry"
|
||||
).fetchone()
|
||||
assert result == ("01ARZ3NDEKTSV4RRFFQ69G5FAA", "Daffy", "male", "alive")
|
||||
|
||||
def test_animal_id_length_check(self, migrated_db):
|
||||
"""Animal ID must be exactly 26 characters."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
with pytest.raises(apsw.ConstraintError):
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, sex, repro_status, life_stage, status,
|
||||
location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES ('short', 'duck', 'male', 'intact', 'adult', 'alive',
|
||||
'01ARZ3NDEKTSV4RRFFQ69G5FAV', 'hatched', 1704067200000, 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
def test_sex_check_constraint(self, migrated_db):
|
||||
"""Sex must be male, female, or unknown."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
with pytest.raises(apsw.ConstraintError):
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, sex, repro_status, life_stage, status,
|
||||
location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'duck', 'invalid', 'intact', 'adult', 'alive',
|
||||
'01ARZ3NDEKTSV4RRFFQ69G5FAV', 'hatched', 1704067200000, 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
def test_repro_status_check_constraint(self, migrated_db):
|
||||
"""Repro status must be intact, wether, spayed, or unknown."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
with pytest.raises(apsw.ConstraintError):
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, sex, repro_status, life_stage, status,
|
||||
location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'duck', 'male', 'invalid', 'adult', 'alive',
|
||||
'01ARZ3NDEKTSV4RRFFQ69G5FAV', 'hatched', 1704067200000, 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
def test_life_stage_check_constraint(self, migrated_db):
|
||||
"""Life stage must be hatchling, juvenile, subadult, or adult."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
with pytest.raises(apsw.ConstraintError):
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, sex, repro_status, life_stage, status,
|
||||
location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'duck', 'male', 'intact', 'invalid', 'alive',
|
||||
'01ARZ3NDEKTSV4RRFFQ69G5FAV', 'hatched', 1704067200000, 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
def test_status_check_constraint(self, migrated_db):
|
||||
"""Status must be alive, dead, harvested, sold, or merged_into."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
with pytest.raises(apsw.ConstraintError):
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, sex, repro_status, life_stage, status,
|
||||
location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'duck', 'male', 'intact', 'adult', 'invalid',
|
||||
'01ARZ3NDEKTSV4RRFFQ69G5FAV', 'hatched', 1704067200000, 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
def test_origin_check_constraint(self, migrated_db):
|
||||
"""Origin must be hatched, purchased, rescued, or unknown."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
with pytest.raises(apsw.ConstraintError):
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, sex, repro_status, life_stage, status,
|
||||
location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'duck', 'male', 'intact', 'adult', 'alive',
|
||||
'01ARZ3NDEKTSV4RRFFQ69G5FAV', 'invalid', 1704067200000, 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
def test_species_fk_constraint(self, migrated_db):
|
||||
"""Species code must reference existing species."""
|
||||
_insert_location(migrated_db)
|
||||
# Do NOT insert species
|
||||
|
||||
with pytest.raises(apsw.ConstraintError):
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, sex, repro_status, life_stage, status,
|
||||
location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'nonexistent', 'male', 'intact', 'adult', 'alive',
|
||||
'01ARZ3NDEKTSV4RRFFQ69G5FAV', 'hatched', 1704067200000, 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
def test_location_fk_constraint(self, migrated_db):
|
||||
"""Location ID must reference existing location."""
|
||||
_insert_species(migrated_db)
|
||||
# Do NOT insert location
|
||||
|
||||
with pytest.raises(apsw.ConstraintError):
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, sex, repro_status, life_stage, status,
|
||||
location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'duck', 'male', 'intact', 'adult', 'alive',
|
||||
'01ARZ3NDEKTSV4RRFFQ69G5FAV', 'hatched', 1704067200000, 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
def test_identified_defaults_to_0(self, migrated_db):
|
||||
"""Identified defaults to 0."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, sex, repro_status, life_stage, status,
|
||||
location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'duck', 'male', 'intact', 'adult', 'alive',
|
||||
'01ARZ3NDEKTSV4RRFFQ69G5FAV', 'hatched', 1704067200000, 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
result = migrated_db.execute(
|
||||
"SELECT identified FROM animal_registry WHERE animal_id='01ARZ3NDEKTSV4RRFFQ69G5FAA'"
|
||||
).fetchone()
|
||||
assert result[0] == 0
|
||||
|
||||
|
||||
class TestUniqueNicknameIndex:
|
||||
"""Tests for the unique nickname constraint on active animals."""
|
||||
|
||||
def test_null_nickname_allowed_multiple_times(self, migrated_db):
|
||||
"""Multiple active animals can have null nickname."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
# First animal with null nickname
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, sex, repro_status, life_stage, status,
|
||||
location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'duck', 'male', 'intact', 'adult', 'alive',
|
||||
'01ARZ3NDEKTSV4RRFFQ69G5FAV', 'hatched', 1704067200000, 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
# Second animal with null nickname - should succeed
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, sex, repro_status, life_stage, status,
|
||||
location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAB', 'duck', 'female', 'intact', 'adult', 'alive',
|
||||
'01ARZ3NDEKTSV4RRFFQ69G5FAV', 'hatched', 1704067200000, 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
count = migrated_db.execute(
|
||||
"SELECT COUNT(*) FROM animal_registry WHERE nickname IS NULL"
|
||||
).fetchone()[0]
|
||||
assert count == 2
|
||||
|
||||
def test_duplicate_nickname_rejected_for_active_animals(self, migrated_db):
|
||||
"""Two active animals cannot have the same nickname."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
# First animal with nickname
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, nickname, sex, repro_status, life_stage, status,
|
||||
location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'duck', 'Daffy', 'male', 'intact', 'adult', 'alive',
|
||||
'01ARZ3NDEKTSV4RRFFQ69G5FAV', 'hatched', 1704067200000, 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
# Second active animal with same nickname - should fail
|
||||
with pytest.raises(apsw.ConstraintError):
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, nickname, sex, repro_status, life_stage, status,
|
||||
location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAB', 'duck', 'Daffy', 'female', 'intact', 'adult', 'alive',
|
||||
'01ARZ3NDEKTSV4RRFFQ69G5FAV', 'hatched', 1704067200000, 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
def test_dead_animal_nickname_can_be_reused(self, migrated_db):
|
||||
"""A dead animal's nickname can be reused by a new active animal."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
# First animal with nickname - dead
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, nickname, sex, repro_status, life_stage, status,
|
||||
location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'duck', 'Daffy', 'male', 'intact', 'adult', 'dead',
|
||||
'01ARZ3NDEKTSV4RRFFQ69G5FAV', 'hatched', 1704067200000, 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
# Second active animal with same nickname - should succeed
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_registry
|
||||
(animal_id, species_code, nickname, sex, repro_status, life_stage, status,
|
||||
location_id, origin, first_seen_utc, last_event_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAB', 'duck', 'Daffy', 'female', 'intact', 'adult', 'alive',
|
||||
'01ARZ3NDEKTSV4RRFFQ69G5FAV', 'hatched', 1704067200000, 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
count = migrated_db.execute(
|
||||
"SELECT COUNT(*) FROM animal_registry WHERE nickname='Daffy'"
|
||||
).fetchone()[0]
|
||||
assert count == 2
|
||||
|
||||
|
||||
class TestLiveAnimalsByLocationTable:
|
||||
"""Tests for live_animals_by_location table schema and constraints."""
|
||||
|
||||
def test_insert_valid_live_animal(self, migrated_db):
|
||||
"""Can insert valid live animal data."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO live_animals_by_location
|
||||
(animal_id, location_id, species_code, identified, nickname, sex, repro_status,
|
||||
life_stage, first_seen_utc, last_move_utc, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAA", # animal_id
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAV", # location_id
|
||||
"duck", # species_code
|
||||
1, # identified
|
||||
"Daffy", # nickname
|
||||
"male", # sex
|
||||
"intact", # repro_status
|
||||
"adult", # life_stage
|
||||
1704067200000, # first_seen_utc
|
||||
1704067200000, # last_move_utc
|
||||
'["friendly", "leader"]', # tags
|
||||
),
|
||||
)
|
||||
|
||||
result = migrated_db.execute(
|
||||
"SELECT animal_id, nickname, tags FROM live_animals_by_location"
|
||||
).fetchone()
|
||||
assert result == ("01ARZ3NDEKTSV4RRFFQ69G5FAA", "Daffy", '["friendly", "leader"]')
|
||||
|
||||
def test_animal_id_length_check(self, migrated_db):
|
||||
"""Animal ID must be exactly 26 characters."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
with pytest.raises(apsw.ConstraintError):
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO live_animals_by_location
|
||||
(animal_id, location_id, species_code, sex, repro_status, life_stage, first_seen_utc)
|
||||
VALUES ('short', '01ARZ3NDEKTSV4RRFFQ69G5FAV', 'duck', 'male', 'intact', 'adult', 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
def test_tags_must_be_valid_json(self, migrated_db):
|
||||
"""Tags must be valid JSON."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
with pytest.raises(apsw.ConstraintError):
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO live_animals_by_location
|
||||
(animal_id, location_id, species_code, sex, repro_status, life_stage, first_seen_utc, tags)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
||||
'duck', 'male', 'intact', 'adult', 1704067200000, 'not valid json')
|
||||
"""
|
||||
)
|
||||
|
||||
def test_tags_defaults_to_empty_array(self, migrated_db):
|
||||
"""Tags defaults to empty JSON array."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO live_animals_by_location
|
||||
(animal_id, location_id, species_code, sex, repro_status, life_stage, first_seen_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
||||
'duck', 'male', 'intact', 'adult', 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
result = migrated_db.execute(
|
||||
"SELECT tags FROM live_animals_by_location WHERE animal_id='01ARZ3NDEKTSV4RRFFQ69G5FAA'"
|
||||
).fetchone()
|
||||
assert result[0] == "[]"
|
||||
|
||||
def test_sex_check_constraint(self, migrated_db):
|
||||
"""Sex must be male, female, or unknown."""
|
||||
_insert_species(migrated_db)
|
||||
_insert_location(migrated_db)
|
||||
|
||||
with pytest.raises(apsw.ConstraintError):
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO live_animals_by_location
|
||||
(animal_id, location_id, species_code, sex, repro_status, life_stage, first_seen_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', '01ARZ3NDEKTSV4RRFFQ69G5FAV',
|
||||
'duck', 'invalid', 'intact', 'adult', 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
class TestAnimalAliasesTable:
|
||||
"""Tests for animal_aliases table schema and constraints."""
|
||||
|
||||
def test_insert_valid_alias(self, migrated_db):
|
||||
"""Can insert valid alias data."""
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_aliases (alias_animal_id, survivor_animal_id, merged_at_utc)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAA", # alias (merged away)
|
||||
"01ARZ3NDEKTSV4RRFFQ69G5FAB", # survivor
|
||||
1704067200000,
|
||||
),
|
||||
)
|
||||
|
||||
result = migrated_db.execute(
|
||||
"SELECT alias_animal_id, survivor_animal_id FROM animal_aliases"
|
||||
).fetchone()
|
||||
assert result == ("01ARZ3NDEKTSV4RRFFQ69G5FAA", "01ARZ3NDEKTSV4RRFFQ69G5FAB")
|
||||
|
||||
def test_alias_animal_id_length_check(self, migrated_db):
|
||||
"""Alias animal ID must be exactly 26 characters."""
|
||||
with pytest.raises(apsw.ConstraintError):
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_aliases (alias_animal_id, survivor_animal_id, merged_at_utc)
|
||||
VALUES ('short', '01ARZ3NDEKTSV4RRFFQ69G5FAB', 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
def test_survivor_animal_id_length_check(self, migrated_db):
|
||||
"""Survivor animal ID must be exactly 26 characters."""
|
||||
with pytest.raises(apsw.ConstraintError):
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_aliases (alias_animal_id, survivor_animal_id, merged_at_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', 'short', 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
def test_alias_is_primary_key(self, migrated_db):
|
||||
"""Alias animal ID is primary key (duplicate rejected)."""
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_aliases (alias_animal_id, survivor_animal_id, merged_at_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', '01ARZ3NDEKTSV4RRFFQ69G5FAB', 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
with pytest.raises(apsw.ConstraintError):
|
||||
migrated_db.execute(
|
||||
"""
|
||||
INSERT INTO animal_aliases (alias_animal_id, survivor_animal_id, merged_at_utc)
|
||||
VALUES ('01ARZ3NDEKTSV4RRFFQ69G5FAA', '01ARZ3NDEKTSV4RRFFQ69G5FAC', 1704067200000)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
class TestIndexes:
|
||||
"""Tests that indexes are created correctly."""
|
||||
|
||||
def test_animal_registry_indexes_exist(self, migrated_db):
|
||||
"""Animal registry table has required indexes."""
|
||||
indexes = migrated_db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='animal_registry'"
|
||||
).fetchall()
|
||||
index_names = {row[0] for row in indexes}
|
||||
|
||||
assert "idx_ar_nickname_active" in index_names
|
||||
assert "idx_ar_location" in index_names
|
||||
assert "idx_ar_filter" in index_names
|
||||
assert "idx_ar_status" in index_names
|
||||
assert "idx_ar_last_event" in index_names
|
||||
|
||||
def test_live_animals_by_location_indexes_exist(self, migrated_db):
|
||||
"""Live animals by location table has required indexes."""
|
||||
indexes = migrated_db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='live_animals_by_location'"
|
||||
).fetchall()
|
||||
index_names = {row[0] for row in indexes}
|
||||
|
||||
assert "idx_labl_location" in index_names
|
||||
assert "idx_labl_filter" in index_names
|
||||
|
||||
def test_animal_aliases_indexes_exist(self, migrated_db):
|
||||
"""Animal aliases table has required indexes."""
|
||||
indexes = migrated_db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='animal_aliases'"
|
||||
).fetchall()
|
||||
index_names = {row[0] for row in indexes}
|
||||
|
||||
assert "idx_aa_survivor" in index_names
|
||||
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