feat: add event store with nonce and clock skew validation

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 <noreply@anthropic.com>
This commit is contained in:
2025-12-28 07:35:05 +00:00
parent cc32370d49
commit 29d5d3f8ff
6 changed files with 820 additions and 0 deletions

361
tests/test_event_store.py Normal file
View File

@@ -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

94
tests/test_event_types.py Normal file
View File

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