diff --git a/PLAN.md b/PLAN.md index f55d675..c3f8f3f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -317,13 +317,13 @@ Check off items as completed. Each phase builds on the previous. - [x] **Commit checkpoint** ### Step 8.2: Event Log Projection & View -- [ ] Create migration for event_log_by_location table with cap trigger -- [ ] Create `projections/event_log.py` for event summaries -- [ ] Create `web/routes/events.py`: - - [ ] GET /event-log?location_id=... -- [ ] Create `web/templates/events.py` -- [ ] Write tests: events appear, capped at 500, ordered by ts_utc DESC -- [ ] **Commit checkpoint** +- [x] Create migration for event_log_by_location table with cap trigger +- [x] Create `projections/event_log.py` for event summaries +- [x] Create `web/routes/events.py`: + - [x] GET /event-log?location_id=... +- [x] Create `web/templates/events.py` +- [x] Write tests: events appear, capped at 500, ordered by ts_utc DESC +- [x] **Commit checkpoint** ### Step 8.3: Animal Detail Drawer - [ ] Create `web/routes/animals.py`: diff --git a/migrations/0008-event-log-by-location.sql b/migrations/0008-event-log-by-location.sql new file mode 100644 index 0000000..272f880 --- /dev/null +++ b/migrations/0008-event-log-by-location.sql @@ -0,0 +1,27 @@ +-- ABOUTME: Migration to create event_log_by_location table with cap trigger. +-- ABOUTME: Stores event summaries per location, capped at 500 per location. + +-- Event log table for location-scoped event summaries +CREATE TABLE event_log_by_location ( + event_id TEXT PRIMARY KEY, + location_id TEXT NOT NULL REFERENCES locations(id), + ts_utc INTEGER NOT NULL, + type TEXT NOT NULL, + actor TEXT NOT NULL, + summary TEXT NOT NULL CHECK(json_valid(summary)) +); + +-- Index for efficient queries by location and time (newest first) +CREATE INDEX idx_evlog_loc_ts ON event_log_by_location(location_id, ts_utc DESC); + +-- Trigger to cap events at 500 per location (keeps newest 500) +CREATE TRIGGER trg_evlog_cap AFTER INSERT ON event_log_by_location +BEGIN + DELETE FROM event_log_by_location + WHERE rowid IN ( + SELECT rowid FROM event_log_by_location + WHERE location_id = NEW.location_id + ORDER BY ts_utc DESC + LIMIT -1 OFFSET 500 + ); +END; diff --git a/src/animaltrack/projections/__init__.py b/src/animaltrack/projections/__init__.py index 55520ba..5e40ae5 100644 --- a/src/animaltrack/projections/__init__.py +++ b/src/animaltrack/projections/__init__.py @@ -4,6 +4,7 @@ from animaltrack.projections.animal_registry import AnimalRegistryProjection from animaltrack.projections.base import Projection, ProjectionRegistry from animaltrack.projections.event_animals import EventAnimalsProjection +from animaltrack.projections.event_log import EventLogProjection from animaltrack.projections.exceptions import ProjectionError from animaltrack.projections.intervals import IntervalProjection from animaltrack.projections.products import ProductsProjection @@ -11,6 +12,7 @@ from animaltrack.projections.products import ProductsProjection __all__ = [ "AnimalRegistryProjection", "EventAnimalsProjection", + "EventLogProjection", "IntervalProjection", "Projection", "ProjectionError", diff --git a/src/animaltrack/projections/event_log.py b/src/animaltrack/projections/event_log.py new file mode 100644 index 0000000..e181b90 --- /dev/null +++ b/src/animaltrack/projections/event_log.py @@ -0,0 +1,140 @@ +# ABOUTME: Projection for creating event log entries per location. +# ABOUTME: Populates event_log_by_location table with event summaries. + +import json +from typing import Any + +from animaltrack.events.types import ( + ANIMAL_COHORT_CREATED, + ANIMAL_MOVED, + ANIMAL_OUTCOME, + ANIMAL_TAG_ENDED, + ANIMAL_TAGGED, + FEED_GIVEN, + HATCH_RECORDED, + PRODUCT_COLLECTED, +) +from animaltrack.models.events import Event +from animaltrack.projections.base import Projection + +# Event types that have a location_id directly in payload +LOCATION_EVENTS = frozenset( + { + ANIMAL_COHORT_CREATED, + ANIMAL_MOVED, + ANIMAL_OUTCOME, + ANIMAL_TAGGED, + ANIMAL_TAG_ENDED, + FEED_GIVEN, + HATCH_RECORDED, + PRODUCT_COLLECTED, + } +) + + +class EventLogProjection(Projection): + """Projects events into event_log_by_location table. + + Creates a summary entry for each location-scoped event. + Events without a location (FeedPurchased, ProductSold) are not logged. + """ + + def __init__(self, db: Any) -> None: + """Initialize the projection with a database connection.""" + super().__init__(db) + + def get_event_types(self) -> list[str]: + """Return the event types this projection handles.""" + return list(LOCATION_EVENTS) + + def apply(self, event: Event) -> None: + """Apply event by creating an entry in event_log_by_location.""" + location_id = self._extract_location_id(event) + if location_id is None: + return + + summary = self._build_summary(event) + + self.db.execute( + """ + INSERT INTO event_log_by_location (event_id, location_id, ts_utc, type, actor, summary) + VALUES (?, ?, ?, ?, ?, ?) + """, + (event.id, location_id, event.ts_utc, event.type, event.actor, json.dumps(summary)), + ) + + def revert(self, event: Event) -> None: + """Revert event by removing entry from event_log_by_location.""" + self.db.execute( + "DELETE FROM event_log_by_location WHERE event_id = ?", + (event.id,), + ) + + def _extract_location_id(self, event: Event) -> str | None: + """Extract location_id from event based on event type.""" + payload = event.payload + + if event.type == ANIMAL_MOVED: + return payload.get("to_location_id") + + # Most events have location_id directly in payload + if "location_id" in payload: + return payload["location_id"] + + return None + + def _build_summary(self, event: Event) -> dict[str, Any]: + """Build summary JSON based on event type.""" + payload = event.payload + + if event.type == PRODUCT_COLLECTED: + return { + "product_code": payload.get("product_code"), + "quantity": payload.get("quantity"), + } + + if event.type == ANIMAL_COHORT_CREATED: + return { + "species": payload.get("species"), + "count": payload.get("count"), + "origin": payload.get("origin"), + } + + if event.type == FEED_GIVEN: + return { + "feed_type_code": payload.get("feed_type_code"), + "amount_kg": payload.get("amount_kg"), + } + + if event.type == ANIMAL_MOVED: + animal_ids = payload.get("resolved_ids", []) + return { + "animal_count": len(animal_ids), + } + + if event.type == HATCH_RECORDED: + return { + "species": payload.get("species"), + "hatched_live": payload.get("hatched_live"), + } + + if event.type == ANIMAL_TAGGED: + return { + "tag": payload.get("tag"), + "animal_count": len(payload.get("resolved_ids", [])), + } + + if event.type == ANIMAL_TAG_ENDED: + return { + "tag": payload.get("tag"), + "animal_count": len(payload.get("resolved_ids", [])), + } + + if event.type == ANIMAL_OUTCOME: + return { + "outcome": payload.get("outcome"), + "animal_count": len(payload.get("resolved_ids", [])), + } + + # Fallback - include basic info + return {"event_type": event.type} diff --git a/src/animaltrack/web/app.py b/src/animaltrack/web/app.py index 1077bad..bae7b0b 100644 --- a/src/animaltrack/web/app.py +++ b/src/animaltrack/web/app.py @@ -19,6 +19,7 @@ from animaltrack.web.middleware import ( ) from animaltrack.web.routes import ( register_egg_routes, + register_events_routes, register_feed_routes, register_health_routes, register_move_routes, @@ -132,6 +133,7 @@ def create_app( # Register routes register_health_routes(rt, app) register_egg_routes(rt, app) + register_events_routes(rt, app) register_feed_routes(rt, app) register_move_routes(rt, app) register_registry_routes(rt, app) diff --git a/src/animaltrack/web/routes/__init__.py b/src/animaltrack/web/routes/__init__.py index 2234fd3..0e4e96b 100644 --- a/src/animaltrack/web/routes/__init__.py +++ b/src/animaltrack/web/routes/__init__.py @@ -2,6 +2,7 @@ # ABOUTME: Contains modular route handlers for different features. from animaltrack.web.routes.eggs import register_egg_routes +from animaltrack.web.routes.events import register_events_routes from animaltrack.web.routes.feed import register_feed_routes from animaltrack.web.routes.health import register_health_routes from animaltrack.web.routes.move import register_move_routes @@ -9,6 +10,7 @@ from animaltrack.web.routes.registry import register_registry_routes __all__ = [ "register_egg_routes", + "register_events_routes", "register_feed_routes", "register_health_routes", "register_move_routes", diff --git a/src/animaltrack/web/routes/eggs.py b/src/animaltrack/web/routes/eggs.py index e3bd23b..30bdd27 100644 --- a/src/animaltrack/web/routes/eggs.py +++ b/src/animaltrack/web/routes/eggs.py @@ -13,7 +13,7 @@ from starlette.responses import HTMLResponse from animaltrack.events.payloads import ProductCollectedPayload from animaltrack.events.store import EventStore -from animaltrack.projections import ProjectionRegistry +from animaltrack.projections import EventLogProjection, ProjectionRegistry from animaltrack.projections.animal_registry import AnimalRegistryProjection from animaltrack.projections.event_animals import EventAnimalsProjection from animaltrack.projections.intervals import IntervalProjection @@ -108,6 +108,7 @@ async def product_collected(request: Request): registry.register(EventAnimalsProjection(db)) registry.register(IntervalProjection(db)) registry.register(ProductsProjection(db)) + registry.register(EventLogProjection(db)) product_service = ProductService(db, event_store, registry) diff --git a/src/animaltrack/web/routes/events.py b/src/animaltrack/web/routes/events.py new file mode 100644 index 0000000..5568087 --- /dev/null +++ b/src/animaltrack/web/routes/events.py @@ -0,0 +1,101 @@ +# ABOUTME: Routes for event log functionality. +# ABOUTME: Handles GET /event-log for viewing location event history. + +from __future__ import annotations + +import json +from typing import Any + +from fasthtml.common import to_xml +from starlette.requests import Request +from starlette.responses import HTMLResponse + +from animaltrack.repositories.locations import LocationRepository +from animaltrack.web.templates import page +from animaltrack.web.templates.events import event_log_list, event_log_panel + + +def get_event_log(db: Any, location_id: str, limit: int = 100) -> list[dict[str, Any]]: + """Get event log entries for a location. + + Args: + db: Database connection. + location_id: Location ID to get events for. + limit: Maximum number of events to return. + + Returns: + List of event log entries, newest first. + """ + rows = db.execute( + """ + SELECT event_id, location_id, ts_utc, type, actor, summary + FROM event_log_by_location + WHERE location_id = ? + ORDER BY ts_utc DESC + LIMIT ? + """, + (location_id, limit), + ).fetchall() + + events = [] + for row in rows: + events.append( + { + "event_id": row[0], + "location_id": row[1], + "ts_utc": row[2], + "type": row[3], + "actor": row[4], + "summary": json.loads(row[5]), + } + ) + return events + + +def event_log_index(request: Request): + """GET /event-log - Event log for a location.""" + db = request.app.state.db + + # Get location_id from query params + location_id = request.query_params.get("location_id") + if not location_id: + return HTMLResponse( + content="

