feat: complete Step 9.1 with outcome, status-correct, and quick actions

- Add animal-outcome route with yield items section for harvest products
- Add animal-status-correct route with @require_role(ADMIN) decorator
- Add exception handlers for AuthenticationError (401) and AuthorizationError (403)
- Enable quick action buttons in animal detail page (Add Tag, Promote, Record Outcome)
- Add comprehensive tests for outcome and status-correct routes (81 total action tests)

🤖 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 13:45:06 +00:00
parent 3acb731a6c
commit 29ea3e27cb
5 changed files with 2036 additions and 4 deletions

View File

@@ -968,3 +968,587 @@ class TestTagEndValidation:
)
assert resp.status_code == 422
# =============================================================================
# Update Attributes Tests
# =============================================================================
class TestAttrsFormRendering:
"""Tests for GET /actions/attrs form rendering."""
def test_attrs_form_renders(self, client):
"""GET /actions/attrs returns 200 with form elements."""
resp = client.get("/actions/attrs")
assert resp.status_code == 200
assert "Update Attributes" in resp.text
def test_attrs_form_has_filter_field(self, client):
"""Form has filter input field."""
resp = client.get("/actions/attrs")
assert resp.status_code == 200
assert 'name="filter"' in resp.text
def test_attrs_form_has_sex_dropdown(self, client):
"""Form has sex dropdown."""
resp = client.get("/actions/attrs")
assert resp.status_code == 200
assert 'name="sex"' in resp.text
def test_attrs_form_has_life_stage_dropdown(self, client):
"""Form has life_stage dropdown."""
resp = client.get("/actions/attrs")
assert resp.status_code == 200
assert 'name="life_stage"' in resp.text
class TestAttrsSuccess:
"""Tests for successful POST /actions/animal-attrs."""
def test_attrs_creates_event(self, client, seeded_db, animals_for_tagging):
"""POST creates AnimalAttributesUpdated 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-attrs",
data={
"filter": "species:duck",
"sex": "male",
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-attrs-nonce-1",
},
)
assert resp.status_code == 200
# Verify event was created
event_row = seeded_db.execute(
"SELECT type FROM events WHERE type = 'AnimalAttributesUpdated' ORDER BY id DESC LIMIT 1"
).fetchone()
assert event_row is not None
assert event_row[0] == "AnimalAttributesUpdated"
def test_attrs_updates_registry(self, client, seeded_db, animals_for_tagging):
"""POST updates animal_registry with new attributes."""
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-attrs",
data={
"filter": "species:duck",
"life_stage": "adult",
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-attrs-nonce-2",
},
)
assert resp.status_code == 200
# Verify attribute was updated
adult_count = seeded_db.execute(
"SELECT COUNT(*) FROM animal_registry WHERE animal_id IN ({}) AND life_stage = 'adult'".format(
",".join("?" * len(animals_for_tagging))
),
animals_for_tagging,
).fetchone()[0]
assert adult_count == len(animals_for_tagging)
def test_attrs_success_returns_toast(self, client, seeded_db, animals_for_tagging):
"""Successful attrs update 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-attrs",
data={
"filter": "species:duck",
"repro_status": "intact",
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-attrs-nonce-3",
},
)
assert resp.status_code == 200
assert "HX-Trigger" in resp.headers
assert "showToast" in resp.headers["HX-Trigger"]
class TestAttrsValidation:
"""Tests for validation errors in POST /actions/animal-attrs."""
def test_attrs_no_attribute_returns_422(self, client, animals_for_tagging):
"""No attribute selected 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-attrs",
data={
"filter": "species:duck",
# No sex, life_stage, or repro_status
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-attrs-nonce-4",
},
)
assert resp.status_code == 422
def test_attrs_no_animals_returns_422(self, client):
"""No animals selected returns 422."""
import time
ts_utc = int(time.time() * 1000)
resp = client.post(
"/actions/animal-attrs",
data={
"filter": "",
"sex": "male",
# No resolved_ids
"roster_hash": "",
"ts_utc": str(ts_utc),
"nonce": "test-attrs-nonce-5",
},
)
assert resp.status_code == 422
# =============================================================================
# Record Outcome Form Tests
# =============================================================================
class TestOutcomeFormRendering:
"""Tests for GET /actions/outcome form rendering."""
def test_outcome_form_renders(self, client):
"""Outcome form page renders with 200."""
resp = client.get("/actions/outcome")
assert resp.status_code == 200
assert "Record Outcome" in resp.text
def test_outcome_form_has_filter_field(self, client):
"""Outcome form has filter input field."""
resp = client.get("/actions/outcome")
assert 'name="filter"' in resp.text
def test_outcome_form_has_outcome_dropdown(self, client):
"""Outcome form has outcome dropdown."""
resp = client.get("/actions/outcome")
assert 'name="outcome"' in resp.text
assert "death" in resp.text.lower()
assert "harvest" in resp.text.lower()
def test_outcome_form_has_yield_section(self, client):
"""Outcome form has yield items section."""
resp = client.get("/actions/outcome")
assert "Yield Items" in resp.text
class TestOutcomeSuccess:
"""Tests for successful POST /actions/animal-outcome."""
def test_outcome_creates_event(self, client, seeded_db, animals_for_tagging):
"""Recording outcome creates AnimalOutcome event."""
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-outcome",
data={
"filter": "species:duck",
"outcome": "death",
"reason": "old age",
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-outcome-nonce-1",
},
)
assert resp.status_code == 200
# Verify event was created
event_row = seeded_db.execute(
"SELECT type FROM events WHERE type = 'AnimalOutcome'"
).fetchone()
assert event_row is not None
def test_outcome_updates_status(self, client, seeded_db, animals_for_tagging):
"""Recording outcome updates animal status in registry."""
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-outcome",
data={
"filter": "species:duck",
"outcome": "harvest",
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-outcome-nonce-2",
},
)
assert resp.status_code == 200
# Verify status was updated
harvested_count = seeded_db.execute(
"SELECT COUNT(*) FROM animal_registry WHERE animal_id IN ({}) AND status = 'harvested'".format(
",".join("?" * len(animals_for_tagging))
),
animals_for_tagging,
).fetchone()[0]
assert harvested_count == len(animals_for_tagging)
def test_outcome_success_returns_toast(self, client, seeded_db, animals_for_tagging):
"""Successful outcome recording 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-outcome",
data={
"filter": "species:duck",
"outcome": "sold",
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-outcome-nonce-3",
},
)
assert resp.status_code == 200
assert "HX-Trigger" in resp.headers
assert "showToast" in resp.headers["HX-Trigger"]
class TestOutcomeValidation:
"""Tests for validation errors in POST /actions/animal-outcome."""
def test_outcome_no_outcome_returns_422(self, client, animals_for_tagging):
"""No outcome selected 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-outcome",
data={
"filter": "species:duck",
# No outcome
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-outcome-nonce-4",
},
)
assert resp.status_code == 422
def test_outcome_no_animals_returns_422(self, client):
"""No animals selected returns 422."""
import time
ts_utc = int(time.time() * 1000)
resp = client.post(
"/actions/animal-outcome",
data={
"filter": "",
"outcome": "death",
# No resolved_ids
"roster_hash": "",
"ts_utc": str(ts_utc),
"nonce": "test-outcome-nonce-5",
},
)
assert resp.status_code == 422
# =============================================================================
# Status Correct Form Tests (Admin-Only)
# =============================================================================
@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
# Use dev_mode=True so CSRF is bypassed and auth is auto-admin
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."""
import time
from animaltrack.models.reference import User, UserRole
from animaltrack.repositories.users import UserRepository
from animaltrack.web.app import create_app
# Create recorder user in database
user_repo = UserRepository(seeded_db)
now = int(time.time() * 1000)
recorder_user = User(
username="recorder",
role=UserRole.RECORDER,
active=True,
created_at_utc=now,
updated_at_utc=now,
)
user_repo.upsert(recorder_user)
# Use dev_mode=False so auth_before checks the database
settings = make_test_settings(trusted_proxy_ips="testclient", dev_mode=False)
app, rt = create_app(settings=settings, db=seeded_db)
# Create client that sends the auth header
# raise_server_exceptions=False to get 403 instead of exception
client = TestClient(app, raise_server_exceptions=False)
client.headers["X-Oidc-Username"] = "recorder"
return client
class TestStatusCorrectFormRendering:
"""Tests for GET /actions/status-correct form rendering."""
def test_status_correct_form_renders_for_admin(self, admin_client):
"""Status correct form page renders for admin."""
resp = admin_client.get("/actions/status-correct")
assert resp.status_code == 200
assert "Correct" in resp.text
def test_status_correct_form_has_filter_field(self, admin_client):
"""Status correct form has filter input field."""
resp = admin_client.get("/actions/status-correct")
assert 'name="filter"' in resp.text
def test_status_correct_form_has_status_dropdown(self, admin_client):
"""Status correct form has status dropdown."""
resp = admin_client.get("/actions/status-correct")
assert 'name="new_status"' in resp.text
assert "alive" in resp.text.lower()
def test_status_correct_form_requires_reason(self, admin_client):
"""Status correct form has required reason field."""
resp = admin_client.get("/actions/status-correct")
assert 'name="reason"' in resp.text
def test_status_correct_returns_403_for_user(self, user_client):
"""Status correct returns 403 for non-admin user."""
resp = user_client.get("/actions/status-correct")
assert resp.status_code == 403
class TestStatusCorrectSuccess:
"""Tests for successful POST /actions/animal-status-correct."""
def test_status_correct_creates_event(self, admin_client, seeded_db, animals_for_tagging):
"""Correcting status creates AnimalStatusCorrected event."""
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 = admin_client.post(
"/actions/animal-status-correct",
data={
"filter": "species:duck",
"new_status": "dead",
"reason": "Data entry error",
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-status-correct-nonce-1",
},
)
assert resp.status_code == 200
# Verify event was created
event_row = seeded_db.execute(
"SELECT type FROM events WHERE type = 'AnimalStatusCorrected'"
).fetchone()
assert event_row is not None
def test_status_correct_updates_status(self, admin_client, seeded_db, animals_for_tagging):
"""Correcting status updates animal status in registry."""
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 = admin_client.post(
"/actions/animal-status-correct",
data={
"filter": "species:duck",
"new_status": "dead",
"reason": "Mis-identified animal",
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-status-correct-nonce-2",
},
)
assert resp.status_code == 200
# Verify status was updated
dead_count = seeded_db.execute(
"SELECT COUNT(*) FROM animal_registry WHERE animal_id IN ({}) AND status = 'dead'".format(
",".join("?" * len(animals_for_tagging))
),
animals_for_tagging,
).fetchone()[0]
assert dead_count == len(animals_for_tagging)
class TestStatusCorrectValidation:
"""Tests for validation errors in POST /actions/animal-status-correct."""
def test_status_correct_no_status_returns_422(self, admin_client, animals_for_tagging):
"""No status selected 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 = admin_client.post(
"/actions/animal-status-correct",
data={
"filter": "species:duck",
# No new_status
"reason": "Some reason",
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-status-correct-nonce-3",
},
)
assert resp.status_code == 422
def test_status_correct_no_reason_returns_422(self, admin_client, animals_for_tagging):
"""No reason provided 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 = admin_client.post(
"/actions/animal-status-correct",
data={
"filter": "species:duck",
"new_status": "dead",
# No reason
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-status-correct-nonce-4",
},
)
assert resp.status_code == 422
def test_status_correct_no_animals_returns_422(self, admin_client):
"""No animals selected returns 422."""
import time
ts_utc = int(time.time() * 1000)
resp = admin_client.post(
"/actions/animal-status-correct",
data={
"filter": "",
"new_status": "dead",
"reason": "Data entry error",
# No resolved_ids
"roster_hash": "",
"ts_utc": str(ts_utc),
"nonce": "test-status-correct-nonce-5",
},
)
assert resp.status_code == 422
def test_status_correct_returns_403_for_user(self, user_client, animals_for_tagging):
"""Status correct returns 403 for non-admin user."""
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 = user_client.post(
"/actions/animal-status-correct",
data={
"filter": "species:duck",
"new_status": "dead",
"reason": "Data entry error",
"resolved_ids": animals_for_tagging,
"roster_hash": roster_hash,
"ts_utc": str(ts_utc),
"nonce": "test-status-correct-nonce-6",
},
)
assert resp.status_code == 403