feat: add animal tagging projection and service

Implement AnimalTagged and AnimalTagEnded event handling:
- Add migration for tag_suggestions table
- Create TagProjection for tag intervals and suggestions
- Update EventAnimalsProjection to handle tag events
- Add add_tag() and end_tag() to AnimalService

Key behaviors:
- No-op idempotence (adding active tag or ending inactive tag)
- Updates live_animals_by_location.tags JSON array
- Tracks tag usage statistics in tag_suggestions

🤖 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-29 07:51:20 +00:00
parent 3583285336
commit 0511ed7bca
5 changed files with 893 additions and 1 deletions

View File

@@ -0,0 +1,455 @@
# ABOUTME: Tests for AnimalService tagging operations.
# ABOUTME: Tests add_tag and end_tag with interval and suggestion tracking.
import json
import time
import pytest
from animaltrack.events.payloads import (
AnimalCohortCreatedPayload,
AnimalTagEndedPayload,
AnimalTaggedPayload,
)
from animaltrack.events.store import EventStore
from animaltrack.events.types import ANIMAL_TAG_ENDED, ANIMAL_TAGGED
from animaltrack.projections import ProjectionRegistry
from animaltrack.projections.animal_registry import AnimalRegistryProjection
from animaltrack.projections.event_animals import EventAnimalsProjection
from animaltrack.projections.intervals import IntervalProjection
from animaltrack.services.animal import AnimalService, ValidationError
@pytest.fixture
def event_store(seeded_db):
"""Create an EventStore for testing."""
return EventStore(seeded_db)
@pytest.fixture
def projection_registry(seeded_db):
"""Create a ProjectionRegistry with all cohort projections registered."""
from animaltrack.projections.tags import TagProjection
registry = ProjectionRegistry()
registry.register(AnimalRegistryProjection(seeded_db))
registry.register(EventAnimalsProjection(seeded_db))
registry.register(IntervalProjection(seeded_db))
registry.register(TagProjection(seeded_db))
return registry
@pytest.fixture
def animal_service(seeded_db, event_store, projection_registry):
"""Create an AnimalService for testing."""
return AnimalService(seeded_db, event_store, projection_registry)
@pytest.fixture
def valid_location_id(seeded_db):
"""Get a valid location ID from seeds."""
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
return row[0]
def make_cohort_payload(
location_id: str,
count: int = 1,
species: str = "duck",
life_stage: str = "adult",
sex: str = "unknown",
origin: str = "purchased",
) -> AnimalCohortCreatedPayload:
"""Create a cohort payload for testing."""
return AnimalCohortCreatedPayload(
species=species,
count=count,
life_stage=life_stage,
sex=sex,
location_id=location_id,
origin=origin,
)
def make_tag_payload(resolved_ids: list[str], tag: str) -> AnimalTaggedPayload:
"""Create a tag payload for testing."""
return AnimalTaggedPayload(resolved_ids=resolved_ids, tag=tag)
def make_tag_end_payload(resolved_ids: list[str], tag: str) -> AnimalTagEndedPayload:
"""Create a tag end payload for testing."""
return AnimalTagEndedPayload(resolved_ids=resolved_ids, tag=tag)
# =============================================================================
# add_tag Tests
# =============================================================================
class TestAnimalServiceAddTag:
"""Tests for add_tag()."""
def test_creates_animal_tagged_event(self, seeded_db, animal_service, valid_location_id):
"""add_tag creates an AnimalTagged event."""
# Create a cohort first
cohort_payload = make_cohort_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"]
# Add a tag
tag_payload = make_tag_payload(animal_ids, "red-band")
tag_ts = ts_utc + 1000
tag_event = animal_service.add_tag(tag_payload, tag_ts, "test_user")
assert tag_event.type == ANIMAL_TAGGED
assert tag_event.actor == "test_user"
assert tag_event.ts_utc == tag_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_cohort_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"]
tag_payload = make_tag_payload(animal_ids, "injured")
tag_event = animal_service.add_tag(tag_payload, ts_utc + 1000, "test_user")
assert "animal_ids" in tag_event.entity_refs
assert set(tag_event.entity_refs["animal_ids"]) == set(animal_ids)
def test_event_has_tag_in_entity_refs(self, seeded_db, animal_service, valid_location_id):
"""Event entity_refs contains the tag."""
cohort_payload = make_cohort_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"]
tag_payload = make_tag_payload(animal_ids, "layer-1")
tag_event = animal_service.add_tag(tag_payload, ts_utc + 1000, "test_user")
assert tag_event.entity_refs["tag"] == "layer-1"
def test_creates_tag_interval(self, seeded_db, animal_service, valid_location_id):
"""add_tag creates an open tag interval."""
cohort_payload = make_cohort_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_id = cohort_event.entity_refs["animal_ids"][0]
tag_payload = make_tag_payload([animal_id], "blue-band")
animal_service.add_tag(tag_payload, ts_utc + 1000, "test_user")
# Check interval was created
row = seeded_db.execute(
"""SELECT start_utc, end_utc FROM animal_tag_intervals
WHERE animal_id = ? AND tag = ?""",
(animal_id, "blue-band"),
).fetchone()
assert row is not None
assert row[0] == ts_utc + 1000 # start_utc
assert row[1] is None # end_utc is NULL (open interval)
def test_updates_live_animals_tags_json(self, seeded_db, animal_service, valid_location_id):
"""add_tag updates tags JSON in live_animals_by_location."""
cohort_payload = make_cohort_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_id = cohort_event.entity_refs["animal_ids"][0]
tag_payload = make_tag_payload([animal_id], "broody")
animal_service.add_tag(tag_payload, ts_utc + 1000, "test_user")
# Check tags JSON was updated
row = seeded_db.execute(
"SELECT tags FROM live_animals_by_location WHERE animal_id = ?",
(animal_id,),
).fetchone()
tags = json.loads(row[0])
assert "broody" in tags
def test_creates_event_animal_links(self, seeded_db, animal_service, valid_location_id):
"""add_tag creates event_animals links."""
cohort_payload = make_cohort_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"]
tag_payload = make_tag_payload(animal_ids, "layer")
tag_event = animal_service.add_tag(tag_payload, ts_utc + 1000, "test_user")
count = seeded_db.execute(
"SELECT COUNT(*) FROM event_animals WHERE event_id = ?",
(tag_event.id,),
).fetchone()[0]
assert count == 3
def test_updates_tag_suggestions(self, seeded_db, animal_service, valid_location_id):
"""add_tag creates/updates tag_suggestions entry."""
cohort_payload = make_cohort_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"]
tag_payload = make_tag_payload(animal_ids, "new-tag")
animal_service.add_tag(tag_payload, ts_utc + 1000, "test_user")
row = seeded_db.execute(
"SELECT total_assignments, active_animals FROM tag_suggestions WHERE tag = ?",
("new-tag",),
).fetchone()
assert row is not None
assert row[0] == 2 # total_assignments
assert row[1] == 2 # active_animals
class TestAnimalServiceAddTagNoOp:
"""Tests for add_tag() no-op behavior."""
def test_noop_when_tag_already_active(self, seeded_db, animal_service, valid_location_id):
"""Adding same tag twice doesn't create duplicate interval."""
cohort_payload = make_cohort_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_id = cohort_event.entity_refs["animal_ids"][0]
# Add tag twice
tag_payload = make_tag_payload([animal_id], "duplicate-test")
animal_service.add_tag(tag_payload, ts_utc + 1000, "test_user")
animal_service.add_tag(tag_payload, ts_utc + 2000, "test_user")
# Should only have one interval
count = seeded_db.execute(
"""SELECT COUNT(*) FROM animal_tag_intervals
WHERE animal_id = ? AND tag = ?""",
(animal_id, "duplicate-test"),
).fetchone()[0]
assert count == 1
class TestAnimalServiceAddTagValidation:
"""Tests for add_tag() validation."""
def test_rejects_nonexistent_animal(self, seeded_db, animal_service):
"""Raises ValidationError for non-existent animal_id."""
fake_animal_id = "01ARZ3NDEKTSV4RRFFQ69G5XXX"
tag_payload = make_tag_payload([fake_animal_id], "test-tag")
with pytest.raises(ValidationError, match="not found"):
animal_service.add_tag(tag_payload, int(time.time() * 1000), "test_user")
# =============================================================================
# end_tag Tests
# =============================================================================
class TestAnimalServiceEndTag:
"""Tests for end_tag()."""
def test_creates_animal_tag_ended_event(self, seeded_db, animal_service, valid_location_id):
"""end_tag creates an AnimalTagEnded event."""
cohort_payload = make_cohort_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"]
# Add then end tag
tag_payload = make_tag_payload(animal_ids, "temp-tag")
animal_service.add_tag(tag_payload, ts_utc + 1000, "test_user")
end_payload = make_tag_end_payload(animal_ids, "temp-tag")
end_event = animal_service.end_tag(end_payload, ts_utc + 2000, "test_user")
assert end_event.type == ANIMAL_TAG_ENDED
assert end_event.actor == "test_user"
assert end_event.ts_utc == ts_utc + 2000
def test_closes_tag_interval(self, seeded_db, animal_service, valid_location_id):
"""end_tag closes the open tag interval."""
cohort_payload = make_cohort_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_id = cohort_event.entity_refs["animal_ids"][0]
# Add then end tag
tag_payload = make_tag_payload([animal_id], "closing-tag")
animal_service.add_tag(tag_payload, ts_utc + 1000, "test_user")
end_payload = make_tag_end_payload([animal_id], "closing-tag")
animal_service.end_tag(end_payload, ts_utc + 2000, "test_user")
# Check interval was closed
row = seeded_db.execute(
"""SELECT start_utc, end_utc FROM animal_tag_intervals
WHERE animal_id = ? AND tag = ?""",
(animal_id, "closing-tag"),
).fetchone()
assert row[0] == ts_utc + 1000 # start_utc
assert row[1] == ts_utc + 2000 # end_utc is set
def test_removes_tag_from_live_animals_json(self, seeded_db, animal_service, valid_location_id):
"""end_tag removes tag from live_animals_by_location tags JSON."""
cohort_payload = make_cohort_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_id = cohort_event.entity_refs["animal_ids"][0]
# Add then end tag
tag_payload = make_tag_payload([animal_id], "removing-tag")
animal_service.add_tag(tag_payload, ts_utc + 1000, "test_user")
end_payload = make_tag_end_payload([animal_id], "removing-tag")
animal_service.end_tag(end_payload, ts_utc + 2000, "test_user")
# Check tags JSON was updated
row = seeded_db.execute(
"SELECT tags FROM live_animals_by_location WHERE animal_id = ?",
(animal_id,),
).fetchone()
tags = json.loads(row[0])
assert "removing-tag" not in tags
def test_decrements_tag_suggestions_active(self, seeded_db, animal_service, valid_location_id):
"""end_tag decrements active_animals in tag_suggestions."""
cohort_payload = make_cohort_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"]
# Add tag to both
tag_payload = make_tag_payload(animal_ids, "decrement-test")
animal_service.add_tag(tag_payload, ts_utc + 1000, "test_user")
# End tag for one animal
end_payload = make_tag_end_payload([animal_ids[0]], "decrement-test")
animal_service.end_tag(end_payload, ts_utc + 2000, "test_user")
row = seeded_db.execute(
"SELECT total_assignments, active_animals FROM tag_suggestions WHERE tag = ?",
("decrement-test",),
).fetchone()
assert row[0] == 2 # total_assignments unchanged
assert row[1] == 1 # active_animals decremented
class TestAnimalServiceEndTagNoOp:
"""Tests for end_tag() no-op behavior."""
def test_noop_when_tag_not_active(self, seeded_db, animal_service, valid_location_id):
"""Ending a tag that's not active is a no-op."""
cohort_payload = make_cohort_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_id = cohort_event.entity_refs["animal_ids"][0]
# End a tag that was never added
end_payload = make_tag_end_payload([animal_id], "never-added")
end_event = animal_service.end_tag(end_payload, ts_utc + 1000, "test_user")
# Event should still be created
assert end_event.type == ANIMAL_TAG_ENDED
# But no intervals should exist
count = seeded_db.execute(
"""SELECT COUNT(*) FROM animal_tag_intervals
WHERE animal_id = ? AND tag = ?""",
(animal_id, "never-added"),
).fetchone()[0]
assert count == 0
class TestAnimalServiceEndTagValidation:
"""Tests for end_tag() validation."""
def test_rejects_nonexistent_animal(self, seeded_db, animal_service):
"""Raises ValidationError for non-existent animal_id."""
fake_animal_id = "01ARZ3NDEKTSV4RRFFQ69G5XXX"
end_payload = make_tag_end_payload([fake_animal_id], "test-tag")
with pytest.raises(ValidationError, match="not found"):
animal_service.end_tag(end_payload, int(time.time() * 1000), "test_user")
# =============================================================================
# Multiple Tags Tests
# =============================================================================
class TestMultipleTags:
"""Tests for multiple tags on the same animal."""
def test_multiple_tags_on_same_animal(self, seeded_db, animal_service, valid_location_id):
"""An animal can have multiple active tags."""
cohort_payload = make_cohort_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_id = cohort_event.entity_refs["animal_ids"][0]
# Add multiple tags
animal_service.add_tag(make_tag_payload([animal_id], "tag-a"), ts_utc + 1000, "test_user")
animal_service.add_tag(make_tag_payload([animal_id], "tag-b"), ts_utc + 2000, "test_user")
animal_service.add_tag(make_tag_payload([animal_id], "tag-c"), ts_utc + 3000, "test_user")
# Check all three intervals exist and are open
count = seeded_db.execute(
"""SELECT COUNT(*) FROM animal_tag_intervals
WHERE animal_id = ? AND end_utc IS NULL""",
(animal_id,),
).fetchone()[0]
assert count == 3
# Check tags JSON has all three
row = seeded_db.execute(
"SELECT tags FROM live_animals_by_location WHERE animal_id = ?",
(animal_id,),
).fetchone()
tags = json.loads(row[0])
assert set(tags) == {"tag-a", "tag-b", "tag-c"}
def test_retagging_after_end(self, seeded_db, animal_service, valid_location_id):
"""A tag can be added again after being ended."""
cohort_payload = make_cohort_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_id = cohort_event.entity_refs["animal_ids"][0]
# Add, end, re-add
animal_service.add_tag(make_tag_payload([animal_id], "retag"), ts_utc + 1000, "test_user")
animal_service.end_tag(
make_tag_end_payload([animal_id], "retag"), ts_utc + 2000, "test_user"
)
animal_service.add_tag(make_tag_payload([animal_id], "retag"), ts_utc + 3000, "test_user")
# Should have two intervals (one closed, one open)
count = seeded_db.execute(
"""SELECT COUNT(*) FROM animal_tag_intervals
WHERE animal_id = ? AND tag = ?""",
(animal_id, "retag"),
).fetchone()[0]
assert count == 2
# One should be closed
closed = seeded_db.execute(
"""SELECT COUNT(*) FROM animal_tag_intervals
WHERE animal_id = ? AND tag = ? AND end_utc IS NOT NULL""",
(animal_id, "retag"),
).fetchone()[0]
assert closed == 1
# One should be open
open_count = seeded_db.execute(
"""SELECT COUNT(*) FROM animal_tag_intervals
WHERE animal_id = ? AND tag = ? AND end_utc IS NULL""",
(animal_id, "retag"),
).fetchone()[0]
assert open_count == 1