feat: add animal attributes update projection and service
Implement AnimalAttributesUpdated event handling: - Update IntervalProjection to close old attr intervals and open new ones - Update AnimalRegistryProjection to update registry tables - Update EventAnimalsProjection to track event-animal links - Add update_attributes() to AnimalService Only attributes that actually change create new intervals. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -492,3 +492,251 @@ class TestAnimalServiceMoveValidation:
|
||||
|
||||
with pytest.raises(ValidationError, match="not found"):
|
||||
animal_service.move_animals(move_payload, int(time.time() * 1000), "test_user")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# update_attributes Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def make_attrs_payload(
|
||||
resolved_ids: list[str],
|
||||
sex: str | None = None,
|
||||
life_stage: str | None = None,
|
||||
repro_status: str | None = None,
|
||||
):
|
||||
"""Create an attributes update payload for testing."""
|
||||
from animaltrack.events.payloads import AnimalAttributesUpdatedPayload, AttributeSet
|
||||
|
||||
attr_set = AttributeSet(sex=sex, life_stage=life_stage, repro_status=repro_status)
|
||||
return AnimalAttributesUpdatedPayload(
|
||||
resolved_ids=resolved_ids,
|
||||
set=attr_set,
|
||||
)
|
||||
|
||||
|
||||
class TestAnimalServiceUpdateAttributes:
|
||||
"""Tests for update_attributes()."""
|
||||
|
||||
def test_creates_animal_attributes_updated_event(
|
||||
self, seeded_db, animal_service, valid_location_id
|
||||
):
|
||||
"""update_attributes creates an AnimalAttributesUpdated event."""
|
||||
from animaltrack.events.types import ANIMAL_ATTRIBUTES_UPDATED
|
||||
|
||||
# First create a cohort
|
||||
cohort_payload = make_payload(valid_location_id, count=2)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
# Update attributes
|
||||
attrs_payload = make_attrs_payload(animal_ids, sex="female")
|
||||
attrs_ts = ts_utc + 1000
|
||||
attrs_event = animal_service.update_attributes(attrs_payload, attrs_ts, "test_user")
|
||||
|
||||
assert attrs_event.type == ANIMAL_ATTRIBUTES_UPDATED
|
||||
assert attrs_event.actor == "test_user"
|
||||
assert attrs_event.ts_utc == attrs_ts
|
||||
|
||||
def test_event_has_animal_ids_in_entity_refs(
|
||||
self, seeded_db, animal_service, valid_location_id
|
||||
):
|
||||
"""Event entity_refs contains animal_ids list."""
|
||||
cohort_payload = make_payload(valid_location_id, count=3)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
attrs_payload = make_attrs_payload(animal_ids, life_stage="juvenile")
|
||||
attrs_event = animal_service.update_attributes(attrs_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
assert "animal_ids" in attrs_event.entity_refs
|
||||
assert set(attrs_event.entity_refs["animal_ids"]) == set(animal_ids)
|
||||
|
||||
def test_updates_sex_in_registry(self, seeded_db, animal_service, valid_location_id):
|
||||
"""Animals have updated sex in animal_registry table."""
|
||||
cohort_payload = make_payload(valid_location_id, count=2, sex="unknown")
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
attrs_payload = make_attrs_payload(animal_ids, sex="male")
|
||||
animal_service.update_attributes(attrs_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
# Check each animal has updated sex
|
||||
for animal_id in animal_ids:
|
||||
row = seeded_db.execute(
|
||||
"SELECT sex FROM animal_registry WHERE animal_id = ?",
|
||||
(animal_id,),
|
||||
).fetchone()
|
||||
assert row[0] == "male"
|
||||
|
||||
def test_updates_life_stage_in_registry(self, seeded_db, animal_service, valid_location_id):
|
||||
"""Animals have updated life_stage in animal_registry table."""
|
||||
cohort_payload = make_payload(valid_location_id, count=1, life_stage="juvenile")
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
attrs_payload = make_attrs_payload(animal_ids, life_stage="adult")
|
||||
animal_service.update_attributes(attrs_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
row = seeded_db.execute(
|
||||
"SELECT life_stage FROM animal_registry WHERE animal_id = ?",
|
||||
(animal_ids[0],),
|
||||
).fetchone()
|
||||
assert row[0] == "adult"
|
||||
|
||||
def test_updates_repro_status_in_registry(self, seeded_db, animal_service, valid_location_id):
|
||||
"""Animals have updated repro_status in animal_registry table."""
|
||||
cohort_payload = make_payload(valid_location_id, count=1)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
attrs_payload = make_attrs_payload(animal_ids, repro_status="intact")
|
||||
animal_service.update_attributes(attrs_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
row = seeded_db.execute(
|
||||
"SELECT repro_status FROM animal_registry WHERE animal_id = ?",
|
||||
(animal_ids[0],),
|
||||
).fetchone()
|
||||
assert row[0] == "intact"
|
||||
|
||||
def test_creates_attr_intervals_for_changed_attrs_only(
|
||||
self, seeded_db, animal_service, valid_location_id
|
||||
):
|
||||
"""Only changed attrs create new intervals."""
|
||||
# Create cohort with sex=unknown, life_stage=adult
|
||||
cohort_payload = make_payload(valid_location_id, count=1, sex="unknown", life_stage="adult")
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_id = cohort_event.entity_refs["animal_ids"][0]
|
||||
|
||||
# Initial intervals: sex, life_stage, repro_status, status = 4
|
||||
initial_count = seeded_db.execute(
|
||||
"SELECT COUNT(*) FROM animal_attr_intervals WHERE animal_id = ?",
|
||||
(animal_id,),
|
||||
).fetchone()[0]
|
||||
assert initial_count == 4
|
||||
|
||||
# Update only sex (life_stage stays the same)
|
||||
attrs_payload = make_attrs_payload([animal_id], sex="female")
|
||||
animal_service.update_attributes(attrs_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
# Should have 5 intervals: 4 initial + 1 new for sex (old one closed)
|
||||
new_count = seeded_db.execute(
|
||||
"SELECT COUNT(*) FROM animal_attr_intervals WHERE animal_id = ?",
|
||||
(animal_id,),
|
||||
).fetchone()[0]
|
||||
assert new_count == 5
|
||||
|
||||
# Verify old sex interval was closed
|
||||
closed_sex = seeded_db.execute(
|
||||
"""SELECT end_utc FROM animal_attr_intervals
|
||||
WHERE animal_id = ? AND attr = 'sex' AND value = 'unknown'""",
|
||||
(animal_id,),
|
||||
).fetchone()
|
||||
assert closed_sex[0] == ts_utc + 1000
|
||||
|
||||
# Verify new sex interval is open
|
||||
open_sex = seeded_db.execute(
|
||||
"""SELECT end_utc FROM animal_attr_intervals
|
||||
WHERE animal_id = ? AND attr = 'sex' AND value = 'female'""",
|
||||
(animal_id,),
|
||||
).fetchone()
|
||||
assert open_sex[0] is None
|
||||
|
||||
def test_event_animal_links_created(self, seeded_db, animal_service, valid_location_id):
|
||||
"""Event-animal links are created for attrs event."""
|
||||
cohort_payload = make_payload(valid_location_id, count=4)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
attrs_payload = make_attrs_payload(animal_ids, sex="female")
|
||||
attrs_event = animal_service.update_attributes(attrs_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
# Check event_animals has 4 rows for the attrs event
|
||||
count = seeded_db.execute(
|
||||
"SELECT COUNT(*) FROM event_animals WHERE event_id = ?",
|
||||
(attrs_event.id,),
|
||||
).fetchone()[0]
|
||||
assert count == 4
|
||||
|
||||
def test_updates_multiple_attrs_at_once(self, seeded_db, animal_service, valid_location_id):
|
||||
"""Multiple attributes can be updated at once."""
|
||||
cohort_payload = make_payload(
|
||||
valid_location_id, count=1, sex="unknown", life_stage="hatchling"
|
||||
)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_id = cohort_event.entity_refs["animal_ids"][0]
|
||||
|
||||
# Update both sex and life_stage
|
||||
attrs_payload = make_attrs_payload([animal_id], sex="female", life_stage="juvenile")
|
||||
animal_service.update_attributes(attrs_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
# Check both were updated in registry
|
||||
row = seeded_db.execute(
|
||||
"SELECT sex, life_stage FROM animal_registry WHERE animal_id = ?",
|
||||
(animal_id,),
|
||||
).fetchone()
|
||||
assert row[0] == "female"
|
||||
assert row[1] == "juvenile"
|
||||
|
||||
# Should have 6 intervals: 4 initial + 2 new (sex + life_stage)
|
||||
count = seeded_db.execute(
|
||||
"SELECT COUNT(*) FROM animal_attr_intervals WHERE animal_id = ?",
|
||||
(animal_id,),
|
||||
).fetchone()[0]
|
||||
assert count == 6
|
||||
|
||||
def test_noop_when_value_unchanged(self, seeded_db, animal_service, valid_location_id):
|
||||
"""No new intervals created when value is already the same."""
|
||||
cohort_payload = make_payload(valid_location_id, count=1, sex="female")
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_id = cohort_event.entity_refs["animal_ids"][0]
|
||||
|
||||
initial_count = seeded_db.execute(
|
||||
"SELECT COUNT(*) FROM animal_attr_intervals WHERE animal_id = ?",
|
||||
(animal_id,),
|
||||
).fetchone()[0]
|
||||
|
||||
# Update sex to same value
|
||||
attrs_payload = make_attrs_payload([animal_id], sex="female")
|
||||
animal_service.update_attributes(attrs_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
# Should have same number of intervals
|
||||
new_count = seeded_db.execute(
|
||||
"SELECT COUNT(*) FROM animal_attr_intervals WHERE animal_id = ?",
|
||||
(animal_id,),
|
||||
).fetchone()[0]
|
||||
assert new_count == initial_count
|
||||
|
||||
|
||||
class TestAnimalServiceUpdateAttributesValidation:
|
||||
"""Tests for update_attributes() validation."""
|
||||
|
||||
def test_rejects_nonexistent_animal(self, seeded_db, animal_service):
|
||||
"""Raises ValidationError for non-existent animal_id."""
|
||||
fake_animal_id = "01ARZ3NDEKTSV4RRFFQ69G5XXX"
|
||||
attrs_payload = make_attrs_payload([fake_animal_id], sex="female")
|
||||
|
||||
with pytest.raises(ValidationError, match="not found"):
|
||||
animal_service.update_attributes(attrs_payload, int(time.time() * 1000), "test_user")
|
||||
|
||||
def test_rejects_empty_attribute_set(self, seeded_db, animal_service, valid_location_id):
|
||||
"""Raises ValidationError when no attributes are being updated."""
|
||||
cohort_payload = make_payload(valid_location_id, count=1)
|
||||
ts_utc = int(time.time() * 1000)
|
||||
cohort_event = animal_service.create_cohort(cohort_payload, ts_utc, "test_user")
|
||||
animal_ids = cohort_event.entity_refs["animal_ids"]
|
||||
|
||||
# Create payload with no attrs set
|
||||
attrs_payload = make_attrs_payload(animal_ids)
|
||||
|
||||
with pytest.raises(ValidationError, match="at least one attribute"):
|
||||
animal_service.update_attributes(attrs_payload, ts_utc + 1000, "test_user")
|
||||
|
||||
Reference in New Issue
Block a user