From 29d5d3f8ff9963f109bd5b4b9b00441d0620bbb6 Mon Sep 17 00:00:00 2001 From: Petru Paler Date: Sun, 28 Dec 2025 07:35:05 +0000 Subject: [PATCH] feat: add event store with nonce and clock skew validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Step 2.2 of the plan: - EventStore class with append_event, get_event, list_events, is_tombstoned - Event type constants for all 17 event types from spec - ClockSkewError for rejecting timestamps >5 min in future - DuplicateNonceError for idempotency nonce validation - 25 tests covering all event store functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/animaltrack/events/__init__.py | 52 ++++ src/animaltrack/events/exceptions.py | 18 ++ src/animaltrack/events/store.py | 247 ++++++++++++++++++ src/animaltrack/events/types.py | 48 ++++ tests/test_event_store.py | 361 +++++++++++++++++++++++++++ tests/test_event_types.py | 94 +++++++ 6 files changed, 820 insertions(+) create mode 100644 src/animaltrack/events/__init__.py create mode 100644 src/animaltrack/events/exceptions.py create mode 100644 src/animaltrack/events/store.py create mode 100644 src/animaltrack/events/types.py create mode 100644 tests/test_event_store.py create mode 100644 tests/test_event_types.py diff --git a/src/animaltrack/events/__init__.py b/src/animaltrack/events/__init__.py new file mode 100644 index 0000000..d79dd54 --- /dev/null +++ b/src/animaltrack/events/__init__.py @@ -0,0 +1,52 @@ +# ABOUTME: Events package for event sourcing infrastructure. +# ABOUTME: Provides event types, store, and related exceptions. + +from animaltrack.events.exceptions import ClockSkewError, DuplicateNonceError +from animaltrack.events.store import EventStore +from animaltrack.events.types import ( + ALL_EVENT_TYPES, + ANIMAL_ATTRIBUTES_UPDATED, + ANIMAL_COHORT_CREATED, + ANIMAL_MERGED, + ANIMAL_MOVED, + ANIMAL_OUTCOME, + ANIMAL_PROMOTED, + ANIMAL_STATUS_CORRECTED, + ANIMAL_TAG_ENDED, + ANIMAL_TAGGED, + FEED_GIVEN, + FEED_PURCHASED, + HATCH_RECORDED, + LOCATION_ARCHIVED, + LOCATION_CREATED, + LOCATION_RENAMED, + PRODUCT_COLLECTED, + PRODUCT_SOLD, +) + +__all__ = [ + # Event types + "LOCATION_CREATED", + "LOCATION_RENAMED", + "LOCATION_ARCHIVED", + "ANIMAL_COHORT_CREATED", + "ANIMAL_PROMOTED", + "ANIMAL_MOVED", + "ANIMAL_ATTRIBUTES_UPDATED", + "ANIMAL_TAGGED", + "ANIMAL_TAG_ENDED", + "HATCH_RECORDED", + "ANIMAL_OUTCOME", + "ANIMAL_MERGED", + "ANIMAL_STATUS_CORRECTED", + "PRODUCT_COLLECTED", + "PRODUCT_SOLD", + "FEED_PURCHASED", + "FEED_GIVEN", + "ALL_EVENT_TYPES", + # Exceptions + "ClockSkewError", + "DuplicateNonceError", + # Store + "EventStore", +] diff --git a/src/animaltrack/events/exceptions.py b/src/animaltrack/events/exceptions.py new file mode 100644 index 0000000..adf0c06 --- /dev/null +++ b/src/animaltrack/events/exceptions.py @@ -0,0 +1,18 @@ +# ABOUTME: Custom exceptions for the event store. +# ABOUTME: Includes ClockSkewError and DuplicateNonceError. + + +class ClockSkewError(Exception): + """Raised when ts_utc is more than 5 minutes in the future. + + This guards against events with timestamps too far in the future, + which could cause issues with time-based queries and projections. + """ + + +class DuplicateNonceError(Exception): + """Raised when an idempotency nonce has already been used. + + Each POST form submission should have a unique nonce to prevent + duplicate event creation from double-submissions. + """ diff --git a/src/animaltrack/events/store.py b/src/animaltrack/events/store.py new file mode 100644 index 0000000..79fbc54 --- /dev/null +++ b/src/animaltrack/events/store.py @@ -0,0 +1,247 @@ +# ABOUTME: Core event store for appending, retrieving, and listing events. +# ABOUTME: Implements nonce validation and clock skew guards per spec. + +import json +import time +from typing import Any + +from animaltrack.events.exceptions import ClockSkewError, DuplicateNonceError +from animaltrack.id_gen import generate_id +from animaltrack.models.events import Event + +# Maximum allowed clock skew: 5 minutes in milliseconds +MAX_CLOCK_SKEW_MS = 5 * 60 * 1000 + + +class EventStore: + """Repository for event sourcing operations. + + Provides methods to append new events, retrieve existing events, + and list events with various filters. Includes validation for + clock skew and idempotency nonces. + """ + + def __init__(self, db: Any) -> None: + """Initialize the event store with a database connection. + + Args: + db: A fastlite database connection. + """ + self.db = db + + def append_event( + self, + event_type: str, + ts_utc: int, + actor: str, + entity_refs: dict, + payload: dict, + nonce: str | None = None, + route: str | None = None, + ) -> Event: + """Append a new event to the store. + + Creates a new event with all validations: + 1. Clock skew check (ts_utc <= now + 5min) + 2. Nonce validation (if provided, reject duplicates) + 3. Generate ULID for event + 4. Insert into events table + 5. Record nonce (if provided) in idempotency_nonces + + Args: + event_type: The type of event (e.g., ProductCollected). + ts_utc: Timestamp in milliseconds since epoch. + actor: The user or system creating the event. + entity_refs: JSON-serializable dict of entity references. + payload: JSON-serializable dict of event payload. + nonce: Optional idempotency nonce (ULID). + route: Required if nonce is provided; the endpoint route. + + Returns: + The created Event model. + + Raises: + ClockSkewError: If ts_utc is more than 5 minutes in the future. + DuplicateNonceError: If nonce has already been used. + ValueError: If nonce is provided without route. + """ + # Validate clock skew + self._check_clock_skew(ts_utc) + + # Validate nonce requirements + if nonce is not None and route is None: + msg = "route is required when nonce is provided" + raise ValueError(msg) + + # Check for duplicate nonce + if nonce is not None: + self._check_nonce(nonce) + + # Generate event ID + event_id = generate_id() + + # Serialize JSON fields + entity_refs_json = json.dumps(entity_refs) + payload_json = json.dumps(payload) + + # Insert event + self.db.execute( + """INSERT INTO events (id, type, ts_utc, actor, entity_refs, payload, version) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + (event_id, event_type, ts_utc, actor, entity_refs_json, payload_json, 1), + ) + + # Record nonce if provided + if nonce is not None: + self.db.execute( + """INSERT INTO idempotency_nonces (nonce, actor, route, created_at_utc) + VALUES (?, ?, ?, ?)""", + (nonce, actor, route, ts_utc), + ) + + return Event( + id=event_id, + type=event_type, + ts_utc=ts_utc, + actor=actor, + entity_refs=entity_refs, + payload=payload, + version=1, + ) + + def get_event(self, event_id: str) -> Event | None: + """Retrieve an event by ID. + + Args: + event_id: The ULID of the event. + + Returns: + The Event if found, None otherwise. + """ + row = self.db.execute( + """SELECT id, type, ts_utc, actor, entity_refs, payload, version + FROM events WHERE id = ?""", + (event_id,), + ).fetchone() + + if row is None: + return None + + return Event( + id=row[0], + type=row[1], + ts_utc=row[2], + actor=row[3], + entity_refs=json.loads(row[4]), + payload=json.loads(row[5]), + version=row[6], + ) + + def list_events( + self, + event_type: str | None = None, + since_utc: int | None = None, + until_utc: int | None = None, + actor: str | None = None, + limit: int = 100, + ) -> list[Event]: + """List events with optional filters. + + Args: + event_type: Filter by event type. + since_utc: Include events with ts_utc >= since_utc. + until_utc: Include events with ts_utc <= until_utc. + actor: Filter by actor. + limit: Maximum number of events to return. + + Returns: + List of events ordered by ts_utc ASC. + """ + query = "SELECT id, type, ts_utc, actor, entity_refs, payload, version FROM events" + conditions = [] + params: list = [] + + if event_type is not None: + conditions.append("type = ?") + params.append(event_type) + + if since_utc is not None: + conditions.append("ts_utc >= ?") + params.append(since_utc) + + if until_utc is not None: + conditions.append("ts_utc <= ?") + params.append(until_utc) + + if actor is not None: + conditions.append("actor = ?") + params.append(actor) + + if conditions: + query += " WHERE " + " AND ".join(conditions) + + query += " ORDER BY ts_utc ASC" + query += f" LIMIT {limit}" + + rows = self.db.execute(query, tuple(params)).fetchall() + + return [ + Event( + id=row[0], + type=row[1], + ts_utc=row[2], + actor=row[3], + entity_refs=json.loads(row[4]), + payload=json.loads(row[5]), + version=row[6], + ) + for row in rows + ] + + def is_tombstoned(self, event_id: str) -> bool: + """Check if an event has been tombstoned (soft deleted). + + Args: + event_id: The ULID of the event to check. + + Returns: + True if a tombstone exists for the event, False otherwise. + """ + row = self.db.execute( + "SELECT 1 FROM event_tombstones WHERE target_event_id = ?", + (event_id,), + ).fetchone() + + return row is not None + + def _check_clock_skew(self, ts_utc: int) -> None: + """Validate that ts_utc is not too far in the future. + + Args: + ts_utc: Timestamp to validate. + + Raises: + ClockSkewError: If ts_utc is more than 5 minutes in the future. + """ + now_ms = int(time.time() * 1000) + if ts_utc > now_ms + MAX_CLOCK_SKEW_MS: + msg = f"ts_utc {ts_utc} is more than 5 minutes in the future" + raise ClockSkewError(msg) + + def _check_nonce(self, nonce: str) -> None: + """Check if a nonce has already been used. + + Args: + nonce: The idempotency nonce to check. + + Raises: + DuplicateNonceError: If the nonce already exists. + """ + existing = self.db.execute( + "SELECT 1 FROM idempotency_nonces WHERE nonce = ?", + (nonce,), + ).fetchone() + + if existing is not None: + msg = f"Nonce {nonce} has already been used" + raise DuplicateNonceError(msg) diff --git a/src/animaltrack/events/types.py b/src/animaltrack/events/types.py new file mode 100644 index 0000000..08db2b9 --- /dev/null +++ b/src/animaltrack/events/types.py @@ -0,0 +1,48 @@ +# ABOUTME: Event type constants for the event sourcing system. +# ABOUTME: Defines all event types as specified in spec section 6. + +# Location events +LOCATION_CREATED = "LocationCreated" +LOCATION_RENAMED = "LocationRenamed" +LOCATION_ARCHIVED = "LocationArchived" + +# Animal events +ANIMAL_COHORT_CREATED = "AnimalCohortCreated" +ANIMAL_PROMOTED = "AnimalPromoted" +ANIMAL_MOVED = "AnimalMoved" +ANIMAL_ATTRIBUTES_UPDATED = "AnimalAttributesUpdated" +ANIMAL_TAGGED = "AnimalTagged" +ANIMAL_TAG_ENDED = "AnimalTagEnded" +HATCH_RECORDED = "HatchRecorded" +ANIMAL_OUTCOME = "AnimalOutcome" +ANIMAL_MERGED = "AnimalMerged" +ANIMAL_STATUS_CORRECTED = "AnimalStatusCorrected" + +# Product events +PRODUCT_COLLECTED = "ProductCollected" +PRODUCT_SOLD = "ProductSold" + +# Feed events +FEED_PURCHASED = "FeedPurchased" +FEED_GIVEN = "FeedGiven" + +# List of all event types for validation +ALL_EVENT_TYPES = [ + LOCATION_CREATED, + LOCATION_RENAMED, + LOCATION_ARCHIVED, + ANIMAL_COHORT_CREATED, + ANIMAL_PROMOTED, + ANIMAL_MOVED, + ANIMAL_ATTRIBUTES_UPDATED, + ANIMAL_TAGGED, + ANIMAL_TAG_ENDED, + HATCH_RECORDED, + ANIMAL_OUTCOME, + ANIMAL_MERGED, + ANIMAL_STATUS_CORRECTED, + PRODUCT_COLLECTED, + PRODUCT_SOLD, + FEED_PURCHASED, + FEED_GIVEN, +] diff --git a/tests/test_event_store.py b/tests/test_event_store.py new file mode 100644 index 0000000..3b5effe --- /dev/null +++ b/tests/test_event_store.py @@ -0,0 +1,361 @@ +# ABOUTME: Tests for the EventStore class. +# ABOUTME: Validates event creation, retrieval, nonce validation, and clock skew guards. + +import time + +import pytest + +from animaltrack.events import ( + PRODUCT_COLLECTED, + ClockSkewError, + DuplicateNonceError, +) +from animaltrack.events.store import EventStore +from animaltrack.id_gen import generate_id + + +@pytest.fixture +def now_utc(): + """Current time in milliseconds since epoch.""" + return int(time.time() * 1000) + + +@pytest.fixture +def event_store(migrated_db): + """Create an EventStore instance with a migrated database.""" + return EventStore(migrated_db) + + +class TestEventStore: + """Tests for basic EventStore operations.""" + + def test_append_event_creates_event(self, event_store, now_utc): + """append_event creates and returns an Event with correct fields.""" + event = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc, + actor="ppetru", + entity_refs={"location_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV"}, + payload={"product_code": "egg.duck", "quantity": 5}, + ) + + assert event.type == PRODUCT_COLLECTED + assert event.ts_utc == now_utc + assert event.actor == "ppetru" + assert event.entity_refs == {"location_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV"} + assert event.payload == {"product_code": "egg.duck", "quantity": 5} + assert event.version == 1 + + def test_append_event_generates_ulid(self, event_store, now_utc): + """append_event generates a 26-character ULID for the event ID.""" + event = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc, + actor="ppetru", + entity_refs={}, + payload={}, + ) + + assert len(event.id) == 26 + assert event.id.isalnum() + assert event.id.isupper() + + def test_get_event_returns_event(self, event_store, now_utc): + """get_event returns the event by ID.""" + created = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc, + actor="ppetru", + entity_refs={"location_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV"}, + payload={"product_code": "egg.duck", "quantity": 5}, + ) + + retrieved = event_store.get_event(created.id) + + assert retrieved is not None + assert retrieved.id == created.id + assert retrieved.type == PRODUCT_COLLECTED + assert retrieved.ts_utc == now_utc + assert retrieved.actor == "ppetru" + assert retrieved.entity_refs == {"location_id": "01ARZ3NDEKTSV4RRFFQ69G5FAV"} + assert retrieved.payload == {"product_code": "egg.duck", "quantity": 5} + + def test_get_event_not_found(self, event_store): + """get_event returns None for non-existent event ID.""" + result = event_store.get_event("01ARZ3NDEKTSV4RRFFQ69G5FAV") + assert result is None + + def test_list_events_no_filter(self, event_store, now_utc): + """list_events returns all events ordered by ts_utc ASC.""" + event1 = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc, + actor="ppetru", + entity_refs={}, + payload={"order": 1}, + ) + event2 = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc + 1000, + actor="ines", + entity_refs={}, + payload={"order": 2}, + ) + + events = event_store.list_events() + + assert len(events) == 2 + assert events[0].id == event1.id + assert events[1].id == event2.id + + def test_list_events_by_type(self, event_store, now_utc): + """list_events filters by event type.""" + from animaltrack.events import FEED_GIVEN + + event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc, + actor="ppetru", + entity_refs={}, + payload={}, + ) + feed_event = event_store.append_event( + event_type=FEED_GIVEN, + ts_utc=now_utc + 1000, + actor="ppetru", + entity_refs={}, + payload={}, + ) + + events = event_store.list_events(event_type=FEED_GIVEN) + + assert len(events) == 1 + assert events[0].id == feed_event.id + + def test_list_events_by_time_range(self, event_store, now_utc): + """list_events filters by since_utc and until_utc.""" + event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc - 10000, + actor="ppetru", + entity_refs={}, + payload={"order": 1}, + ) + event2 = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc, + actor="ppetru", + entity_refs={}, + payload={"order": 2}, + ) + event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc + 10000, + actor="ppetru", + entity_refs={}, + payload={"order": 3}, + ) + + events = event_store.list_events(since_utc=now_utc - 5000, until_utc=now_utc + 5000) + + assert len(events) == 1 + assert events[0].id == event2.id + + def test_list_events_by_actor(self, event_store, now_utc): + """list_events filters by actor.""" + event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc, + actor="ppetru", + entity_refs={}, + payload={}, + ) + ines_event = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc + 1000, + actor="ines", + entity_refs={}, + payload={}, + ) + + events = event_store.list_events(actor="ines") + + assert len(events) == 1 + assert events[0].id == ines_event.id + + def test_list_events_limit(self, event_store, now_utc): + """list_events respects limit parameter.""" + for i in range(5): + event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc + i * 1000, + actor="ppetru", + entity_refs={}, + payload={"order": i}, + ) + + events = event_store.list_events(limit=3) + + assert len(events) == 3 + + +class TestClockSkewValidation: + """Tests for clock skew validation.""" + + def test_clock_skew_rejected(self, event_store, now_utc): + """ts_utc more than 5 minutes in the future raises ClockSkewError.""" + future_ts = now_utc + (6 * 60 * 1000) # 6 minutes in the future + + with pytest.raises(ClockSkewError): + event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=future_ts, + actor="ppetru", + entity_refs={}, + payload={}, + ) + + def test_clock_skew_at_boundary_accepted(self, event_store, now_utc): + """ts_utc exactly at now + 5 minutes is accepted.""" + boundary_ts = now_utc + (5 * 60 * 1000) # Exactly 5 minutes in the future + + event = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=boundary_ts, + actor="ppetru", + entity_refs={}, + payload={}, + ) + + assert event.ts_utc == boundary_ts + + def test_clock_skew_within_range_accepted(self, event_store, now_utc): + """ts_utc in the past or near-future is accepted.""" + past_ts = now_utc - (60 * 60 * 1000) # 1 hour in the past + + event = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=past_ts, + actor="ppetru", + entity_refs={}, + payload={}, + ) + + assert event.ts_utc == past_ts + + +class TestNonceValidation: + """Tests for idempotency nonce validation.""" + + def test_nonce_validation_rejects_duplicate(self, event_store, now_utc): + """Same nonce raises DuplicateNonceError.""" + nonce = generate_id() + + event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc, + actor="ppetru", + entity_refs={}, + payload={}, + nonce=nonce, + route="/actions/product-collected", + ) + + with pytest.raises(DuplicateNonceError): + event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc + 1000, + actor="ppetru", + entity_refs={}, + payload={}, + nonce=nonce, + route="/actions/product-collected", + ) + + def test_nonce_recorded_in_table(self, migrated_db, event_store, now_utc): + """Nonce is stored in idempotency_nonces table.""" + nonce = generate_id() + + event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc, + actor="ppetru", + entity_refs={}, + payload={}, + nonce=nonce, + route="/actions/product-collected", + ) + + row = migrated_db.execute( + "SELECT actor, route, created_at_utc FROM idempotency_nonces WHERE nonce = ?", + (nonce,), + ).fetchone() + + assert row is not None + assert row[0] == "ppetru" + assert row[1] == "/actions/product-collected" + assert row[2] == now_utc + + def test_nonce_optional(self, event_store, now_utc): + """append_event works without nonce (for internal events).""" + event = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc, + actor="system", + entity_refs={}, + payload={}, + ) + + assert event is not None + assert event.id is not None + + def test_nonce_requires_route(self, event_store, now_utc): + """Nonce without route raises ValueError.""" + nonce = generate_id() + + with pytest.raises(ValueError, match="route is required"): + event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc, + actor="ppetru", + entity_refs={}, + payload={}, + nonce=nonce, + route=None, + ) + + +class TestTombstoneChecking: + """Tests for tombstone checking.""" + + def test_is_tombstoned_false(self, event_store, now_utc): + """is_tombstoned returns False when no tombstone exists.""" + event = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc, + actor="ppetru", + entity_refs={}, + payload={}, + ) + + assert event_store.is_tombstoned(event.id) is False + + def test_is_tombstoned_true(self, migrated_db, event_store, now_utc): + """is_tombstoned returns True when tombstone exists.""" + event = event_store.append_event( + event_type=PRODUCT_COLLECTED, + ts_utc=now_utc, + actor="ppetru", + entity_refs={}, + payload={}, + ) + + # Manually insert a tombstone for testing + tombstone_id = generate_id() + migrated_db.execute( + """INSERT INTO event_tombstones (id, ts_utc, actor, target_event_id, reason) + VALUES (?, ?, ?, ?, ?)""", + (tombstone_id, now_utc + 1000, "admin", event.id, "Test deletion"), + ) + + assert event_store.is_tombstoned(event.id) is True diff --git a/tests/test_event_types.py b/tests/test_event_types.py new file mode 100644 index 0000000..95bf4e8 --- /dev/null +++ b/tests/test_event_types.py @@ -0,0 +1,94 @@ +# ABOUTME: Tests for event type constants. +# ABOUTME: Validates all event types are defined and are strings. + +from animaltrack.events import ( + ALL_EVENT_TYPES, + ANIMAL_ATTRIBUTES_UPDATED, + ANIMAL_COHORT_CREATED, + ANIMAL_MERGED, + ANIMAL_MOVED, + ANIMAL_OUTCOME, + ANIMAL_PROMOTED, + ANIMAL_STATUS_CORRECTED, + ANIMAL_TAG_ENDED, + ANIMAL_TAGGED, + FEED_GIVEN, + FEED_PURCHASED, + HATCH_RECORDED, + LOCATION_ARCHIVED, + LOCATION_CREATED, + LOCATION_RENAMED, + PRODUCT_COLLECTED, + PRODUCT_SOLD, +) + + +class TestEventTypes: + """Tests for event type constants.""" + + def test_all_event_types_defined(self): + """All 17 event types should be defined.""" + expected_types = [ + LOCATION_CREATED, + LOCATION_RENAMED, + LOCATION_ARCHIVED, + ANIMAL_COHORT_CREATED, + ANIMAL_PROMOTED, + ANIMAL_MOVED, + ANIMAL_ATTRIBUTES_UPDATED, + ANIMAL_TAGGED, + ANIMAL_TAG_ENDED, + HATCH_RECORDED, + ANIMAL_OUTCOME, + ANIMAL_MERGED, + ANIMAL_STATUS_CORRECTED, + PRODUCT_COLLECTED, + PRODUCT_SOLD, + FEED_PURCHASED, + FEED_GIVEN, + ] + assert len(expected_types) == 17 + assert len(ALL_EVENT_TYPES) == 17 + assert set(expected_types) == set(ALL_EVENT_TYPES) + + def test_event_types_are_strings(self): + """All event types should be non-empty strings.""" + for event_type in ALL_EVENT_TYPES: + assert isinstance(event_type, str) + assert len(event_type) > 0 + + def test_event_types_are_pascal_case(self): + """All event types should be in PascalCase format.""" + for event_type in ALL_EVENT_TYPES: + assert event_type[0].isupper(), f"{event_type} should start with uppercase" + assert " " not in event_type, f"{event_type} should not contain spaces" + assert "_" not in event_type, f"{event_type} should not contain underscores" + + def test_location_events(self): + """Location events should be correctly defined.""" + assert LOCATION_CREATED == "LocationCreated" + assert LOCATION_RENAMED == "LocationRenamed" + assert LOCATION_ARCHIVED == "LocationArchived" + + def test_animal_events(self): + """Animal events should be correctly defined.""" + assert ANIMAL_COHORT_CREATED == "AnimalCohortCreated" + assert ANIMAL_PROMOTED == "AnimalPromoted" + assert ANIMAL_MOVED == "AnimalMoved" + assert ANIMAL_ATTRIBUTES_UPDATED == "AnimalAttributesUpdated" + assert ANIMAL_TAGGED == "AnimalTagged" + assert ANIMAL_TAG_ENDED == "AnimalTagEnded" + assert HATCH_RECORDED == "HatchRecorded" + assert ANIMAL_OUTCOME == "AnimalOutcome" + assert ANIMAL_MERGED == "AnimalMerged" + assert ANIMAL_STATUS_CORRECTED == "AnimalStatusCorrected" + + def test_product_events(self): + """Product events should be correctly defined.""" + assert PRODUCT_COLLECTED == "ProductCollected" + assert PRODUCT_SOLD == "ProductSold" + + def test_feed_events(self): + """Feed events should be correctly defined.""" + assert FEED_PURCHASED == "FeedPurchased" + assert FEED_GIVEN == "FeedGiven"