# 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"