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