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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user