- Add migration 0008 for event_log_by_location table with cap trigger - Create EventLogProjection for location-scoped event summaries - Add GET /event-log route with location_id filtering - Create event log templates with timeline styling - Register EventLogProjection in eggs, feed, and move routes - Cap events at 500 per location (trigger removes oldest) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
455 lines
15 KiB
Python
455 lines
15 KiB
Python
# ABOUTME: Tests for EventLogProjection.
|
|
# ABOUTME: Validates event log entries are created for location-scoped events.
|
|
|
|
import json
|
|
|
|
from animaltrack.events.types import (
|
|
ANIMAL_COHORT_CREATED,
|
|
ANIMAL_MOVED,
|
|
FEED_GIVEN,
|
|
FEED_PURCHASED,
|
|
HATCH_RECORDED,
|
|
PRODUCT_COLLECTED,
|
|
PRODUCT_SOLD,
|
|
)
|
|
from animaltrack.models.events import Event
|
|
from animaltrack.projections.event_log import EventLogProjection
|
|
|
|
|
|
def make_product_collected_event(
|
|
event_id: str,
|
|
location_id: str,
|
|
animal_ids: list[str],
|
|
quantity: int = 5,
|
|
ts_utc: int = 1704067200000,
|
|
) -> Event:
|
|
"""Create a test ProductCollected event."""
|
|
return Event(
|
|
id=event_id,
|
|
type=PRODUCT_COLLECTED,
|
|
ts_utc=ts_utc,
|
|
actor="test_user",
|
|
entity_refs={
|
|
"location_id": location_id,
|
|
"animal_ids": animal_ids,
|
|
},
|
|
payload={
|
|
"location_id": location_id,
|
|
"product_code": "egg.duck",
|
|
"quantity": quantity,
|
|
"resolved_ids": animal_ids,
|
|
"notes": None,
|
|
},
|
|
version=1,
|
|
)
|
|
|
|
|
|
def make_cohort_event(
|
|
event_id: str,
|
|
location_id: str,
|
|
animal_ids: list[str],
|
|
species: str = "duck",
|
|
ts_utc: int = 1704067200000,
|
|
) -> Event:
|
|
"""Create a test AnimalCohortCreated event."""
|
|
return Event(
|
|
id=event_id,
|
|
type=ANIMAL_COHORT_CREATED,
|
|
ts_utc=ts_utc,
|
|
actor="test_user",
|
|
entity_refs={
|
|
"location_id": location_id,
|
|
"animal_ids": animal_ids,
|
|
},
|
|
payload={
|
|
"species": species,
|
|
"count": len(animal_ids),
|
|
"life_stage": "adult",
|
|
"sex": "unknown",
|
|
"location_id": location_id,
|
|
"origin": "purchased",
|
|
"notes": None,
|
|
},
|
|
version=1,
|
|
)
|
|
|
|
|
|
def make_feed_given_event(
|
|
event_id: str,
|
|
location_id: str,
|
|
feed_type_code: str = "layer",
|
|
amount_kg: int = 5,
|
|
ts_utc: int = 1704067200000,
|
|
) -> Event:
|
|
"""Create a test FeedGiven event."""
|
|
return Event(
|
|
id=event_id,
|
|
type=FEED_GIVEN,
|
|
ts_utc=ts_utc,
|
|
actor="test_user",
|
|
entity_refs={
|
|
"location_id": location_id,
|
|
},
|
|
payload={
|
|
"location_id": location_id,
|
|
"feed_type_code": feed_type_code,
|
|
"amount_kg": amount_kg,
|
|
"notes": None,
|
|
},
|
|
version=1,
|
|
)
|
|
|
|
|
|
def make_feed_purchased_event(
|
|
event_id: str,
|
|
ts_utc: int = 1704067200000,
|
|
) -> Event:
|
|
"""Create a test FeedPurchased event (no location)."""
|
|
return Event(
|
|
id=event_id,
|
|
type=FEED_PURCHASED,
|
|
ts_utc=ts_utc,
|
|
actor="test_user",
|
|
entity_refs={},
|
|
payload={
|
|
"feed_type_code": "layer",
|
|
"bag_size_kg": 20,
|
|
"bags_count": 1,
|
|
"bag_price_cents": 2500,
|
|
"vendor": None,
|
|
"notes": None,
|
|
},
|
|
version=1,
|
|
)
|
|
|
|
|
|
def make_product_sold_event(
|
|
event_id: str,
|
|
ts_utc: int = 1704067200000,
|
|
) -> Event:
|
|
"""Create a test ProductSold event (no location)."""
|
|
return Event(
|
|
id=event_id,
|
|
type=PRODUCT_SOLD,
|
|
ts_utc=ts_utc,
|
|
actor="test_user",
|
|
entity_refs={},
|
|
payload={
|
|
"product_code": "egg.duck",
|
|
"quantity": 30,
|
|
"total_price_cents": 900,
|
|
"buyer": None,
|
|
"notes": None,
|
|
},
|
|
version=1,
|
|
)
|
|
|
|
|
|
def make_animal_moved_event(
|
|
event_id: str,
|
|
to_location_id: str,
|
|
animal_ids: list[str],
|
|
ts_utc: int = 1704067200000,
|
|
) -> Event:
|
|
"""Create a test AnimalMoved event."""
|
|
return Event(
|
|
id=event_id,
|
|
type=ANIMAL_MOVED,
|
|
ts_utc=ts_utc,
|
|
actor="test_user",
|
|
entity_refs={
|
|
"to_location_id": to_location_id,
|
|
"animal_ids": animal_ids,
|
|
},
|
|
payload={
|
|
"to_location_id": to_location_id,
|
|
"resolved_ids": animal_ids,
|
|
"notes": None,
|
|
},
|
|
version=1,
|
|
)
|
|
|
|
|
|
def make_hatch_event(
|
|
event_id: str,
|
|
location_id: str,
|
|
hatched_live: int = 5,
|
|
ts_utc: int = 1704067200000,
|
|
) -> Event:
|
|
"""Create a test HatchRecorded event."""
|
|
return Event(
|
|
id=event_id,
|
|
type=HATCH_RECORDED,
|
|
ts_utc=ts_utc,
|
|
actor="test_user",
|
|
entity_refs={
|
|
"location_id": location_id,
|
|
},
|
|
payload={
|
|
"species": "duck",
|
|
"location_id": location_id,
|
|
"assigned_brood_location_id": None,
|
|
"hatched_live": hatched_live,
|
|
"notes": None,
|
|
},
|
|
version=1,
|
|
)
|
|
|
|
|
|
class TestEventLogProjectionEventTypes:
|
|
"""Tests for get_event_types method."""
|
|
|
|
def test_handles_product_collected(self, seeded_db):
|
|
"""Projection handles ProductCollected event type."""
|
|
projection = EventLogProjection(seeded_db)
|
|
assert PRODUCT_COLLECTED in projection.get_event_types()
|
|
|
|
def test_handles_animal_cohort_created(self, seeded_db):
|
|
"""Projection handles AnimalCohortCreated event type."""
|
|
projection = EventLogProjection(seeded_db)
|
|
assert ANIMAL_COHORT_CREATED in projection.get_event_types()
|
|
|
|
def test_handles_feed_given(self, seeded_db):
|
|
"""Projection handles FeedGiven event type."""
|
|
projection = EventLogProjection(seeded_db)
|
|
assert FEED_GIVEN in projection.get_event_types()
|
|
|
|
def test_handles_animal_moved(self, seeded_db):
|
|
"""Projection handles AnimalMoved event type."""
|
|
projection = EventLogProjection(seeded_db)
|
|
assert ANIMAL_MOVED in projection.get_event_types()
|
|
|
|
def test_handles_hatch_recorded(self, seeded_db):
|
|
"""Projection handles HatchRecorded event type."""
|
|
projection = EventLogProjection(seeded_db)
|
|
assert HATCH_RECORDED in projection.get_event_types()
|
|
|
|
def test_does_not_handle_feed_purchased(self, seeded_db):
|
|
"""Projection does not handle FeedPurchased (no location)."""
|
|
projection = EventLogProjection(seeded_db)
|
|
assert FEED_PURCHASED not in projection.get_event_types()
|
|
|
|
def test_does_not_handle_product_sold(self, seeded_db):
|
|
"""Projection does not handle ProductSold (no location)."""
|
|
projection = EventLogProjection(seeded_db)
|
|
assert PRODUCT_SOLD not in projection.get_event_types()
|
|
|
|
|
|
class TestEventLogProjectionApply:
|
|
"""Tests for apply()."""
|
|
|
|
def test_creates_event_log_entry_for_product_collected(self, seeded_db):
|
|
"""Apply creates event log entry for ProductCollected."""
|
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
|
location_id = row[0]
|
|
|
|
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
|
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
|
|
|
projection = EventLogProjection(seeded_db)
|
|
event = make_product_collected_event(event_id, location_id, animal_ids, quantity=5)
|
|
projection.apply(event)
|
|
|
|
row = seeded_db.execute(
|
|
"SELECT event_id, location_id, type, actor FROM event_log_by_location"
|
|
).fetchone()
|
|
assert row[0] == event_id
|
|
assert row[1] == location_id
|
|
assert row[2] == PRODUCT_COLLECTED
|
|
assert row[3] == "test_user"
|
|
|
|
def test_event_log_summary_contains_relevant_info(self, seeded_db):
|
|
"""Event log summary contains relevant event info."""
|
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
|
location_id = row[0]
|
|
|
|
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
|
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
|
|
|
projection = EventLogProjection(seeded_db)
|
|
event = make_product_collected_event(event_id, location_id, animal_ids, quantity=5)
|
|
projection.apply(event)
|
|
|
|
row = seeded_db.execute("SELECT summary FROM event_log_by_location").fetchone()
|
|
summary = json.loads(row[0])
|
|
assert summary["product_code"] == "egg.duck"
|
|
assert summary["quantity"] == 5
|
|
|
|
def test_creates_event_log_entry_for_cohort_created(self, seeded_db):
|
|
"""Apply creates event log entry for AnimalCohortCreated."""
|
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
|
location_id = row[0]
|
|
|
|
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
|
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01", "01ARZ3NDEKTSV4RRFFQ69G5A02"]
|
|
|
|
projection = EventLogProjection(seeded_db)
|
|
event = make_cohort_event(event_id, location_id, animal_ids, species="duck")
|
|
projection.apply(event)
|
|
|
|
row = seeded_db.execute("SELECT event_id, type FROM event_log_by_location").fetchone()
|
|
assert row[0] == event_id
|
|
assert row[1] == ANIMAL_COHORT_CREATED
|
|
|
|
def test_cohort_summary_contains_species_and_count(self, seeded_db):
|
|
"""Cohort event summary contains species and count."""
|
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
|
location_id = row[0]
|
|
|
|
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
|
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01", "01ARZ3NDEKTSV4RRFFQ69G5A02"]
|
|
|
|
projection = EventLogProjection(seeded_db)
|
|
event = make_cohort_event(event_id, location_id, animal_ids, species="goose")
|
|
projection.apply(event)
|
|
|
|
row = seeded_db.execute("SELECT summary FROM event_log_by_location").fetchone()
|
|
summary = json.loads(row[0])
|
|
assert summary["species"] == "goose"
|
|
assert summary["count"] == 2
|
|
|
|
def test_creates_event_log_entry_for_feed_given(self, seeded_db):
|
|
"""Apply creates event log entry for FeedGiven."""
|
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
|
location_id = row[0]
|
|
|
|
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
|
|
|
|
projection = EventLogProjection(seeded_db)
|
|
event = make_feed_given_event(event_id, location_id, amount_kg=3)
|
|
projection.apply(event)
|
|
|
|
row = seeded_db.execute("SELECT event_id, type FROM event_log_by_location").fetchone()
|
|
assert row[0] == event_id
|
|
assert row[1] == FEED_GIVEN
|
|
|
|
def test_feed_given_summary_contains_amount(self, seeded_db):
|
|
"""FeedGiven event summary contains feed type and amount."""
|
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
|
location_id = row[0]
|
|
|
|
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
|
|
|
|
projection = EventLogProjection(seeded_db)
|
|
event = make_feed_given_event(event_id, location_id, feed_type_code="grower", amount_kg=5)
|
|
projection.apply(event)
|
|
|
|
row = seeded_db.execute("SELECT summary FROM event_log_by_location").fetchone()
|
|
summary = json.loads(row[0])
|
|
assert summary["feed_type_code"] == "grower"
|
|
assert summary["amount_kg"] == 5
|
|
|
|
def test_creates_event_log_for_animal_moved(self, seeded_db):
|
|
"""Apply creates event log entry for AnimalMoved at destination."""
|
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()
|
|
to_location_id = row[0]
|
|
|
|
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
|
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
|
|
|
projection = EventLogProjection(seeded_db)
|
|
event = make_animal_moved_event(event_id, to_location_id, animal_ids)
|
|
projection.apply(event)
|
|
|
|
row = seeded_db.execute(
|
|
"SELECT event_id, location_id, type FROM event_log_by_location"
|
|
).fetchone()
|
|
assert row[0] == event_id
|
|
assert row[1] == to_location_id
|
|
assert row[2] == ANIMAL_MOVED
|
|
|
|
def test_creates_event_log_for_hatch_recorded(self, seeded_db):
|
|
"""Apply creates event log entry for HatchRecorded."""
|
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
|
location_id = row[0]
|
|
|
|
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
|
|
|
|
projection = EventLogProjection(seeded_db)
|
|
event = make_hatch_event(event_id, location_id, hatched_live=8)
|
|
projection.apply(event)
|
|
|
|
row = seeded_db.execute("SELECT event_id, type FROM event_log_by_location").fetchone()
|
|
assert row[0] == event_id
|
|
assert row[1] == HATCH_RECORDED
|
|
|
|
def test_hatch_summary_contains_hatched_count(self, seeded_db):
|
|
"""HatchRecorded summary contains species and hatched count."""
|
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
|
location_id = row[0]
|
|
|
|
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
|
|
|
|
projection = EventLogProjection(seeded_db)
|
|
event = make_hatch_event(event_id, location_id, hatched_live=8)
|
|
projection.apply(event)
|
|
|
|
row = seeded_db.execute("SELECT summary FROM event_log_by_location").fetchone()
|
|
summary = json.loads(row[0])
|
|
assert summary["species"] == "duck"
|
|
assert summary["hatched_live"] == 8
|
|
|
|
|
|
class TestEventLogProjectionRevert:
|
|
"""Tests for revert()."""
|
|
|
|
def test_removes_event_log_entry(self, seeded_db):
|
|
"""Revert removes the event log entry."""
|
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
|
location_id = row[0]
|
|
|
|
event_id = "01ARZ3NDEKTSV4RRFFQ69G5001"
|
|
animal_ids = ["01ARZ3NDEKTSV4RRFFQ69G5A01"]
|
|
|
|
projection = EventLogProjection(seeded_db)
|
|
event = make_product_collected_event(event_id, location_id, animal_ids)
|
|
projection.apply(event)
|
|
|
|
# Verify row exists
|
|
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
|
|
assert count == 1
|
|
|
|
# Revert
|
|
projection.revert(event)
|
|
|
|
# Verify row removed
|
|
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
|
|
assert count == 0
|
|
|
|
def test_revert_only_affects_specific_event(self, seeded_db):
|
|
"""Revert only removes the specific event log entry."""
|
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
|
location_id = row[0]
|
|
|
|
projection = EventLogProjection(seeded_db)
|
|
|
|
# Create first event
|
|
event1 = make_product_collected_event(
|
|
"01ARZ3NDEKTSV4RRFFQ69G5001",
|
|
location_id,
|
|
["01ARZ3NDEKTSV4RRFFQ69G5A01"],
|
|
)
|
|
projection.apply(event1)
|
|
|
|
# Create second event
|
|
event2 = make_feed_given_event(
|
|
"01ARZ3NDEKTSV4RRFFQ69G5002",
|
|
location_id,
|
|
ts_utc=1704067300000,
|
|
)
|
|
projection.apply(event2)
|
|
|
|
# Verify both exist
|
|
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
|
|
assert count == 2
|
|
|
|
# Revert only event1
|
|
projection.revert(event1)
|
|
|
|
# Event2 should still exist
|
|
count = seeded_db.execute("SELECT COUNT(*) FROM event_log_by_location").fetchone()[0]
|
|
assert count == 1
|
|
|
|
row = seeded_db.execute("SELECT event_id FROM event_log_by_location").fetchone()
|
|
assert row[0] == "01ARZ3NDEKTSV4RRFFQ69G5002"
|