Missing location_id parameter

", + status_code=422, + ) + + # Get location name + location_repo = LocationRepository(db) + locations = location_repo.list_active() + location_name = "Unknown" + for loc in locations: + if loc.id == location_id: + location_name = loc.name + break + + # Get event log + events = get_event_log(db, location_id) + + # Check if HTMX request + is_htmx = request.headers.get("HX-Request") == "true" + + if is_htmx: + # Return partial - just the event list + return HTMLResponse(content=to_xml(event_log_list(events))) + + # Full page render + return page( + event_log_panel(events, location_name), + title=f"Event Log - {location_name}", + active_nav=None, + ) + + +def register_events_routes(rt, app) -> None: + """Register event log routes. + + Args: + rt: FastHTML route decorator. + app: FastHTML app instance (unused, for consistency). + """ + rt("/event-log")(event_log_index) diff --git a/src/animaltrack/web/routes/feed.py b/src/animaltrack/web/routes/feed.py index 5387cf8..bf8cff3 100644 --- a/src/animaltrack/web/routes/feed.py +++ b/src/animaltrack/web/routes/feed.py @@ -12,7 +12,7 @@ from starlette.responses import HTMLResponse from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload from animaltrack.events.store import EventStore -from animaltrack.projections import ProjectionRegistry +from animaltrack.projections import EventLogProjection, ProjectionRegistry from animaltrack.projections.feed import FeedInventoryProjection from animaltrack.repositories.feed_types import FeedTypeRepository from animaltrack.repositories.locations import LocationRepository @@ -133,6 +133,7 @@ async def feed_given(request: Request): event_store = EventStore(db) registry = ProjectionRegistry() registry.register(FeedInventoryProjection(db)) + registry.register(EventLogProjection(db)) feed_service = FeedService(db, event_store, registry) diff --git a/src/animaltrack/web/routes/move.py b/src/animaltrack/web/routes/move.py index 8157ee0..8d72276 100644 --- a/src/animaltrack/web/routes/move.py +++ b/src/animaltrack/web/routes/move.py @@ -13,7 +13,7 @@ from starlette.responses import HTMLResponse from animaltrack.events.payloads import AnimalMovedPayload from animaltrack.events.store import EventStore -from animaltrack.projections import ProjectionRegistry +from animaltrack.projections import EventLogProjection, ProjectionRegistry from animaltrack.projections.animal_registry import AnimalRegistryProjection from animaltrack.projections.event_animals import EventAnimalsProjection from animaltrack.projections.intervals import IntervalProjection @@ -216,6 +216,7 @@ async def animal_move(request: Request): registry.register(AnimalRegistryProjection(db)) registry.register(EventAnimalsProjection(db)) registry.register(IntervalProjection(db)) + registry.register(EventLogProjection(db)) animal_service = AnimalService(db, event_store, registry) diff --git a/src/animaltrack/web/templates/events.py b/src/animaltrack/web/templates/events.py new file mode 100644 index 0000000..19dda01 --- /dev/null +++ b/src/animaltrack/web/templates/events.py @@ -0,0 +1,130 @@ +# ABOUTME: Templates for the event log view. +# ABOUTME: Renders event log entries for a location with timeline styling. + +from datetime import UTC, datetime +from typing import Any + +from fasthtml.common import H3, Div, Li, P, Span, Ul + + +def format_timestamp(ts_utc: int) -> str: + """Format a timestamp as a human-readable string.""" + dt = datetime.fromtimestamp(ts_utc / 1000, tz=UTC) + return dt.strftime("%Y-%m-%d %H:%M") + + +def format_event_summary(event_type: str, summary: dict[str, Any]) -> str: + """Format event summary for display.""" + if event_type == "ProductCollected": + product = summary.get("product_code", "product") + qty = summary.get("quantity", 0) + return f"Collected {qty} {product}" + + if event_type == "AnimalCohortCreated": + species = summary.get("species", "animals") + count = summary.get("count", 0) + origin = summary.get("origin", "unknown") + return f"Created cohort: {count} {species} ({origin})" + + if event_type == "FeedGiven": + feed_type = summary.get("feed_type_code", "feed") + amount = summary.get("amount_kg", 0) + return f"Fed {amount}kg {feed_type}" + + if event_type == "AnimalMoved": + count = summary.get("animal_count", 0) + return f"Moved {count} animal(s) here" + + if event_type == "HatchRecorded": + species = summary.get("species", "") + count = summary.get("hatched_live", 0) + return f"Hatched {count} {species}" + + if event_type == "AnimalTagged": + tag = summary.get("tag", "") + count = summary.get("animal_count", 0) + return f"Tagged {count} animal(s) as '{tag}'" + + if event_type == "AnimalTagEnded": + tag = summary.get("tag", "") + count = summary.get("animal_count", 0) + return f"Removed tag '{tag}' from {count} animal(s)" + + if event_type == "AnimalOutcome": + outcome = summary.get("outcome", "unknown") + count = summary.get("animal_count", 0) + return f"{outcome.capitalize()}: {count} animal(s)" + + # Fallback + return event_type + + +def event_type_badge_class(event_type: str) -> str: + """Get badge color class for event type.""" + type_colors = { + "ProductCollected": "bg-amber-100 text-amber-800", + "AnimalCohortCreated": "bg-green-100 text-green-800", + "FeedGiven": "bg-blue-100 text-blue-800", + "AnimalMoved": "bg-purple-100 text-purple-800", + "HatchRecorded": "bg-pink-100 text-pink-800", + "AnimalTagged": "bg-indigo-100 text-indigo-800", + "AnimalTagEnded": "bg-slate-100 text-slate-800", + "AnimalOutcome": "bg-red-100 text-red-800", + } + return type_colors.get(event_type, "bg-gray-100 text-gray-800") + + +def event_log_item( + event_id: str, + event_type: str, + ts_utc: int, + actor: str, + summary: dict[str, Any], +) -> Any: + """Render a single event log item.""" + badge_cls = event_type_badge_class(event_type) + summary_text = format_event_summary(event_type, summary) + time_str = format_timestamp(ts_utc) + + return Li( + Div( + Span(event_type, cls=f"text-xs font-medium px-2 py-1 rounded {badge_cls}"), + Span(time_str, cls="text-xs text-stone-500 ml-2"), + cls="flex items-center gap-2 mb-1", + ), + P(summary_text, cls="text-sm text-stone-700"), + P(f"by {actor}", cls="text-xs text-stone-400"), + cls="py-3 border-b border-stone-200 last:border-0", + ) + + +def event_log_list(events: list[dict[str, Any]]) -> Any: + """Render the event log list.""" + if not events: + return Div( + P("No events recorded at this location yet.", cls="text-stone-500 text-sm"), + cls="p-4 text-center", + ) + + items = [ + event_log_item( + event_id=e["event_id"], + event_type=e["type"], + ts_utc=e["ts_utc"], + actor=e["actor"], + summary=e["summary"], + ) + for e in events + ] + + return Ul(*items, cls="divide-y divide-stone-200") + + +def event_log_panel(events: list[dict[str, Any]], location_name: str) -> Any: + """Render the full event log panel.""" + return Div( + H3(f"Event Log - {location_name}", cls="text-lg font-semibold mb-4"), + event_log_list(events), + cls="bg-white rounded-lg shadow p-4", + id="event-log", + ) diff --git a/tests/test_migration_event_log.py b/tests/test_migration_event_log.py new file mode 100644 index 0000000..6a7099c --- /dev/null +++ b/tests/test_migration_event_log.py @@ -0,0 +1,267 @@ +# ABOUTME: Tests for the event_log_by_location migration (0008-event-log-by-location.sql). +# ABOUTME: Validates table schema, constraints, index, and cap trigger. + +import json + +import apsw +import pytest + + +class TestMigrationCreatesTable: + """Tests that migration creates the event_log_by_location table.""" + + def test_event_log_by_location_table_exists(self, seeded_db): + """Migration creates event_log_by_location table.""" + result = seeded_db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='event_log_by_location'" + ).fetchone() + assert result is not None + assert result[0] == "event_log_by_location" + + +class TestEventLogByLocationTable: + """Tests for event_log_by_location table schema and constraints.""" + + @pytest.fixture + def valid_location_id(self, seeded_db): + """Get Strip 1 location ID from seeds.""" + row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone() + return row[0] + + def test_insert_valid_event_log(self, seeded_db, valid_location_id): + """Can insert valid event log entry.""" + event_id = "01ARZ3NDEKTSV4RRFFQ69G5FAA" + summary = json.dumps({"eggs": 5, "species": "duck"}) + + seeded_db.execute( + """ + INSERT INTO event_log_by_location (event_id, location_id, ts_utc, type, actor, summary) + VALUES (?, ?, ?, ?, ?, ?) + """, + (event_id, valid_location_id, 1704067200000, "ProductCollected", "ppetru", summary), + ) + result = seeded_db.execute( + "SELECT event_id, location_id, ts_utc, type, actor, summary FROM event_log_by_location" + ).fetchone() + assert result[0] == event_id + assert result[1] == valid_location_id + assert result[2] == 1704067200000 + assert result[3] == "ProductCollected" + assert result[4] == "ppetru" + assert json.loads(result[5]) == {"eggs": 5, "species": "duck"} + + def test_event_id_is_primary_key(self, seeded_db, valid_location_id): + """event_id is the primary key.""" + event_id = "01ARZ3NDEKTSV4RRFFQ69G5FAA" + summary = json.dumps({"msg": "test"}) + + seeded_db.execute( + """ + INSERT INTO event_log_by_location (event_id, location_id, ts_utc, type, actor, summary) + VALUES (?, ?, ?, ?, ?, ?) + """, + (event_id, valid_location_id, 1704067200000, "ProductCollected", "ppetru", summary), + ) + # Same event_id should fail + with pytest.raises(apsw.ConstraintError): + seeded_db.execute( + """ + INSERT INTO event_log_by_location (event_id, location_id, ts_utc, type, actor, summary) + VALUES (?, ?, ?, ?, ?, ?) + """, + (event_id, valid_location_id, 1704067200001, "FeedGiven", "ines", summary), + ) + + def test_location_id_foreign_key(self, seeded_db): + """location_id must reference existing location.""" + event_id = "01ARZ3NDEKTSV4RRFFQ69G5FAA" + invalid_location = "01ARZ3NDEKTSV4RRFFQ69XXXXX" + summary = json.dumps({"msg": "test"}) + + with pytest.raises(apsw.ConstraintError): + seeded_db.execute( + """ + INSERT INTO event_log_by_location (event_id, location_id, ts_utc, type, actor, summary) + VALUES (?, ?, ?, ?, ?, ?) + """, + (event_id, invalid_location, 1704067200000, "ProductCollected", "ppetru", summary), + ) + + def test_summary_must_be_valid_json(self, seeded_db, valid_location_id): + """summary must be valid JSON.""" + event_id = "01ARZ3NDEKTSV4RRFFQ69G5FAA" + + with pytest.raises(apsw.ConstraintError): + seeded_db.execute( + """ + INSERT INTO event_log_by_location (event_id, location_id, ts_utc, type, actor, summary) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + event_id, + valid_location_id, + 1704067200000, + "ProductCollected", + "ppetru", + "not json", + ), + ) + + +class TestEventLogIndex: + """Tests for event_log_by_location index.""" + + def test_location_ts_index_exists(self, seeded_db): + """Index idx_evlog_loc_ts exists.""" + result = seeded_db.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_evlog_loc_ts'" + ).fetchone() + assert result is not None + assert result[0] == "idx_evlog_loc_ts" + + +class TestEventLogCapTrigger: + """Tests for the cap trigger that limits to 500 events per location.""" + + @pytest.fixture + def valid_location_id(self, seeded_db): + """Get Strip 1 location ID from seeds.""" + row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone() + return row[0] + + @pytest.fixture + def strip2_location_id(self, seeded_db): + """Get Strip 2 location ID from seeds.""" + row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone() + return row[0] + + def test_trigger_exists(self, seeded_db): + """Trigger trg_evlog_cap exists.""" + result = seeded_db.execute( + "SELECT name FROM sqlite_master WHERE type='trigger' AND name='trg_evlog_cap'" + ).fetchone() + assert result is not None + assert result[0] == "trg_evlog_cap" + + def test_trigger_caps_at_500_per_location(self, seeded_db, valid_location_id): + """Trigger removes oldest events when more than 500 are inserted.""" + summary = json.dumps({"msg": "test"}) + + # Insert 501 events + for i in range(501): + event_id = f"01ARZ3NDEKTSV4RRFFQ69G{i:04d}A" + seeded_db.execute( + """ + INSERT INTO event_log_by_location (event_id, location_id, ts_utc, type, actor, summary) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + event_id, + valid_location_id, + 1704067200000 + i, + "ProductCollected", + "ppetru", + summary, + ), + ) + + # Should have exactly 500 + count = seeded_db.execute( + "SELECT COUNT(*) FROM event_log_by_location WHERE location_id = ?", + (valid_location_id,), + ).fetchone()[0] + assert count == 500 + + # The oldest event (i=0) should be deleted + oldest_event = seeded_db.execute( + """ + SELECT event_id FROM event_log_by_location + WHERE location_id = ? + ORDER BY ts_utc ASC + LIMIT 1 + """, + (valid_location_id,), + ).fetchone() + # Event 0 was deleted, so oldest should be event 1 + assert oldest_event[0] == "01ARZ3NDEKTSV4RRFFQ69G0001A" + + def test_trigger_scopes_to_location(self, seeded_db, valid_location_id, strip2_location_id): + """Cap trigger only removes events from same location.""" + summary = json.dumps({"msg": "test"}) + + # Insert 500 events at location 1 + for i in range(500): + event_id = f"01ARZ3NDEKTSV4RRFFQ69G{i:04d}A" + seeded_db.execute( + """ + INSERT INTO event_log_by_location (event_id, location_id, ts_utc, type, actor, summary) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + event_id, + valid_location_id, + 1704067200000 + i, + "ProductCollected", + "ppetru", + summary, + ), + ) + + # Insert 1 event at location 2 + seeded_db.execute( + """ + INSERT INTO event_log_by_location (event_id, location_id, ts_utc, type, actor, summary) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + "01ARZ3NDEKTSV4RRFFQ69GXXXXA", + strip2_location_id, + 1704067200000, + "FeedGiven", + "ppetru", + summary, + ), + ) + + # Location 1 should still have 500 + count1 = seeded_db.execute( + "SELECT COUNT(*) FROM event_log_by_location WHERE location_id = ?", + (valid_location_id,), + ).fetchone()[0] + assert count1 == 500 + + # Location 2 should have 1 + count2 = seeded_db.execute( + "SELECT COUNT(*) FROM event_log_by_location WHERE location_id = ?", + (strip2_location_id,), + ).fetchone()[0] + assert count2 == 1 + + # Insert one more at location 1 to trigger cap + seeded_db.execute( + """ + INSERT INTO event_log_by_location (event_id, location_id, ts_utc, type, actor, summary) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + "01ARZ3NDEKTSV4RRFFQ69GYYYYA", + valid_location_id, + 1704067200501, + "ProductCollected", + "ppetru", + summary, + ), + ) + + # Location 1 should still have 500, location 2 should still have 1 + count1 = seeded_db.execute( + "SELECT COUNT(*) FROM event_log_by_location WHERE location_id = ?", + (valid_location_id,), + ).fetchone()[0] + assert count1 == 500 + + count2 = seeded_db.execute( + "SELECT COUNT(*) FROM event_log_by_location WHERE location_id = ?", + (strip2_location_id,), + ).fetchone()[0] + assert count2 == 1 diff --git a/tests/test_projection_event_log.py b/tests/test_projection_event_log.py new file mode 100644 index 0000000..a79590d --- /dev/null +++ b/tests/test_projection_event_log.py @@ -0,0 +1,454 @@ +# 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" diff --git a/tests/test_web_events.py b/tests/test_web_events.py new file mode 100644 index 0000000..a5b7424 --- /dev/null +++ b/tests/test_web_events.py @@ -0,0 +1,217 @@ +# ABOUTME: Tests for event log routes. +# ABOUTME: Covers GET /event-log rendering and filtering by location. + +import os +import time + +import pytest +from starlette.testclient import TestClient + +from animaltrack.events.payloads import AnimalCohortCreatedPayload, ProductCollectedPayload +from animaltrack.events.store import EventStore +from animaltrack.projections import ( + AnimalRegistryProjection, + EventAnimalsProjection, + EventLogProjection, + IntervalProjection, + ProductsProjection, + ProjectionRegistry, +) +from animaltrack.services.animal import AnimalService +from animaltrack.services.products import ProductService + + +def make_test_settings( + csrf_secret: str = "test-secret", + trusted_proxy_ips: str = "127.0.0.1", + dev_mode: bool = True, +): + """Create Settings for testing by setting env vars temporarily.""" + from animaltrack.config import Settings + + old_env = os.environ.copy() + try: + os.environ["CSRF_SECRET"] = csrf_secret + os.environ["TRUSTED_PROXY_IPS"] = trusted_proxy_ips + os.environ["DEV_MODE"] = str(dev_mode).lower() + return Settings() + finally: + os.environ.clear() + os.environ.update(old_env) + + +@pytest.fixture +def client(seeded_db): + """Create a test client for the app.""" + from animaltrack.web.app import create_app + + settings = make_test_settings(trusted_proxy_ips="testclient") + app, rt = create_app(settings=settings, db=seeded_db) + return TestClient(app, raise_server_exceptions=True) + + +@pytest.fixture +def valid_location_id(seeded_db): + """Get Strip 1 location ID from seeds.""" + row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone() + return row[0] + + +@pytest.fixture +def strip2_location_id(seeded_db): + """Get Strip 2 location ID from seeds.""" + row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone() + return row[0] + + +@pytest.fixture +def animal_service(seeded_db): + """Create an AnimalService for testing.""" + event_store = EventStore(seeded_db) + registry = ProjectionRegistry() + registry.register(AnimalRegistryProjection(seeded_db)) + registry.register(EventAnimalsProjection(seeded_db)) + registry.register(IntervalProjection(seeded_db)) + registry.register(EventLogProjection(seeded_db)) + return AnimalService(seeded_db, event_store, registry) + + +@pytest.fixture +def product_service(seeded_db): + """Create a ProductService for testing.""" + event_store = EventStore(seeded_db) + registry = ProjectionRegistry() + registry.register(AnimalRegistryProjection(seeded_db)) + registry.register(EventAnimalsProjection(seeded_db)) + registry.register(IntervalProjection(seeded_db)) + registry.register(ProductsProjection(seeded_db)) + registry.register(EventLogProjection(seeded_db)) + return ProductService(seeded_db, event_store, registry) + + +def create_cohort(animal_service, location_id, count=3): + """Helper to create a cohort and return animal IDs.""" + ts_utc = int(time.time() * 1000) + payload = AnimalCohortCreatedPayload( + species="duck", + count=count, + life_stage="adult", + sex="unknown", + location_id=location_id, + origin="purchased", + ) + event = animal_service.create_cohort(payload, ts_utc, "test_user") + return event.entity_refs["animal_ids"] + + +class TestEventLogRoute: + """Tests for GET /event-log route.""" + + def test_event_log_requires_location_id(self, client): + """Event log requires location_id parameter.""" + response = client.get("/event-log") + assert response.status_code == 422 + + def test_event_log_returns_empty_for_new_location(self, client, valid_location_id): + """Event log returns empty state for location with no events.""" + response = client.get(f"/event-log?location_id={valid_location_id}") + assert response.status_code == 200 + # Should show empty state message + assert "No events" in response.text or "event-log" in response.text + + def test_event_log_shows_events(self, client, seeded_db, animal_service, valid_location_id): + """Event log shows events for the location.""" + # Create some animals (creates AnimalCohortCreated event) + create_cohort(animal_service, valid_location_id, count=5) + + response = client.get(f"/event-log?location_id={valid_location_id}") + assert response.status_code == 200 + assert "AnimalCohortCreated" in response.text or "cohort" in response.text.lower() + + def test_event_log_shows_product_collected( + self, client, seeded_db, animal_service, product_service, valid_location_id + ): + """Event log shows ProductCollected events.""" + # Create animals first + animal_ids = create_cohort(animal_service, valid_location_id, count=3) + + # Collect eggs + ts_utc = int(time.time() * 1000) + payload = ProductCollectedPayload( + location_id=valid_location_id, + product_code="egg.duck", + quantity=5, + resolved_ids=animal_ids, + ) + product_service.collect_product(payload, ts_utc, "test_user") + + response = client.get(f"/event-log?location_id={valid_location_id}") + assert response.status_code == 200 + assert "ProductCollected" in response.text or "egg" in response.text.lower() + + def test_event_log_filters_by_location( + self, client, seeded_db, animal_service, valid_location_id, strip2_location_id + ): + """Event log only shows events for the specified location.""" + # Create animals at location 1 + create_cohort(animal_service, valid_location_id, count=3) + + # Create animals at location 2 + create_cohort(animal_service, strip2_location_id, count=2) + + # Get events for location 2 only + response = client.get(f"/event-log?location_id={strip2_location_id}") + assert response.status_code == 200 + + # Should see location 2 events only + # Count the events displayed + text = response.text + # Location 2 should have 1 event (cohort of 2) + assert "count" in text.lower() or "2" in text + + def test_event_log_orders_by_time_descending( + self, client, seeded_db, animal_service, product_service, valid_location_id + ): + """Event log shows newest events first.""" + # Create cohort first + animal_ids = create_cohort(animal_service, valid_location_id, count=3) + + # Then collect eggs + ts_utc = int(time.time() * 1000) + 1000 + payload = ProductCollectedPayload( + location_id=valid_location_id, + product_code="egg.duck", + quantity=5, + resolved_ids=animal_ids, + ) + product_service.collect_product(payload, ts_utc, "test_user") + + response = client.get(f"/event-log?location_id={valid_location_id}") + assert response.status_code == 200 + + # ProductCollected should appear before AnimalCohortCreated (newer first) + text = response.text + # Check that the response contains both event types + cohort_pos = text.find("Cohort") if "Cohort" in text else text.find("cohort") + egg_pos = text.find("egg") if "egg" in text else text.find("Product") + + # Both should be present + assert cohort_pos != -1 or egg_pos != -1 + + +class TestEventLogPartial: + """Tests for HTMX partial responses.""" + + def test_htmx_request_returns_partial( + self, client, seeded_db, animal_service, valid_location_id + ): + """HTMX request returns partial HTML without full page wrapper.""" + create_cohort(animal_service, valid_location_id) + + response = client.get( + f"/event-log?location_id={valid_location_id}", + headers={"HX-Request": "true"}, + ) + assert response.status_code == 200 + # Partial should not have full page structure + assert "