feat: implement location events & error handling (Step 10.2)
- Add LocationService with create/rename/archive methods - Add LocationProjection for event handling - Add admin-only location management routes at /locations - Add error response helpers (error_response, error_toast, success_toast) - Add toast handler JS to base template for HX-Trigger notifications - Update seeds.py to emit LocationCreated events per spec §23 - Archived locations block animal moves with 422 error 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -240,6 +240,9 @@ class TestAnimalServiceTransactionIntegrity:
|
||||
|
||||
def test_no_partial_data_on_projection_error(self, seeded_db, event_store, valid_location_id):
|
||||
"""If projection fails, event is not persisted."""
|
||||
# Count events before the test (seeds create LocationCreated events)
|
||||
event_count_before = seeded_db.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
||||
|
||||
# Create a registry with a failing projection
|
||||
from animaltrack.projections import Projection, ProjectionError
|
||||
|
||||
@@ -264,9 +267,9 @@ class TestAnimalServiceTransactionIntegrity:
|
||||
with pytest.raises(ProjectionError):
|
||||
service.create_cohort(payload, ts_utc, "test_user")
|
||||
|
||||
# Verify nothing was persisted
|
||||
event_count = seeded_db.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
||||
assert event_count == 0
|
||||
# Verify no new events were persisted (count unchanged from before)
|
||||
event_count_after = seeded_db.execute("SELECT COUNT(*) FROM events").fetchone()[0]
|
||||
assert event_count_after == event_count_before
|
||||
|
||||
animal_count = seeded_db.execute("SELECT COUNT(*) FROM animal_registry").fetchone()[0]
|
||||
assert animal_count == 0
|
||||
|
||||
343
tests/test_service_location.py
Normal file
343
tests/test_service_location.py
Normal file
@@ -0,0 +1,343 @@
|
||||
# ABOUTME: Tests for LocationService operations.
|
||||
# ABOUTME: Tests create_location, rename_location, and archive_location with event emission.
|
||||
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from animaltrack.events.store import EventStore
|
||||
from animaltrack.events.types import LOCATION_ARCHIVED, LOCATION_CREATED, LOCATION_RENAMED
|
||||
from animaltrack.projections import ProjectionRegistry
|
||||
from animaltrack.services.location import LocationService, ValidationError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_store(seeded_db):
|
||||
"""Create an EventStore for testing."""
|
||||
return EventStore(seeded_db)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def projection_registry(seeded_db):
|
||||
"""Create a ProjectionRegistry with location projections registered."""
|
||||
from animaltrack.projections.location import LocationProjection
|
||||
|
||||
registry = ProjectionRegistry()
|
||||
registry.register(LocationProjection(seeded_db))
|
||||
return registry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def location_service(seeded_db, event_store, projection_registry):
|
||||
"""Create a LocationService for testing."""
|
||||
return LocationService(seeded_db, event_store, projection_registry)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# create_location Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLocationServiceCreate:
|
||||
"""Tests for create_location()."""
|
||||
|
||||
def test_creates_location_created_event(self, seeded_db, location_service):
|
||||
"""create_location creates a LocationCreated event."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
event = location_service.create_location("New Location", ts_utc, "test_user")
|
||||
|
||||
assert event.type == LOCATION_CREATED
|
||||
assert event.actor == "test_user"
|
||||
assert event.ts_utc == ts_utc
|
||||
|
||||
def test_event_has_location_id_in_entity_refs(self, seeded_db, location_service):
|
||||
"""Event entity_refs contains location_id."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
event = location_service.create_location("Another Location", ts_utc, "test_user")
|
||||
|
||||
assert "location_id" in event.entity_refs
|
||||
assert len(event.entity_refs["location_id"]) == 26 # ULID length
|
||||
|
||||
def test_event_payload_contains_name(self, seeded_db, location_service):
|
||||
"""Event payload contains the location name."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
event = location_service.create_location("My Location", ts_utc, "test_user")
|
||||
|
||||
assert event.payload["name"] == "My Location"
|
||||
|
||||
def test_inserts_into_locations_table(self, seeded_db, location_service):
|
||||
"""create_location inserts a new row in locations table."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
event = location_service.create_location("Test Location", ts_utc, "test_user")
|
||||
|
||||
row = seeded_db.execute(
|
||||
"SELECT id, name, active FROM locations WHERE id = ?",
|
||||
(event.entity_refs["location_id"],),
|
||||
).fetchone()
|
||||
|
||||
assert row is not None
|
||||
assert row[1] == "Test Location"
|
||||
assert row[2] == 1 # active=True
|
||||
|
||||
def test_returns_existing_location_if_name_exists(self, seeded_db, location_service):
|
||||
"""create_location returns None if location with same name already exists."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
# Create first location
|
||||
event1 = location_service.create_location("Unique Name", ts_utc, "test_user")
|
||||
assert event1 is not None
|
||||
|
||||
# Try to create duplicate - should return None (idempotent)
|
||||
event2 = location_service.create_location("Unique Name", ts_utc + 1000, "test_user")
|
||||
|
||||
assert event2 is None
|
||||
|
||||
def test_idempotent_skips_if_event_exists_for_location(self, seeded_db, location_service):
|
||||
"""create_location is idempotent - skips if LocationCreated event exists."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
# Create the location once
|
||||
event1 = location_service.create_location("Idempotent Test", ts_utc, "test_user")
|
||||
location_id = event1.entity_refs["location_id"]
|
||||
|
||||
# Count events for this location
|
||||
event_count_before = seeded_db.execute(
|
||||
"SELECT COUNT(*) FROM events WHERE type = ? AND entity_refs LIKE ?",
|
||||
(LOCATION_CREATED, f"%{location_id}%"),
|
||||
).fetchone()[0]
|
||||
|
||||
# Calling create again with same name should not create a new event
|
||||
event2 = location_service.create_location("Idempotent Test", ts_utc + 1000, "test_user")
|
||||
|
||||
event_count_after = seeded_db.execute(
|
||||
"SELECT COUNT(*) FROM events WHERE type = ? AND entity_refs LIKE ?",
|
||||
(LOCATION_CREATED, f"%{location_id}%"),
|
||||
).fetchone()[0]
|
||||
|
||||
assert event2 is None
|
||||
assert event_count_after == event_count_before
|
||||
|
||||
|
||||
class TestLocationServiceCreateValidation:
|
||||
"""Tests for create_location() validation."""
|
||||
|
||||
def test_rejects_empty_name(self, seeded_db, location_service):
|
||||
"""Raises ValidationError for empty name."""
|
||||
with pytest.raises(ValidationError, match="name"):
|
||||
location_service.create_location("", int(time.time() * 1000), "test_user")
|
||||
|
||||
def test_rejects_whitespace_only_name(self, seeded_db, location_service):
|
||||
"""Raises ValidationError for whitespace-only name."""
|
||||
with pytest.raises(ValidationError, match="name"):
|
||||
location_service.create_location(" ", int(time.time() * 1000), "test_user")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# rename_location Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLocationServiceRename:
|
||||
"""Tests for rename_location()."""
|
||||
|
||||
def test_creates_location_renamed_event(self, seeded_db, location_service):
|
||||
"""rename_location creates a LocationRenamed event."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
# First create a location
|
||||
create_event = location_service.create_location("Original Name", ts_utc, "test_user")
|
||||
location_id = create_event.entity_refs["location_id"]
|
||||
|
||||
# Rename it
|
||||
event = location_service.rename_location(
|
||||
location_id, "New Name", ts_utc + 1000, "test_user"
|
||||
)
|
||||
|
||||
assert event.type == LOCATION_RENAMED
|
||||
assert event.actor == "test_user"
|
||||
assert event.ts_utc == ts_utc + 1000
|
||||
|
||||
def test_event_has_location_id_in_entity_refs(self, seeded_db, location_service):
|
||||
"""Event entity_refs contains location_id."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
create_event = location_service.create_location("Rename Test", ts_utc, "test_user")
|
||||
location_id = create_event.entity_refs["location_id"]
|
||||
|
||||
event = location_service.rename_location(location_id, "Renamed", ts_utc + 1000, "test_user")
|
||||
|
||||
assert event.entity_refs["location_id"] == location_id
|
||||
|
||||
def test_event_payload_contains_new_name(self, seeded_db, location_service):
|
||||
"""Event payload contains the new name."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
create_event = location_service.create_location("Before", ts_utc, "test_user")
|
||||
location_id = create_event.entity_refs["location_id"]
|
||||
|
||||
event = location_service.rename_location(location_id, "After", ts_utc + 1000, "test_user")
|
||||
|
||||
assert event.payload["new_name"] == "After"
|
||||
|
||||
def test_updates_locations_table_name(self, seeded_db, location_service):
|
||||
"""rename_location updates the name in locations table."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
create_event = location_service.create_location("Old Name", ts_utc, "test_user")
|
||||
location_id = create_event.entity_refs["location_id"]
|
||||
|
||||
location_service.rename_location(location_id, "Updated Name", ts_utc + 1000, "test_user")
|
||||
|
||||
row = seeded_db.execute(
|
||||
"SELECT name FROM locations WHERE id = ?", (location_id,)
|
||||
).fetchone()
|
||||
|
||||
assert row[0] == "Updated Name"
|
||||
|
||||
|
||||
class TestLocationServiceRenameValidation:
|
||||
"""Tests for rename_location() validation."""
|
||||
|
||||
def test_rejects_nonexistent_location(self, seeded_db, location_service):
|
||||
"""Raises ValidationError for non-existent location_id."""
|
||||
fake_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
|
||||
with pytest.raises(ValidationError, match="not found"):
|
||||
location_service.rename_location(
|
||||
fake_id, "New Name", int(time.time() * 1000), "test_user"
|
||||
)
|
||||
|
||||
def test_rejects_duplicate_name(self, seeded_db, location_service):
|
||||
"""Raises ValidationError when renaming to an existing name."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
|
||||
# Create two locations
|
||||
location_service.create_location("First Location", ts_utc, "test_user")
|
||||
create_event2 = location_service.create_location(
|
||||
"Second Location", ts_utc + 1000, "test_user"
|
||||
)
|
||||
location_id = create_event2.entity_refs["location_id"]
|
||||
|
||||
# Try to rename second to first's name
|
||||
with pytest.raises(ValidationError, match="already exists"):
|
||||
location_service.rename_location(
|
||||
location_id, "First Location", ts_utc + 2000, "test_user"
|
||||
)
|
||||
|
||||
def test_rejects_empty_new_name(self, seeded_db, location_service):
|
||||
"""Raises ValidationError for empty new_name."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
create_event = location_service.create_location("Test", ts_utc, "test_user")
|
||||
location_id = create_event.entity_refs["location_id"]
|
||||
|
||||
with pytest.raises(ValidationError, match="name"):
|
||||
location_service.rename_location(location_id, "", ts_utc + 1000, "test_user")
|
||||
|
||||
def test_allows_rename_to_same_name(self, seeded_db, location_service):
|
||||
"""Allows renaming to the same name (no-op)."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
create_event = location_service.create_location("Same Name", ts_utc, "test_user")
|
||||
location_id = create_event.entity_refs["location_id"]
|
||||
|
||||
# Should not raise - renaming to same name is allowed (no-op)
|
||||
event = location_service.rename_location(
|
||||
location_id, "Same Name", ts_utc + 1000, "test_user"
|
||||
)
|
||||
|
||||
assert event.type == LOCATION_RENAMED
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# archive_location Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLocationServiceArchive:
|
||||
"""Tests for archive_location()."""
|
||||
|
||||
def test_creates_location_archived_event(self, seeded_db, location_service):
|
||||
"""archive_location creates a LocationArchived event."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
create_event = location_service.create_location("To Archive", ts_utc, "test_user")
|
||||
location_id = create_event.entity_refs["location_id"]
|
||||
|
||||
event = location_service.archive_location(location_id, ts_utc + 1000, "test_user")
|
||||
|
||||
assert event.type == LOCATION_ARCHIVED
|
||||
assert event.actor == "test_user"
|
||||
assert event.ts_utc == ts_utc + 1000
|
||||
|
||||
def test_event_has_location_id_in_entity_refs(self, seeded_db, location_service):
|
||||
"""Event entity_refs contains location_id."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
create_event = location_service.create_location("Archive Test", ts_utc, "test_user")
|
||||
location_id = create_event.entity_refs["location_id"]
|
||||
|
||||
event = location_service.archive_location(location_id, ts_utc + 1000, "test_user")
|
||||
|
||||
assert event.entity_refs["location_id"] == location_id
|
||||
|
||||
def test_sets_location_inactive(self, seeded_db, location_service):
|
||||
"""archive_location sets active=0 in locations table."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
create_event = location_service.create_location("Active Location", ts_utc, "test_user")
|
||||
location_id = create_event.entity_refs["location_id"]
|
||||
|
||||
# Verify initially active
|
||||
row = seeded_db.execute(
|
||||
"SELECT active FROM locations WHERE id = ?", (location_id,)
|
||||
).fetchone()
|
||||
assert row[0] == 1
|
||||
|
||||
# Archive it
|
||||
location_service.archive_location(location_id, ts_utc + 1000, "test_user")
|
||||
|
||||
# Verify now inactive
|
||||
row = seeded_db.execute(
|
||||
"SELECT active FROM locations WHERE id = ?", (location_id,)
|
||||
).fetchone()
|
||||
assert row[0] == 0
|
||||
|
||||
def test_location_not_in_list_active(self, seeded_db, location_service):
|
||||
"""Archived location does not appear in list_active()."""
|
||||
from animaltrack.repositories.locations import LocationRepository
|
||||
|
||||
ts_utc = int(time.time() * 1000)
|
||||
create_event = location_service.create_location("Will Be Archived", ts_utc, "test_user")
|
||||
location_id = create_event.entity_refs["location_id"]
|
||||
|
||||
# Before archive - should be in list
|
||||
active_before = LocationRepository(seeded_db).list_active()
|
||||
assert any(loc.id == location_id for loc in active_before)
|
||||
|
||||
# Archive it
|
||||
location_service.archive_location(location_id, ts_utc + 1000, "test_user")
|
||||
|
||||
# After archive - should not be in list
|
||||
active_after = LocationRepository(seeded_db).list_active()
|
||||
assert not any(loc.id == location_id for loc in active_after)
|
||||
|
||||
|
||||
class TestLocationServiceArchiveValidation:
|
||||
"""Tests for archive_location() validation."""
|
||||
|
||||
def test_rejects_nonexistent_location(self, seeded_db, location_service):
|
||||
"""Raises ValidationError for non-existent location_id."""
|
||||
fake_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
|
||||
with pytest.raises(ValidationError, match="not found"):
|
||||
location_service.archive_location(fake_id, int(time.time() * 1000), "test_user")
|
||||
|
||||
def test_rejects_already_archived_location(self, seeded_db, location_service):
|
||||
"""Raises ValidationError when archiving an already archived location."""
|
||||
ts_utc = int(time.time() * 1000)
|
||||
create_event = location_service.create_location("Already Archived", ts_utc, "test_user")
|
||||
location_id = create_event.entity_refs["location_id"]
|
||||
|
||||
# Archive once
|
||||
location_service.archive_location(location_id, ts_utc + 1000, "test_user")
|
||||
|
||||
# Try to archive again
|
||||
with pytest.raises(ValidationError, match="already archived"):
|
||||
location_service.archive_location(location_id, ts_utc + 2000, "test_user")
|
||||
443
tests/test_web_locations.py
Normal file
443
tests/test_web_locations.py
Normal file
@@ -0,0 +1,443 @@
|
||||
# ABOUTME: Tests for Location management web routes.
|
||||
# ABOUTME: Covers GET /locations list and POST /actions/location-* (admin-only).
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
from animaltrack.models.reference import User, UserRole
|
||||
from animaltrack.repositories.users import UserRepository
|
||||
|
||||
|
||||
def make_test_settings(
|
||||
csrf_secret: str = "test-secret",
|
||||
trusted_proxy_ips: str = "127.0.0.1",
|
||||
dev_mode: bool = True,
|
||||
):
|
||||
"""Create Settings for testing by setting env vars temporarily."""
|
||||
from animaltrack.config import Settings
|
||||
|
||||
old_env = os.environ.copy()
|
||||
try:
|
||||
os.environ["CSRF_SECRET"] = csrf_secret
|
||||
os.environ["TRUSTED_PROXY_IPS"] = trusted_proxy_ips
|
||||
os.environ["DEV_MODE"] = str(dev_mode).lower()
|
||||
return Settings()
|
||||
finally:
|
||||
os.environ.clear()
|
||||
os.environ.update(old_env)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_client(seeded_db):
|
||||
"""Test client with admin role (dev mode - bypasses CSRF, auto-admin auth)."""
|
||||
from animaltrack.web.app import create_app
|
||||
|
||||
settings = make_test_settings(trusted_proxy_ips="testclient", dev_mode=True)
|
||||
app, rt = create_app(settings=settings, db=seeded_db)
|
||||
return TestClient(app, raise_server_exceptions=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_client(seeded_db):
|
||||
"""Test client with regular (recorder) role."""
|
||||
from animaltrack.web.app import create_app
|
||||
|
||||
# Create recorder user in database
|
||||
user_repo = UserRepository(seeded_db)
|
||||
now = int(time.time() * 1000)
|
||||
user = User(
|
||||
username="recorder",
|
||||
display_name="Recorder User",
|
||||
role=UserRole.RECORDER,
|
||||
active=True,
|
||||
created_at_utc=now,
|
||||
updated_at_utc=now,
|
||||
)
|
||||
user_repo.upsert(user)
|
||||
|
||||
# Use dev_mode=False to enable real auth
|
||||
settings = make_test_settings(trusted_proxy_ips="testclient", dev_mode=False)
|
||||
app, rt = create_app(settings=settings, db=seeded_db)
|
||||
client = TestClient(app, raise_server_exceptions=True)
|
||||
# Set header to simulate trusted proxy auth
|
||||
client.headers["X-Oidc-Username"] = "recorder"
|
||||
return client
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GET /locations Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLocationListRendering:
|
||||
"""Tests for GET /locations list rendering."""
|
||||
|
||||
def test_locations_page_renders_for_admin(self, admin_client):
|
||||
"""GET /locations returns 200 for admin."""
|
||||
resp = admin_client.get("/locations")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_locations_page_shows_seeded_locations(self, admin_client):
|
||||
"""Locations page shows seeded locations."""
|
||||
resp = admin_client.get("/locations")
|
||||
assert resp.status_code == 200
|
||||
assert "Strip 1" in resp.text
|
||||
assert "Strip 2" in resp.text
|
||||
assert "Nursery 1" in resp.text
|
||||
|
||||
def test_locations_page_returns_403_for_recorder(self, user_client):
|
||||
"""GET /locations returns 403 for non-admin."""
|
||||
resp = user_client.get("/locations")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /actions/location-created Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLocationCreatedSuccess:
|
||||
"""Tests for successful POST /actions/location-created."""
|
||||
|
||||
def test_creates_location(self, admin_client, seeded_db):
|
||||
"""POST creates a new location."""
|
||||
resp = admin_client.post(
|
||||
"/actions/location-created",
|
||||
data={
|
||||
"name": "New Test Location",
|
||||
"nonce": "test-nonce-loc-1",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify location was created
|
||||
row = seeded_db.execute(
|
||||
"SELECT name, active FROM locations WHERE name = 'New Test Location'"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "New Test Location"
|
||||
assert row[1] == 1
|
||||
|
||||
def test_creates_event(self, admin_client, seeded_db):
|
||||
"""POST creates LocationCreated event."""
|
||||
resp = admin_client.post(
|
||||
"/actions/location-created",
|
||||
data={
|
||||
"name": "Event Test Location",
|
||||
"nonce": "test-nonce-loc-2",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
event_row = seeded_db.execute(
|
||||
"SELECT type FROM events WHERE type = 'LocationCreated' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
assert event_row is not None
|
||||
assert event_row[0] == "LocationCreated"
|
||||
|
||||
def test_returns_toast_on_success(self, admin_client):
|
||||
"""POST returns HX-Trigger with toast message."""
|
||||
resp = admin_client.post(
|
||||
"/actions/location-created",
|
||||
data={
|
||||
"name": "Toast Test Location",
|
||||
"nonce": "test-nonce-loc-3",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "HX-Trigger" in resp.headers
|
||||
trigger = json.loads(resp.headers["HX-Trigger"])
|
||||
assert "showToast" in trigger
|
||||
assert trigger["showToast"]["type"] == "success"
|
||||
|
||||
def test_idempotent_for_existing_name(self, admin_client, seeded_db):
|
||||
"""POST is idempotent - returns success for existing name."""
|
||||
# First create
|
||||
resp1 = admin_client.post(
|
||||
"/actions/location-created",
|
||||
data={
|
||||
"name": "Idempotent Location",
|
||||
"nonce": "test-nonce-loc-4",
|
||||
},
|
||||
)
|
||||
assert resp1.status_code == 200
|
||||
|
||||
# Second create with same name - should still succeed (idempotent)
|
||||
resp2 = admin_client.post(
|
||||
"/actions/location-created",
|
||||
data={
|
||||
"name": "Idempotent Location",
|
||||
"nonce": "test-nonce-loc-5",
|
||||
},
|
||||
)
|
||||
assert resp2.status_code == 200
|
||||
|
||||
|
||||
class TestLocationCreatedValidation:
|
||||
"""Tests for POST /actions/location-created validation."""
|
||||
|
||||
def test_returns_403_for_recorder(self, user_client):
|
||||
"""POST returns 403 for non-admin."""
|
||||
resp = user_client.post(
|
||||
"/actions/location-created",
|
||||
data={
|
||||
"name": "Unauthorized Location",
|
||||
"nonce": "test-nonce-loc-6",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_returns_422_for_empty_name(self, admin_client):
|
||||
"""POST returns 422 for empty name."""
|
||||
resp = admin_client.post(
|
||||
"/actions/location-created",
|
||||
data={
|
||||
"name": "",
|
||||
"nonce": "test-nonce-loc-7",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_returns_422_for_whitespace_name(self, admin_client):
|
||||
"""POST returns 422 for whitespace-only name."""
|
||||
resp = admin_client.post(
|
||||
"/actions/location-created",
|
||||
data={
|
||||
"name": " ",
|
||||
"nonce": "test-nonce-loc-8",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /actions/location-renamed Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLocationRenamedSuccess:
|
||||
"""Tests for successful POST /actions/location-renamed."""
|
||||
|
||||
def test_renames_location(self, admin_client, seeded_db):
|
||||
"""POST renames an existing location."""
|
||||
# Get an existing location ID
|
||||
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
||||
location_id = row[0]
|
||||
|
||||
resp = admin_client.post(
|
||||
"/actions/location-renamed",
|
||||
data={
|
||||
"location_id": location_id,
|
||||
"new_name": "Renamed Strip 1",
|
||||
"nonce": "test-nonce-loc-9",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify rename
|
||||
row = seeded_db.execute(
|
||||
"SELECT name FROM locations WHERE id = ?", (location_id,)
|
||||
).fetchone()
|
||||
assert row[0] == "Renamed Strip 1"
|
||||
|
||||
def test_creates_event(self, admin_client, seeded_db):
|
||||
"""POST creates LocationRenamed event."""
|
||||
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 2'").fetchone()
|
||||
location_id = row[0]
|
||||
|
||||
resp = admin_client.post(
|
||||
"/actions/location-renamed",
|
||||
data={
|
||||
"location_id": location_id,
|
||||
"new_name": "Renamed Strip 2",
|
||||
"nonce": "test-nonce-loc-10",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
event_row = seeded_db.execute(
|
||||
"SELECT type FROM events WHERE type = 'LocationRenamed' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
assert event_row is not None
|
||||
|
||||
|
||||
class TestLocationRenamedValidation:
|
||||
"""Tests for POST /actions/location-renamed validation."""
|
||||
|
||||
def test_returns_403_for_recorder(self, user_client, seeded_db):
|
||||
"""POST returns 403 for non-admin."""
|
||||
row = seeded_db.execute("SELECT id FROM locations LIMIT 1").fetchone()
|
||||
location_id = row[0]
|
||||
|
||||
resp = user_client.post(
|
||||
"/actions/location-renamed",
|
||||
data={
|
||||
"location_id": location_id,
|
||||
"new_name": "New Name",
|
||||
"nonce": "test-nonce-loc-11",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_returns_422_for_nonexistent_location(self, admin_client):
|
||||
"""POST returns 422 for non-existent location."""
|
||||
fake_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
|
||||
resp = admin_client.post(
|
||||
"/actions/location-renamed",
|
||||
data={
|
||||
"location_id": fake_id,
|
||||
"new_name": "New Name",
|
||||
"nonce": "test-nonce-loc-12",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_returns_422_for_duplicate_name(self, admin_client, seeded_db):
|
||||
"""POST returns 422 when renaming to existing name."""
|
||||
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 3'").fetchone()
|
||||
location_id = row[0]
|
||||
|
||||
# Try to rename to an existing name
|
||||
resp = admin_client.post(
|
||||
"/actions/location-renamed",
|
||||
data={
|
||||
"location_id": location_id,
|
||||
"new_name": "Strip 4", # Already exists
|
||||
"nonce": "test-nonce-loc-13",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# POST /actions/location-archived Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestLocationArchivedSuccess:
|
||||
"""Tests for successful POST /actions/location-archived."""
|
||||
|
||||
def test_archives_location(self, admin_client, seeded_db):
|
||||
"""POST archives an existing location."""
|
||||
# First create a location to archive
|
||||
admin_client.post(
|
||||
"/actions/location-created",
|
||||
data={"name": "To Be Archived", "nonce": "test-nonce-loc-14"},
|
||||
)
|
||||
|
||||
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'To Be Archived'").fetchone()
|
||||
location_id = row[0]
|
||||
|
||||
resp = admin_client.post(
|
||||
"/actions/location-archived",
|
||||
data={
|
||||
"location_id": location_id,
|
||||
"nonce": "test-nonce-loc-15",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Verify archived
|
||||
row = seeded_db.execute(
|
||||
"SELECT active FROM locations WHERE id = ?", (location_id,)
|
||||
).fetchone()
|
||||
assert row[0] == 0
|
||||
|
||||
def test_creates_event(self, admin_client, seeded_db):
|
||||
"""POST creates LocationArchived event."""
|
||||
# Create a location to archive
|
||||
admin_client.post(
|
||||
"/actions/location-created",
|
||||
data={"name": "Archive Event Test", "nonce": "test-nonce-loc-16"},
|
||||
)
|
||||
|
||||
row = seeded_db.execute(
|
||||
"SELECT id FROM locations WHERE name = 'Archive Event Test'"
|
||||
).fetchone()
|
||||
location_id = row[0]
|
||||
|
||||
resp = admin_client.post(
|
||||
"/actions/location-archived",
|
||||
data={
|
||||
"location_id": location_id,
|
||||
"nonce": "test-nonce-loc-17",
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
|
||||
event_row = seeded_db.execute(
|
||||
"SELECT type FROM events WHERE type = 'LocationArchived' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
assert event_row is not None
|
||||
|
||||
|
||||
class TestLocationArchivedValidation:
|
||||
"""Tests for POST /actions/location-archived validation."""
|
||||
|
||||
def test_returns_403_for_recorder(self, user_client, seeded_db):
|
||||
"""POST returns 403 for non-admin."""
|
||||
row = seeded_db.execute("SELECT id FROM locations LIMIT 1").fetchone()
|
||||
location_id = row[0]
|
||||
|
||||
resp = user_client.post(
|
||||
"/actions/location-archived",
|
||||
data={
|
||||
"location_id": location_id,
|
||||
"nonce": "test-nonce-loc-18",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_returns_422_for_nonexistent_location(self, admin_client):
|
||||
"""POST returns 422 for non-existent location."""
|
||||
fake_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"
|
||||
|
||||
resp = admin_client.post(
|
||||
"/actions/location-archived",
|
||||
data={
|
||||
"location_id": fake_id,
|
||||
"nonce": "test-nonce-loc-19",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
def test_returns_422_for_already_archived(self, admin_client, seeded_db):
|
||||
"""POST returns 422 when archiving already archived location."""
|
||||
# Create and archive a location
|
||||
admin_client.post(
|
||||
"/actions/location-created",
|
||||
data={"name": "Double Archive Test", "nonce": "test-nonce-loc-20"},
|
||||
)
|
||||
|
||||
row = seeded_db.execute(
|
||||
"SELECT id FROM locations WHERE name = 'Double Archive Test'"
|
||||
).fetchone()
|
||||
location_id = row[0]
|
||||
|
||||
# Archive once
|
||||
admin_client.post(
|
||||
"/actions/location-archived",
|
||||
data={"location_id": location_id, "nonce": "test-nonce-loc-21"},
|
||||
)
|
||||
|
||||
# Try to archive again
|
||||
resp = admin_client.post(
|
||||
"/actions/location-archived",
|
||||
data={
|
||||
"location_id": location_id,
|
||||
"nonce": "test-nonce-loc-22",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
109
tests/test_web_responses.py
Normal file
109
tests/test_web_responses.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# ABOUTME: Tests for web response helper functions.
|
||||
# ABOUTME: Tests error_response, error_toast, and success_toast helpers.
|
||||
|
||||
import json
|
||||
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from animaltrack.web.responses import error_response, error_toast, success_toast
|
||||
|
||||
|
||||
class TestErrorResponse:
|
||||
"""Tests for error_response()."""
|
||||
|
||||
def test_returns_html_response(self):
|
||||
"""error_response returns an HTMLResponse."""
|
||||
response = error_response("<div>Error</div>")
|
||||
assert isinstance(response, HTMLResponse)
|
||||
|
||||
def test_default_status_422(self):
|
||||
"""error_response defaults to 422 status code."""
|
||||
response = error_response("<div>Validation error</div>")
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_custom_status_code(self):
|
||||
"""error_response accepts custom status code."""
|
||||
response = error_response("<div>Conflict</div>", status_code=409)
|
||||
assert response.status_code == 409
|
||||
|
||||
def test_403_status_code(self):
|
||||
"""error_response works with 403 status code."""
|
||||
response = error_response("<div>Forbidden</div>", status_code=403)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_401_status_code(self):
|
||||
"""error_response works with 401 status code."""
|
||||
response = error_response("<div>Unauthorized</div>", status_code=401)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_content_in_body(self):
|
||||
"""error_response includes content in body."""
|
||||
response = error_response("<p>Error message</p>")
|
||||
assert b"<p>Error message</p>" in response.body
|
||||
|
||||
|
||||
class TestErrorToast:
|
||||
"""Tests for error_toast()."""
|
||||
|
||||
def test_returns_html_response(self):
|
||||
"""error_toast returns an HTMLResponse."""
|
||||
response = error_toast("Something went wrong")
|
||||
assert isinstance(response, HTMLResponse)
|
||||
|
||||
def test_default_status_422(self):
|
||||
"""error_toast defaults to 422 status code."""
|
||||
response = error_toast("Validation failed")
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_custom_status_code(self):
|
||||
"""error_toast accepts custom status code."""
|
||||
response = error_toast("Not allowed", status_code=403)
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_has_hx_trigger_header(self):
|
||||
"""error_toast sets HX-Trigger header."""
|
||||
response = error_toast("Error message")
|
||||
assert "HX-Trigger" in response.headers
|
||||
|
||||
def test_toast_message_in_header(self):
|
||||
"""error_toast includes message in HX-Trigger header."""
|
||||
response = error_toast("Error message")
|
||||
trigger = json.loads(response.headers["HX-Trigger"])
|
||||
assert trigger["showToast"]["message"] == "Error message"
|
||||
|
||||
def test_toast_type_error(self):
|
||||
"""error_toast sets toast type to error."""
|
||||
response = error_toast("Error message")
|
||||
trigger = json.loads(response.headers["HX-Trigger"])
|
||||
assert trigger["showToast"]["type"] == "error"
|
||||
|
||||
|
||||
class TestSuccessToast:
|
||||
"""Tests for success_toast()."""
|
||||
|
||||
def test_returns_dict(self):
|
||||
"""success_toast returns a dict (for HX-Trigger header)."""
|
||||
result = success_toast("Action completed")
|
||||
assert isinstance(result, dict)
|
||||
|
||||
def test_has_show_toast_key(self):
|
||||
"""success_toast dict has showToast key."""
|
||||
result = success_toast("Action completed")
|
||||
assert "showToast" in result
|
||||
|
||||
def test_message_in_toast(self):
|
||||
"""success_toast includes message."""
|
||||
result = success_toast("Created successfully")
|
||||
assert result["showToast"]["message"] == "Created successfully"
|
||||
|
||||
def test_toast_type_success(self):
|
||||
"""success_toast sets toast type to success."""
|
||||
result = success_toast("Done")
|
||||
assert result["showToast"]["type"] == "success"
|
||||
|
||||
def test_can_json_dumps(self):
|
||||
"""success_toast result can be JSON serialized."""
|
||||
result = success_toast("Test message")
|
||||
serialized = json.dumps(result)
|
||||
assert '"showToast"' in serialized
|
||||
assert '"Test message"' in serialized
|
||||
Reference in New Issue
Block a user