feat: implement animal-tag-add and animal-tag-end routes (Step 9.1)

Add selection-based tag actions with optimistic locking:
- GET /actions/tag-add and POST /actions/animal-tag-add
- GET /actions/tag-end and POST /actions/animal-tag-end
- Form templates with selection preview and tag input/dropdown
- Diff panel for handling selection mismatches (409 response)
- Add TagProjection to the action service registry
- 16 tests covering form rendering, success, validation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-31 12:50:38 +00:00
parent 99f2fbb964
commit 3acb731a6c
3 changed files with 1199 additions and 2 deletions

View File

@@ -584,3 +584,387 @@ class TestPromoteValidation:
)
assert resp.status_code == 404
# =============================================================================
# Add Tag Tests
# =============================================================================
@pytest.fixture
def animals_for_tagging(seeded_db, client, location_strip1_id):
"""Create animals for tag testing and return their IDs."""
# Create a cohort with 3 animals
resp = client.post(
"/actions/animal-cohort",
data={
"species": "duck",
"location_id": location_strip1_id,
"count": "3",
"life_stage": "adult",
"sex": "female",
"origin": "purchased",
"nonce": "test-tag-fixture-cohort-1",
},
)
assert resp.status_code == 200
# Get the animal IDs
rows = seeded_db.execute(
"SELECT animal_id FROM animal_registry WHERE origin = 'purchased' AND life_stage = 'adult' ORDER BY animal_id DESC LIMIT 3"
).fetchall()
return [row[0] for row in rows]
class TestTagAddFormRendering:
"""Tests for GET /actions/tag-add form rendering."""
def test_tag_add_form_renders(self, client):
"""GET /actions/tag-add returns 200 with form elements."""
resp = client.get("/actions/tag-add")
assert resp.status_code == 200
assert "Add Tag" in resp.text
def test_tag_add_form_has_filter_field(self, client):
"""Form has filter input field."""
resp = client.get("/actions/tag-add")
assert resp.status_code == 200
assert 'name="filter"' in resp.text
def test_tag_add_form_has_tag_field(self, client):
"""Form has tag input field."""
resp = client.get("/actions/tag-add")
assert resp.status_code == 200
assert 'name="tag"' in resp.text
def test_tag_add_form_with_filter_shows_selection(self, client, animals_for_tagging):
"""Form with filter shows selection preview."""
# Use species filter which is a valid filter field
resp = client.get("/actions/tag-add?filter=species:duck")
assert resp.status_code == 200
# Should show selection count
assert "selected" in resp.text.lower()
class TestTagAddSuccess:
"""Tests for successful POST /actions/animal-tag-add."""
def test_tag_add_creates_event(self, client, seeded_db, animals_for_tagging):
"""POST creates AnimalTagged event when valid."""
import time
from animaltrack.selection import compute_roster_hash
roster_hash = compute_roster_hash(animals_for_tagging, None)
ts_utc = int(time.time() * 1000)
resp = client.post(
"/actions/animal-tag-add",
data={
"filter": "species:duck",
"tag": "test-tag",
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-tag-nonce-1",
},
)
assert resp.status_code == 200
# Verify event was created
event_row = seeded_db.execute(
"SELECT type FROM events WHERE type = 'AnimalTagged' ORDER BY id DESC LIMIT 1"
).fetchone()
assert event_row is not None
assert event_row[0] == "AnimalTagged"
def test_tag_add_creates_tag_intervals(self, client, seeded_db, animals_for_tagging):
"""POST creates tag intervals for animals."""
import time
from animaltrack.selection import compute_roster_hash
roster_hash = compute_roster_hash(animals_for_tagging, None)
ts_utc = int(time.time() * 1000)
resp = client.post(
"/actions/animal-tag-add",
data={
"filter": "species:duck",
"tag": "layer-birds",
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-tag-nonce-2",
},
)
assert resp.status_code == 200
# Verify tag intervals were created
tag_count = seeded_db.execute(
"SELECT COUNT(*) FROM animal_tag_intervals WHERE tag = 'layer-birds' AND end_utc IS NULL"
).fetchone()[0]
assert tag_count >= len(animals_for_tagging)
def test_tag_add_success_returns_toast(self, client, seeded_db, animals_for_tagging):
"""Successful tag add returns HX-Trigger with toast."""
import time
from animaltrack.selection import compute_roster_hash
roster_hash = compute_roster_hash(animals_for_tagging, None)
ts_utc = int(time.time() * 1000)
resp = client.post(
"/actions/animal-tag-add",
data={
"filter": "species:duck",
"tag": "test-tag-toast",
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-tag-nonce-3",
},
)
assert resp.status_code == 200
assert "HX-Trigger" in resp.headers
assert "showToast" in resp.headers["HX-Trigger"]
class TestTagAddValidation:
"""Tests for validation errors in POST /actions/animal-tag-add."""
def test_tag_add_missing_tag_returns_422(self, client, animals_for_tagging):
"""Missing tag returns 422."""
import time
from animaltrack.selection import compute_roster_hash
roster_hash = compute_roster_hash(animals_for_tagging, None)
ts_utc = int(time.time() * 1000)
resp = client.post(
"/actions/animal-tag-add",
data={
"filter": "species:duck",
# Missing tag
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-tag-nonce-4",
},
)
assert resp.status_code == 422
def test_tag_add_no_animals_returns_422(self, client):
"""No animals selected returns 422."""
import time
ts_utc = int(time.time() * 1000)
resp = client.post(
"/actions/animal-tag-add",
data={
"filter": "",
"tag": "test-tag",
# No resolved_ids
"roster_hash": "",
"ts_utc": str(ts_utc),
"nonce": "test-tag-nonce-5",
},
)
assert resp.status_code == 422
# =============================================================================
# End Tag Tests
# =============================================================================
@pytest.fixture
def tagged_animals(seeded_db, client, animals_for_tagging):
"""Tag animals and return their IDs."""
import time
from animaltrack.selection import compute_roster_hash
roster_hash = compute_roster_hash(animals_for_tagging, None)
ts_utc = int(time.time() * 1000)
# First tag the animals
resp = client.post(
"/actions/animal-tag-add",
data={
"filter": "species:duck",
"tag": "test-end-tag",
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-fixture-tag-1",
},
)
assert resp.status_code == 200
return animals_for_tagging
class TestTagEndFormRendering:
"""Tests for GET /actions/tag-end form rendering."""
def test_tag_end_form_renders(self, client):
"""GET /actions/tag-end returns 200 with form elements."""
resp = client.get("/actions/tag-end")
assert resp.status_code == 200
assert "End Tag" in resp.text
def test_tag_end_form_has_filter_field(self, client):
"""Form has filter input field."""
resp = client.get("/actions/tag-end")
assert resp.status_code == 200
assert 'name="filter"' in resp.text
class TestTagEndSuccess:
"""Tests for successful POST /actions/animal-tag-end."""
def test_tag_end_creates_event(self, client, seeded_db, tagged_animals):
"""POST creates AnimalTagEnded event when valid."""
import time
from animaltrack.selection import compute_roster_hash
roster_hash = compute_roster_hash(tagged_animals, None)
ts_utc = int(time.time() * 1000)
resp = client.post(
"/actions/animal-tag-end",
data={
"filter": "species:duck",
"tag": "test-end-tag",
"resolved_ids": tagged_animals,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-tag-end-nonce-1",
},
)
assert resp.status_code == 200
# Verify event was created
event_row = seeded_db.execute(
"SELECT type FROM events WHERE type = 'AnimalTagEnded' ORDER BY id DESC LIMIT 1"
).fetchone()
assert event_row is not None
assert event_row[0] == "AnimalTagEnded"
def test_tag_end_closes_intervals(self, client, seeded_db, tagged_animals):
"""POST closes tag intervals for animals."""
import time
from animaltrack.selection import compute_roster_hash
roster_hash = compute_roster_hash(tagged_animals, None)
ts_utc = int(time.time() * 1000)
# Verify intervals are open before
open_before = seeded_db.execute(
"SELECT COUNT(*) FROM animal_tag_intervals WHERE tag = 'test-end-tag' AND end_utc IS NULL"
).fetchone()[0]
assert open_before >= len(tagged_animals)
resp = client.post(
"/actions/animal-tag-end",
data={
"filter": "species:duck",
"tag": "test-end-tag",
"resolved_ids": tagged_animals,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-tag-end-nonce-2",
},
)
assert resp.status_code == 200
# Verify intervals are closed after
open_after = seeded_db.execute(
"SELECT COUNT(*) FROM animal_tag_intervals WHERE tag = 'test-end-tag' AND end_utc IS NULL"
).fetchone()[0]
assert open_after == 0
def test_tag_end_success_returns_toast(self, client, seeded_db, tagged_animals):
"""Successful tag end returns HX-Trigger with toast."""
import time
from animaltrack.selection import compute_roster_hash
roster_hash = compute_roster_hash(tagged_animals, None)
ts_utc = int(time.time() * 1000)
resp = client.post(
"/actions/animal-tag-end",
data={
"filter": "species:duck",
"tag": "test-end-tag",
"resolved_ids": tagged_animals,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-tag-end-nonce-3",
},
)
assert resp.status_code == 200
assert "HX-Trigger" in resp.headers
assert "showToast" in resp.headers["HX-Trigger"]
class TestTagEndValidation:
"""Tests for validation errors in POST /actions/animal-tag-end."""
def test_tag_end_missing_tag_returns_422(self, client, tagged_animals):
"""Missing tag returns 422."""
import time
from animaltrack.selection import compute_roster_hash
roster_hash = compute_roster_hash(tagged_animals, None)
ts_utc = int(time.time() * 1000)
resp = client.post(
"/actions/animal-tag-end",
data={
"filter": "species:duck",
# Missing tag
"resolved_ids": tagged_animals,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-tag-end-nonce-4",
},
)
assert resp.status_code == 422
def test_tag_end_no_animals_returns_422(self, client):
"""No animals selected returns 422."""
import time
ts_utc = int(time.time() * 1000)
resp = client.post(
"/actions/animal-tag-end",
data={
"filter": "",
"tag": "some-tag",
# No resolved_ids
"roster_hash": "",
"ts_utc": str(ts_utc),
"nonce": "test-tag-end-nonce-5",
},
)
assert resp.status_code == 422