feat: add event tables schema and models
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>
This commit is contained in:
56
migrations/0002-event-tables.sql
Normal file
56
migrations/0002-event-tables.sql
Normal file
@@ -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);
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
# ABOUTME: Models package for AnimalTrack.
|
# 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 (
|
from animaltrack.models.reference import (
|
||||||
FeedType,
|
FeedType,
|
||||||
Location,
|
Location,
|
||||||
@@ -12,7 +19,12 @@ from animaltrack.models.reference import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"Event",
|
||||||
|
"EventAnimal",
|
||||||
|
"EventRevision",
|
||||||
|
"EventTombstone",
|
||||||
"FeedType",
|
"FeedType",
|
||||||
|
"IdempotencyNonce",
|
||||||
"Location",
|
"Location",
|
||||||
"Product",
|
"Product",
|
||||||
"ProductUnit",
|
"ProductUnit",
|
||||||
|
|||||||
92
src/animaltrack/models/events.py
Normal file
92
src/animaltrack/models/events.py
Normal file
@@ -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
|
||||||
452
tests/test_migration_event_tables.py
Normal file
452
tests/test_migration_event_tables.py
Normal file
@@ -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
|
||||||
271
tests/test_models_events.py
Normal file
271
tests/test_models_events.py
Normal file
@@ -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)
|
||||||
Reference in New Issue
Block a user