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:
52
src/animaltrack/events/__init__.py
Normal file
52
src/animaltrack/events/__init__.py
Normal file
@@ -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",
|
||||||
|
]
|
||||||
18
src/animaltrack/events/exceptions.py
Normal file
18
src/animaltrack/events/exceptions.py
Normal file
@@ -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.
|
||||||
|
"""
|
||||||
247
src/animaltrack/events/store.py
Normal file
247
src/animaltrack/events/store.py
Normal file
@@ -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)
|
||||||
48
src/animaltrack/events/types.py
Normal file
48
src/animaltrack/events/types.py
Normal file
@@ -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,
|
||||||
|
]
|
||||||
361
tests/test_event_store.py
Normal file
361
tests/test_event_store.py
Normal 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
94
tests/test_event_types.py
Normal 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"
|
||||||
Reference in New Issue
Block a user