diff --git a/migrations/0002-event-tables.sql b/migrations/0002-event-tables.sql new file mode 100644 index 0000000..7a479c4 --- /dev/null +++ b/migrations/0002-event-tables.sql @@ -0,0 +1,56 @@ +-- ABOUTME: Creates event sourcing tables (events, event_revisions, event_tombstones). +-- ABOUTME: Also creates idempotency_nonces and event_animals tables. + +-- events: Core event log table +CREATE TABLE events ( + id TEXT PRIMARY KEY CHECK(length(id) = 26), + type TEXT NOT NULL, + ts_utc INTEGER NOT NULL, + actor TEXT NOT NULL, + entity_refs TEXT NOT NULL CHECK(json_valid(entity_refs)), + payload TEXT NOT NULL CHECK(json_valid(payload)), + version INTEGER NOT NULL DEFAULT 1 +); +CREATE INDEX idx_events_ts ON events(ts_utc); +CREATE INDEX idx_events_type_ts ON events(type, ts_utc); +CREATE INDEX idx_events_actor_ts ON events(actor, ts_utc); + +-- event_revisions: Stores prior versions when events are edited +CREATE TABLE event_revisions ( + event_id TEXT NOT NULL CHECK(length(event_id) = 26), + version INTEGER NOT NULL, + ts_utc INTEGER NOT NULL, + actor TEXT NOT NULL, + entity_refs TEXT NOT NULL CHECK(json_valid(entity_refs)), + payload TEXT NOT NULL CHECK(json_valid(payload)), + edited_at_utc INTEGER NOT NULL, + edited_by TEXT NOT NULL, + PRIMARY KEY (event_id, version) +); + +-- event_tombstones: Immutable deletion records +CREATE TABLE event_tombstones ( + id TEXT PRIMARY KEY CHECK(length(id) = 26), + ts_utc INTEGER NOT NULL, + actor TEXT NOT NULL, + target_event_id TEXT NOT NULL CHECK(length(target_event_id) = 26), + reason TEXT +); +CREATE INDEX idx_event_tombstones_target ON event_tombstones(target_event_id); + +-- idempotency_nonces: Prevents duplicate POST submissions +CREATE TABLE idempotency_nonces ( + nonce TEXT PRIMARY KEY, + actor TEXT NOT NULL, + route TEXT NOT NULL, + created_at_utc INTEGER NOT NULL +); + +-- event_animals: Links events to affected animals +CREATE TABLE event_animals ( + event_id TEXT NOT NULL CHECK(length(event_id) = 26), + animal_id TEXT NOT NULL CHECK(length(animal_id) = 26), + ts_utc INTEGER NOT NULL, + PRIMARY KEY (event_id, animal_id) +); +CREATE UNIQUE INDEX ux_event_animals_animal_ts ON event_animals(animal_id, ts_utc); diff --git a/src/animaltrack/models/__init__.py b/src/animaltrack/models/__init__.py index 04282b2..ed8d8f4 100644 --- a/src/animaltrack/models/__init__.py +++ b/src/animaltrack/models/__init__.py @@ -1,6 +1,13 @@ # ABOUTME: Models package for AnimalTrack. -# ABOUTME: Exports all Pydantic models and enums for reference data. +# ABOUTME: Exports all Pydantic models and enums for reference and event data. +from animaltrack.models.events import ( + Event, + EventAnimal, + EventRevision, + EventTombstone, + IdempotencyNonce, +) from animaltrack.models.reference import ( FeedType, Location, @@ -12,7 +19,12 @@ from animaltrack.models.reference import ( ) __all__ = [ + "Event", + "EventAnimal", + "EventRevision", + "EventTombstone", "FeedType", + "IdempotencyNonce", "Location", "Product", "ProductUnit", diff --git a/src/animaltrack/models/events.py b/src/animaltrack/models/events.py new file mode 100644 index 0000000..7139bb0 --- /dev/null +++ b/src/animaltrack/models/events.py @@ -0,0 +1,92 @@ +# ABOUTME: Pydantic models for event sourcing tables. +# ABOUTME: Includes Event, EventRevision, EventTombstone, IdempotencyNonce, EventAnimal. + +from pydantic import BaseModel, Field, field_validator + + +class Event(BaseModel): + """Core event model representing a state change in the system.""" + + id: str = Field(..., min_length=26, max_length=26) + type: str + ts_utc: int + actor: str + entity_refs: dict + payload: dict + version: int = 1 + + @field_validator("id") + @classmethod + def id_must_be_26_chars(cls, v: str) -> str: + """Event ID must be exactly 26 characters (ULID format).""" + if len(v) != 26: + msg = "Event ID must be exactly 26 characters" + raise ValueError(msg) + return v + + +class EventRevision(BaseModel): + """Stores prior versions of events when they are edited.""" + + event_id: str = Field(..., min_length=26, max_length=26) + version: int + ts_utc: int + actor: str + entity_refs: dict + payload: dict + edited_at_utc: int + edited_by: str + + @field_validator("event_id") + @classmethod + def event_id_must_be_26_chars(cls, v: str) -> str: + """Event ID must be exactly 26 characters (ULID format).""" + if len(v) != 26: + msg = "Event ID must be exactly 26 characters" + raise ValueError(msg) + return v + + +class EventTombstone(BaseModel): + """Immutable record of event deletion.""" + + id: str = Field(..., min_length=26, max_length=26) + ts_utc: int + actor: str + target_event_id: str = Field(..., min_length=26, max_length=26) + reason: str | None = None + + @field_validator("id", "target_event_id") + @classmethod + def ulid_must_be_26_chars(cls, v: str) -> str: + """ULID fields must be exactly 26 characters.""" + if len(v) != 26: + msg = "ULID must be exactly 26 characters" + raise ValueError(msg) + return v + + +class IdempotencyNonce(BaseModel): + """Tracks POST form nonces to prevent duplicate submissions.""" + + nonce: str + actor: str + route: str + created_at_utc: int + + +class EventAnimal(BaseModel): + """Links events to affected animals for efficient querying.""" + + event_id: str = Field(..., min_length=26, max_length=26) + animal_id: str = Field(..., min_length=26, max_length=26) + ts_utc: int + + @field_validator("event_id", "animal_id") + @classmethod + def ulid_must_be_26_chars(cls, v: str) -> str: + """ULID fields must be exactly 26 characters.""" + if len(v) != 26: + msg = "ULID must be exactly 26 characters" + raise ValueError(msg) + return v diff --git a/tests/test_migration_event_tables.py b/tests/test_migration_event_tables.py new file mode 100644 index 0000000..d378471 --- /dev/null +++ b/tests/test_migration_event_tables.py @@ -0,0 +1,452 @@ +# ABOUTME: Tests for the event tables migration (0002-event-tables.sql). +# ABOUTME: Validates that tables are created with correct schema and constraints. + +import json + +import apsw +import pytest + +from animaltrack.db import get_db +from animaltrack.migrations import run_migrations + + +@pytest.fixture +def migrated_db(tmp_path): + """Create a database with migrations applied.""" + db_path = str(tmp_path / "test.db") + migrations_dir = "migrations" + run_migrations(db_path, migrations_dir, verbose=False) + return get_db(db_path) + + +class TestMigrationCreatesAllTables: + """Tests that migration creates all event tables.""" + + def test_events_table_exists(self, migrated_db): + """Migration creates events table.""" + result = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='events'" + ).fetchone() + assert result is not None + assert result[0] == "events" + + def test_event_revisions_table_exists(self, migrated_db): + """Migration creates event_revisions table.""" + result = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='event_revisions'" + ).fetchone() + assert result is not None + + def test_event_tombstones_table_exists(self, migrated_db): + """Migration creates event_tombstones table.""" + result = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='event_tombstones'" + ).fetchone() + assert result is not None + + def test_idempotency_nonces_table_exists(self, migrated_db): + """Migration creates idempotency_nonces table.""" + result = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='idempotency_nonces'" + ).fetchone() + assert result is not None + + def test_event_animals_table_exists(self, migrated_db): + """Migration creates event_animals table.""" + result = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='event_animals'" + ).fetchone() + assert result is not None + + +class TestEventsTable: + """Tests for events table schema and constraints.""" + + def test_insert_valid_event(self, migrated_db): + """Can insert valid event data.""" + migrated_db.execute( + """ + INSERT INTO events (id, type, ts_utc, actor, entity_refs, payload, version) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "ProductCollected", + 1704067200000, + "ppetru", + json.dumps({"location_id": "01ARZ3NDEKTSV4RRFFQ69G5FAW"}), + json.dumps({"quantity": 10}), + 1, + ), + ) + result = migrated_db.execute( + "SELECT id, type, version FROM events WHERE id=?", ("01ARZ3NDEKTSV4RRFFQ69G5FAV",) + ).fetchone() + assert result == ("01ARZ3NDEKTSV4RRFFQ69G5FAV", "ProductCollected", 1) + + def test_id_length_check_constraint(self, migrated_db): + """Event ID must be exactly 26 characters.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO events (id, type, ts_utc, actor, entity_refs, payload) + VALUES (?, ?, ?, ?, ?, ?) + """, + ("short", "FeedGiven", 1704067200000, "ppetru", "{}", "{}"), + ) + + def test_entity_refs_must_be_valid_json(self, migrated_db): + """Event entity_refs must be valid JSON.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO events (id, type, ts_utc, actor, entity_refs, payload) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "FeedGiven", + 1704067200000, + "ppetru", + "not valid json", + "{}", + ), + ) + + def test_payload_must_be_valid_json(self, migrated_db): + """Event payload must be valid JSON.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO events (id, type, ts_utc, actor, entity_refs, payload) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "FeedGiven", + 1704067200000, + "ppetru", + "{}", + "not valid json", + ), + ) + + def test_version_defaults_to_1(self, migrated_db): + """Event version defaults to 1.""" + migrated_db.execute( + """ + INSERT INTO events (id, type, ts_utc, actor, entity_refs, payload) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "FeedGiven", + 1704067200000, + "ppetru", + "{}", + "{}", + ), + ) + result = migrated_db.execute( + "SELECT version FROM events WHERE id=?", + ("01ARZ3NDEKTSV4RRFFQ69G5FAV",), + ).fetchone() + assert result[0] == 1 + + +class TestEventRevisionsTable: + """Tests for event_revisions table schema and constraints.""" + + def test_insert_valid_event_revision(self, migrated_db): + """Can insert valid event revision data.""" + migrated_db.execute( + """ + INSERT INTO event_revisions + (event_id, version, ts_utc, actor, entity_refs, payload, edited_at_utc, edited_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + 1, + 1704067200000, + "ppetru", + "{}", + json.dumps({"quantity": 10}), + 1704153600000, + "ines", + ), + ) + result = migrated_db.execute( + "SELECT event_id, version, edited_by FROM event_revisions" + ).fetchone() + assert result == ("01ARZ3NDEKTSV4RRFFQ69G5FAV", 1, "ines") + + def test_composite_primary_key(self, migrated_db): + """event_revisions has composite primary key (event_id, version).""" + migrated_db.execute( + """ + INSERT INTO event_revisions + (event_id, version, ts_utc, actor, entity_refs, payload, edited_at_utc, edited_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + 1, + 1704067200000, + "ppetru", + "{}", + "{}", + 1704153600000, + "ines", + ), + ) + # Same event_id with different version should work + migrated_db.execute( + """ + INSERT INTO event_revisions + (event_id, version, ts_utc, actor, entity_refs, payload, edited_at_utc, edited_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + 2, + 1704067200000, + "ppetru", + "{}", + "{}", + 1704240000000, + "ines", + ), + ) + # Duplicate (event_id, version) should fail + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO event_revisions + (event_id, version, ts_utc, actor, entity_refs, payload, edited_at_utc, edited_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + 1, + 1704067200000, + "ppetru", + "{}", + "{}", + 1704153600000, + "ines", + ), + ) + + +class TestEventTombstonesTable: + """Tests for event_tombstones table schema and constraints.""" + + def test_insert_valid_event_tombstone(self, migrated_db): + """Can insert valid event tombstone data.""" + migrated_db.execute( + """ + INSERT INTO event_tombstones (id, ts_utc, actor, target_event_id, reason) + VALUES (?, ?, ?, ?, ?) + """, + ( + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + 1704067200000, + "ppetru", + "01ARZ3NDEKTSV4RRFFQ69G5FAW", + "Duplicate entry", + ), + ) + result = migrated_db.execute( + "SELECT id, target_event_id, reason FROM event_tombstones" + ).fetchone() + assert result == ( + "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "01ARZ3NDEKTSV4RRFFQ69G5FAW", + "Duplicate entry", + ) + + def test_reason_nullable(self, migrated_db): + """Tombstone reason can be NULL.""" + migrated_db.execute( + """ + INSERT INTO event_tombstones (id, ts_utc, actor, target_event_id) + VALUES (?, ?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAV", 1704067200000, "ppetru", "01ARZ3NDEKTSV4RRFFQ69G5FAW"), + ) + result = migrated_db.execute( + "SELECT reason FROM event_tombstones WHERE id=?", + ("01ARZ3NDEKTSV4RRFFQ69G5FAV",), + ).fetchone() + assert result[0] is None + + def test_id_length_check_constraint(self, migrated_db): + """Tombstone ID must be exactly 26 characters.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO event_tombstones (id, ts_utc, actor, target_event_id) + VALUES (?, ?, ?, ?) + """, + ("short", 1704067200000, "ppetru", "01ARZ3NDEKTSV4RRFFQ69G5FAW"), + ) + + def test_target_event_id_length_check_constraint(self, migrated_db): + """Tombstone target_event_id must be exactly 26 characters.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO event_tombstones (id, ts_utc, actor, target_event_id) + VALUES (?, ?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAV", 1704067200000, "ppetru", "short"), + ) + + +class TestIdempotencyNoncesTable: + """Tests for idempotency_nonces table schema.""" + + def test_insert_valid_nonce(self, migrated_db): + """Can insert valid idempotency nonce data.""" + migrated_db.execute( + """ + INSERT INTO idempotency_nonces (nonce, actor, route, created_at_utc) + VALUES (?, ?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAV", "ppetru", "/actions/product-collected", 1704067200000), + ) + result = migrated_db.execute("SELECT nonce, route FROM idempotency_nonces").fetchone() + assert result == ("01ARZ3NDEKTSV4RRFFQ69G5FAV", "/actions/product-collected") + + def test_nonce_is_primary_key(self, migrated_db): + """Nonce is primary key (duplicate rejected).""" + migrated_db.execute( + """ + INSERT INTO idempotency_nonces (nonce, actor, route, created_at_utc) + VALUES (?, ?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAV", "ppetru", "/actions/product-collected", 1704067200000), + ) + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO idempotency_nonces (nonce, actor, route, created_at_utc) + VALUES (?, ?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAV", "ines", "/actions/feed-given", 1704153600000), + ) + + +class TestEventAnimalsTable: + """Tests for event_animals table schema and constraints.""" + + def test_insert_valid_event_animal(self, migrated_db): + """Can insert valid event_animal data.""" + migrated_db.execute( + """ + INSERT INTO event_animals (event_id, animal_id, ts_utc) + VALUES (?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAV", "01ARZ3NDEKTSV4RRFFQ69G5FAW", 1704067200000), + ) + result = migrated_db.execute("SELECT event_id, animal_id FROM event_animals").fetchone() + assert result == ("01ARZ3NDEKTSV4RRFFQ69G5FAV", "01ARZ3NDEKTSV4RRFFQ69G5FAW") + + def test_composite_primary_key(self, migrated_db): + """event_animals has composite primary key (event_id, animal_id).""" + migrated_db.execute( + """ + INSERT INTO event_animals (event_id, animal_id, ts_utc) + VALUES (?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAV", "01ARZ3NDEKTSV4RRFFQ69G5FAW", 1704067200000), + ) + # Same event_id with different animal_id should work + migrated_db.execute( + """ + INSERT INTO event_animals (event_id, animal_id, ts_utc) + VALUES (?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAV", "01ARZ3NDEKTSV4RRFFQ69G5FAX", 1704067200000), + ) + # Duplicate (event_id, animal_id) should fail + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO event_animals (event_id, animal_id, ts_utc) + VALUES (?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAV", "01ARZ3NDEKTSV4RRFFQ69G5FAW", 1704067200000), + ) + + def test_unique_index_animal_ts(self, migrated_db): + """Unique constraint on (animal_id, ts_utc) prevents same-animal same-timestamp.""" + migrated_db.execute( + """ + INSERT INTO event_animals (event_id, animal_id, ts_utc) + VALUES (?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAV", "01ARZ3NDEKTSV4RRFFQ69G5FAW", 1704067200000), + ) + # Same animal_id at same ts_utc with different event_id should fail + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO event_animals (event_id, animal_id, ts_utc) + VALUES (?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAX", "01ARZ3NDEKTSV4RRFFQ69G5FAW", 1704067200000), + ) + + def test_event_id_length_check_constraint(self, migrated_db): + """event_animals event_id must be exactly 26 characters.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO event_animals (event_id, animal_id, ts_utc) + VALUES (?, ?, ?) + """, + ("short", "01ARZ3NDEKTSV4RRFFQ69G5FAW", 1704067200000), + ) + + def test_animal_id_length_check_constraint(self, migrated_db): + """event_animals animal_id must be exactly 26 characters.""" + with pytest.raises(apsw.ConstraintError): + migrated_db.execute( + """ + INSERT INTO event_animals (event_id, animal_id, ts_utc) + VALUES (?, ?, ?) + """, + ("01ARZ3NDEKTSV4RRFFQ69G5FAV", "short", 1704067200000), + ) + + +class TestIndexes: + """Tests that indexes are created correctly.""" + + def test_events_indexes_exist(self, migrated_db): + """Events table has required indexes.""" + indexes = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='events'" + ).fetchall() + index_names = {row[0] for row in indexes} + assert "idx_events_ts" in index_names + assert "idx_events_type_ts" in index_names + assert "idx_events_actor_ts" in index_names + + def test_event_tombstones_index_exists(self, migrated_db): + """event_tombstones table has target index.""" + indexes = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='event_tombstones'" + ).fetchall() + index_names = {row[0] for row in indexes} + assert "idx_event_tombstones_target" in index_names + + def test_event_animals_unique_index_exists(self, migrated_db): + """event_animals table has unique index on (animal_id, ts_utc).""" + indexes = migrated_db.execute( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='event_animals'" + ).fetchall() + index_names = {row[0] for row in indexes} + assert "ux_event_animals_animal_ts" in index_names diff --git a/tests/test_models_events.py b/tests/test_models_events.py new file mode 100644 index 0000000..e93b84d --- /dev/null +++ b/tests/test_models_events.py @@ -0,0 +1,271 @@ +# 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)