Create migration for events, event_revisions, event_tombstones, idempotency_nonces, and event_animals tables with ULID checks and JSON validation. Add Pydantic models with field validators. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
272 lines
9.2 KiB
Python
272 lines
9.2 KiB
Python
# ABOUTME: Tests for event-related Pydantic models.
|
|
# ABOUTME: Validates Event, EventRevision, EventTombstone, IdempotencyNonce, EventAnimal.
|
|
|
|
import pytest
|
|
from pydantic import ValidationError
|
|
|
|
from animaltrack.models.events import (
|
|
Event,
|
|
EventAnimal,
|
|
EventRevision,
|
|
EventTombstone,
|
|
IdempotencyNonce,
|
|
)
|
|
|
|
|
|
class TestEvent:
|
|
"""Tests for the Event model."""
|
|
|
|
def test_valid_event(self):
|
|
"""Event with all required fields validates successfully."""
|
|
event = Event(
|
|
id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
type="ProductCollected",
|
|
ts_utc=1704067200000,
|
|
actor="ppetru",
|
|
entity_refs={"location_id": "01ARZ3NDEKTSV4RRFFQ69G5FAW"},
|
|
payload={"product_code": "egg.duck", "quantity": 10},
|
|
)
|
|
assert event.id == "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
|
assert event.type == "ProductCollected"
|
|
assert event.version == 1
|
|
|
|
def test_event_version_defaults_to_1(self):
|
|
"""Event version defaults to 1."""
|
|
event = Event(
|
|
id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
type="FeedGiven",
|
|
ts_utc=1704067200000,
|
|
actor="ines",
|
|
entity_refs={},
|
|
payload={},
|
|
)
|
|
assert event.version == 1
|
|
|
|
def test_event_id_must_be_26_chars(self):
|
|
"""Event with ID not exactly 26 chars raises ValidationError."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
Event(
|
|
id="short",
|
|
type="FeedGiven",
|
|
ts_utc=1704067200000,
|
|
actor="ppetru",
|
|
entity_refs={},
|
|
payload={},
|
|
)
|
|
assert "26 characters" in str(exc_info.value)
|
|
|
|
def test_event_id_too_long(self):
|
|
"""Event with ID longer than 26 chars raises ValidationError."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
Event(
|
|
id="01ARZ3NDEKTSV4RRFFQ69G5FAVX", # 27 chars
|
|
type="FeedGiven",
|
|
ts_utc=1704067200000,
|
|
actor="ppetru",
|
|
entity_refs={},
|
|
payload={},
|
|
)
|
|
assert "26 characters" in str(exc_info.value)
|
|
|
|
def test_event_requires_type(self):
|
|
"""Event without type raises ValidationError."""
|
|
with pytest.raises(ValidationError):
|
|
Event(
|
|
id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
ts_utc=1704067200000,
|
|
actor="ppetru",
|
|
entity_refs={},
|
|
payload={},
|
|
)
|
|
|
|
def test_event_entity_refs_must_be_dict(self):
|
|
"""Event entity_refs must be a dict."""
|
|
with pytest.raises(ValidationError):
|
|
Event(
|
|
id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
type="FeedGiven",
|
|
ts_utc=1704067200000,
|
|
actor="ppetru",
|
|
entity_refs="not a dict",
|
|
payload={},
|
|
)
|
|
|
|
def test_event_payload_must_be_dict(self):
|
|
"""Event payload must be a dict."""
|
|
with pytest.raises(ValidationError):
|
|
Event(
|
|
id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
type="FeedGiven",
|
|
ts_utc=1704067200000,
|
|
actor="ppetru",
|
|
entity_refs={},
|
|
payload="not a dict",
|
|
)
|
|
|
|
def test_event_with_custom_version(self):
|
|
"""Event can be created with custom version."""
|
|
event = Event(
|
|
id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
type="FeedGiven",
|
|
ts_utc=1704067200000,
|
|
actor="ppetru",
|
|
entity_refs={},
|
|
payload={},
|
|
version=3,
|
|
)
|
|
assert event.version == 3
|
|
|
|
|
|
class TestEventRevision:
|
|
"""Tests for the EventRevision model."""
|
|
|
|
def test_valid_event_revision(self):
|
|
"""EventRevision with all required fields validates successfully."""
|
|
revision = EventRevision(
|
|
event_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
version=1,
|
|
ts_utc=1704067200000,
|
|
actor="ppetru",
|
|
entity_refs={"location_id": "01ARZ3NDEKTSV4RRFFQ69G5FAW"},
|
|
payload={"quantity": 10},
|
|
edited_at_utc=1704153600000,
|
|
edited_by="ines",
|
|
)
|
|
assert revision.event_id == "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
|
assert revision.version == 1
|
|
assert revision.edited_by == "ines"
|
|
|
|
def test_event_revision_event_id_must_be_26_chars(self):
|
|
"""EventRevision event_id must be exactly 26 chars."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
EventRevision(
|
|
event_id="short",
|
|
version=1,
|
|
ts_utc=1704067200000,
|
|
actor="ppetru",
|
|
entity_refs={},
|
|
payload={},
|
|
edited_at_utc=1704153600000,
|
|
edited_by="ines",
|
|
)
|
|
assert "26 characters" in str(exc_info.value)
|
|
|
|
def test_event_revision_requires_edited_by(self):
|
|
"""EventRevision requires edited_by field."""
|
|
with pytest.raises(ValidationError):
|
|
EventRevision(
|
|
event_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
version=1,
|
|
ts_utc=1704067200000,
|
|
actor="ppetru",
|
|
entity_refs={},
|
|
payload={},
|
|
edited_at_utc=1704153600000,
|
|
)
|
|
|
|
|
|
class TestEventTombstone:
|
|
"""Tests for the EventTombstone model."""
|
|
|
|
def test_valid_event_tombstone(self):
|
|
"""EventTombstone with all required fields validates successfully."""
|
|
tombstone = EventTombstone(
|
|
id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
ts_utc=1704067200000,
|
|
actor="ppetru",
|
|
target_event_id="01ARZ3NDEKTSV4RRFFQ69G5FAW",
|
|
reason="Duplicate entry",
|
|
)
|
|
assert tombstone.id == "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
|
assert tombstone.target_event_id == "01ARZ3NDEKTSV4RRFFQ69G5FAW"
|
|
assert tombstone.reason == "Duplicate entry"
|
|
|
|
def test_event_tombstone_reason_optional(self):
|
|
"""EventTombstone reason is optional."""
|
|
tombstone = EventTombstone(
|
|
id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
ts_utc=1704067200000,
|
|
actor="ppetru",
|
|
target_event_id="01ARZ3NDEKTSV4RRFFQ69G5FAW",
|
|
)
|
|
assert tombstone.reason is None
|
|
|
|
def test_event_tombstone_id_must_be_26_chars(self):
|
|
"""EventTombstone id must be exactly 26 chars."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
EventTombstone(
|
|
id="short",
|
|
ts_utc=1704067200000,
|
|
actor="ppetru",
|
|
target_event_id="01ARZ3NDEKTSV4RRFFQ69G5FAW",
|
|
)
|
|
assert "26 characters" in str(exc_info.value)
|
|
|
|
def test_event_tombstone_target_event_id_must_be_26_chars(self):
|
|
"""EventTombstone target_event_id must be exactly 26 chars."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
EventTombstone(
|
|
id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
ts_utc=1704067200000,
|
|
actor="ppetru",
|
|
target_event_id="short",
|
|
)
|
|
assert "26 characters" in str(exc_info.value)
|
|
|
|
|
|
class TestIdempotencyNonce:
|
|
"""Tests for the IdempotencyNonce model."""
|
|
|
|
def test_valid_idempotency_nonce(self):
|
|
"""IdempotencyNonce with all required fields validates successfully."""
|
|
nonce = IdempotencyNonce(
|
|
nonce="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
actor="ppetru",
|
|
route="/actions/product-collected",
|
|
created_at_utc=1704067200000,
|
|
)
|
|
assert nonce.nonce == "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
|
assert nonce.route == "/actions/product-collected"
|
|
|
|
def test_idempotency_nonce_requires_all_fields(self):
|
|
"""IdempotencyNonce requires all fields."""
|
|
with pytest.raises(ValidationError):
|
|
IdempotencyNonce(
|
|
nonce="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
actor="ppetru",
|
|
)
|
|
|
|
|
|
class TestEventAnimal:
|
|
"""Tests for the EventAnimal model."""
|
|
|
|
def test_valid_event_animal(self):
|
|
"""EventAnimal with all required fields validates successfully."""
|
|
event_animal = EventAnimal(
|
|
event_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAW",
|
|
ts_utc=1704067200000,
|
|
)
|
|
assert event_animal.event_id == "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
|
assert event_animal.animal_id == "01ARZ3NDEKTSV4RRFFQ69G5FAW"
|
|
|
|
def test_event_animal_event_id_must_be_26_chars(self):
|
|
"""EventAnimal event_id must be exactly 26 chars."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
EventAnimal(
|
|
event_id="short",
|
|
animal_id="01ARZ3NDEKTSV4RRFFQ69G5FAW",
|
|
ts_utc=1704067200000,
|
|
)
|
|
assert "26 characters" in str(exc_info.value)
|
|
|
|
def test_event_animal_animal_id_must_be_26_chars(self):
|
|
"""EventAnimal animal_id must be exactly 26 chars."""
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
EventAnimal(
|
|
event_id="01ARZ3NDEKTSV4RRFFQ69G5FAV",
|
|
animal_id="short",
|
|
ts_utc=1704067200000,
|
|
)
|
|
assert "26 characters" in str(exc_info.value)
|