All checks were successful
Deploy / deploy (push) Successful in 2m40s
- Create recent_events.py helper for rendering event lists with humanized timestamps and deleted event styling (line-through + opacity) - Query events with ORDER BY ts_utc DESC to show newest first - Join event_tombstones to detect deleted events - Fix move form to read animal_ids (not resolved_ids) from entity_refs - Fix feed purchase format to use total_kg from entity_refs - Use hx_get with #event-panel-content target for slide-over panel - Add days-since-last stats for move and feed forms 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
459 lines
16 KiB
Python
459 lines
16 KiB
Python
# ABOUTME: Tests for Feed Quick Capture web routes.
|
|
# ABOUTME: Covers GET /feed form rendering and POST /actions/feed-given, /actions/feed-purchased.
|
|
|
|
import os
|
|
import time
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
from animaltrack.events.payloads import FeedPurchasedPayload
|
|
from animaltrack.events.store import EventStore
|
|
from animaltrack.projections import ProjectionRegistry
|
|
from animaltrack.projections.feed import FeedInventoryProjection
|
|
from animaltrack.services.feed import FeedService
|
|
|
|
|
|
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 client(seeded_db):
|
|
"""Create a test client for the app."""
|
|
from animaltrack.web.app import create_app
|
|
|
|
settings = make_test_settings(trusted_proxy_ips="testclient")
|
|
app, rt = create_app(settings=settings, db=seeded_db)
|
|
return TestClient(app, raise_server_exceptions=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def location_strip1_id(seeded_db):
|
|
"""Get Strip 1 location ID from seeded data."""
|
|
row = seeded_db.execute("SELECT id FROM locations WHERE name = 'Strip 1'").fetchone()
|
|
return row[0]
|
|
|
|
|
|
@pytest.fixture
|
|
def feed_service(seeded_db):
|
|
"""Create a FeedService for testing."""
|
|
event_store = EventStore(seeded_db)
|
|
registry = ProjectionRegistry()
|
|
registry.register(FeedInventoryProjection(seeded_db))
|
|
return FeedService(seeded_db, event_store, registry)
|
|
|
|
|
|
@pytest.fixture
|
|
def feed_purchase_in_db(seeded_db, feed_service):
|
|
"""Create a feed purchase so give_feed can work."""
|
|
payload = FeedPurchasedPayload(
|
|
feed_type_code="layer",
|
|
bag_size_kg=20,
|
|
bags_count=5,
|
|
bag_price_cents=2400,
|
|
)
|
|
ts_utc = int(time.time() * 1000) - 86400000 # 1 day ago
|
|
feed_service.purchase_feed(payload, ts_utc, "test_user")
|
|
return payload
|
|
|
|
|
|
class TestFeedFormRendering:
|
|
"""Tests for GET /feed form rendering."""
|
|
|
|
def test_feed_page_renders(self, client):
|
|
"""GET /feed returns 200."""
|
|
resp = client.get("/feed")
|
|
assert resp.status_code == 200
|
|
|
|
def test_feed_page_shows_tabs(self, client):
|
|
"""Feed page shows both Give Feed and Purchase Feed tabs."""
|
|
resp = client.get("/feed")
|
|
assert resp.status_code == 200
|
|
assert "Give Feed" in resp.text or "give" in resp.text.lower()
|
|
assert "Purchase" in resp.text or "purchase" in resp.text.lower()
|
|
|
|
def test_give_feed_form_has_fields(self, client):
|
|
"""Give feed form has required fields."""
|
|
resp = client.get("/feed")
|
|
assert resp.status_code == 200
|
|
# Check for location, feed type, amount fields
|
|
assert 'name="location_id"' in resp.text or 'id="location_id"' in resp.text
|
|
assert 'name="feed_type_code"' in resp.text or 'id="feed_type_code"' in resp.text
|
|
assert 'name="amount_kg"' in resp.text or 'id="amount_kg"' in resp.text
|
|
|
|
def test_locations_in_dropdown(self, client):
|
|
"""Active locations appear in dropdown."""
|
|
resp = client.get("/feed")
|
|
assert resp.status_code == 200
|
|
assert "Strip 1" in resp.text
|
|
assert "Strip 2" in resp.text
|
|
|
|
def test_feed_types_in_dropdown(self, client):
|
|
"""Active feed types appear in dropdown."""
|
|
resp = client.get("/feed")
|
|
assert resp.status_code == 200
|
|
# Seeded feed types include 'layer'
|
|
assert "layer" in resp.text.lower()
|
|
|
|
|
|
class TestFeedGiven:
|
|
"""Tests for POST /actions/feed-given."""
|
|
|
|
def test_give_feed_creates_event(
|
|
self, client, seeded_db, location_strip1_id, feed_purchase_in_db
|
|
):
|
|
"""POST creates FeedGiven event."""
|
|
resp = client.post(
|
|
"/actions/feed-given",
|
|
data={
|
|
"location_id": location_strip1_id,
|
|
"feed_type_code": "layer",
|
|
"amount_kg": "5",
|
|
"nonce": "test-nonce-feed-1",
|
|
},
|
|
)
|
|
|
|
assert resp.status_code in [200, 302, 303]
|
|
|
|
# Verify event was created in database
|
|
event_row = seeded_db.execute(
|
|
"SELECT type, payload FROM events WHERE type = 'FeedGiven' ORDER BY id DESC LIMIT 1"
|
|
).fetchone()
|
|
assert event_row is not None
|
|
assert event_row[0] == "FeedGiven"
|
|
|
|
def test_give_feed_validation_amount_zero(
|
|
self, client, location_strip1_id, feed_purchase_in_db
|
|
):
|
|
"""amount_kg=0 returns 422."""
|
|
resp = client.post(
|
|
"/actions/feed-given",
|
|
data={
|
|
"location_id": location_strip1_id,
|
|
"feed_type_code": "layer",
|
|
"amount_kg": "0",
|
|
"nonce": "test-nonce-feed-2",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_give_feed_validation_amount_negative(
|
|
self, client, location_strip1_id, feed_purchase_in_db
|
|
):
|
|
"""amount_kg=-1 returns 422."""
|
|
resp = client.post(
|
|
"/actions/feed-given",
|
|
data={
|
|
"location_id": location_strip1_id,
|
|
"feed_type_code": "layer",
|
|
"amount_kg": "-1",
|
|
"nonce": "test-nonce-feed-3",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_give_feed_validation_missing_location(self, client, feed_purchase_in_db):
|
|
"""Missing location_id returns 422."""
|
|
resp = client.post(
|
|
"/actions/feed-given",
|
|
data={
|
|
"feed_type_code": "layer",
|
|
"amount_kg": "5",
|
|
"nonce": "test-nonce-feed-4",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_give_feed_validation_missing_feed_type(
|
|
self, client, location_strip1_id, feed_purchase_in_db
|
|
):
|
|
"""Missing feed_type_code returns 422."""
|
|
resp = client.post(
|
|
"/actions/feed-given",
|
|
data={
|
|
"location_id": location_strip1_id,
|
|
"amount_kg": "5",
|
|
"nonce": "test-nonce-feed-5",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_give_feed_blocked_without_purchase(self, client, location_strip1_id):
|
|
"""Cannot give feed if no purchase exists for this feed type."""
|
|
# Don't use feed_purchase_in_db fixture
|
|
resp = client.post(
|
|
"/actions/feed-given",
|
|
data={
|
|
"location_id": location_strip1_id,
|
|
"feed_type_code": "layer",
|
|
"amount_kg": "5",
|
|
"nonce": "test-nonce-feed-6",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
assert "purchase" in resp.text.lower()
|
|
|
|
def test_give_feed_location_sticks(
|
|
self, client, seeded_db, location_strip1_id, feed_purchase_in_db
|
|
):
|
|
"""After successful POST, returned form shows same location selected."""
|
|
resp = client.post(
|
|
"/actions/feed-given",
|
|
data={
|
|
"location_id": location_strip1_id,
|
|
"feed_type_code": "layer",
|
|
"amount_kg": "5",
|
|
"nonce": "test-nonce-feed-7",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
# The location should be pre-selected
|
|
assert "selected" in resp.text and location_strip1_id in resp.text
|
|
|
|
def test_give_feed_type_sticks(
|
|
self, client, seeded_db, location_strip1_id, feed_purchase_in_db
|
|
):
|
|
"""After successful POST, returned form shows same feed type selected."""
|
|
resp = client.post(
|
|
"/actions/feed-given",
|
|
data={
|
|
"location_id": location_strip1_id,
|
|
"feed_type_code": "layer",
|
|
"amount_kg": "5",
|
|
"nonce": "test-nonce-feed-8",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
# The feed type should be pre-selected
|
|
assert "layer" in resp.text.lower()
|
|
|
|
|
|
class TestFeedPurchased:
|
|
"""Tests for POST /actions/feed-purchased."""
|
|
|
|
def test_purchase_feed_creates_event(self, client, seeded_db):
|
|
"""POST creates FeedPurchased event."""
|
|
resp = client.post(
|
|
"/actions/feed-purchased",
|
|
data={
|
|
"feed_type_code": "layer",
|
|
"bag_size_kg": "20",
|
|
"bags_count": "2",
|
|
"bag_price_cents": "2400",
|
|
"nonce": "test-nonce-purchase-1",
|
|
},
|
|
)
|
|
|
|
assert resp.status_code in [200, 302, 303]
|
|
|
|
# Verify event was created in database
|
|
event_row = seeded_db.execute(
|
|
"SELECT type, payload FROM events WHERE type = 'FeedPurchased' ORDER BY id DESC LIMIT 1"
|
|
).fetchone()
|
|
assert event_row is not None
|
|
assert event_row[0] == "FeedPurchased"
|
|
|
|
def test_purchase_feed_validation_bag_size_zero(self, client):
|
|
"""bag_size_kg=0 returns 422."""
|
|
resp = client.post(
|
|
"/actions/feed-purchased",
|
|
data={
|
|
"feed_type_code": "layer",
|
|
"bag_size_kg": "0",
|
|
"bags_count": "2",
|
|
"bag_price_cents": "2400",
|
|
"nonce": "test-nonce-purchase-2",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_purchase_feed_validation_bags_count_zero(self, client):
|
|
"""bags_count=0 returns 422."""
|
|
resp = client.post(
|
|
"/actions/feed-purchased",
|
|
data={
|
|
"feed_type_code": "layer",
|
|
"bag_size_kg": "20",
|
|
"bags_count": "0",
|
|
"bag_price_cents": "2400",
|
|
"nonce": "test-nonce-purchase-3",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_purchase_feed_validation_missing_feed_type(self, client):
|
|
"""Missing feed_type_code returns 422."""
|
|
resp = client.post(
|
|
"/actions/feed-purchased",
|
|
data={
|
|
"bag_size_kg": "20",
|
|
"bags_count": "2",
|
|
"bag_price_cents": "2400",
|
|
"nonce": "test-nonce-purchase-4",
|
|
},
|
|
)
|
|
assert resp.status_code == 422
|
|
|
|
def test_purchase_enables_give(self, client, seeded_db, location_strip1_id):
|
|
"""After purchasing, give feed works."""
|
|
# First purchase
|
|
resp1 = client.post(
|
|
"/actions/feed-purchased",
|
|
data={
|
|
"feed_type_code": "layer",
|
|
"bag_size_kg": "20",
|
|
"bags_count": "2",
|
|
"bag_price_cents": "2400",
|
|
"nonce": "test-nonce-purchase-5",
|
|
},
|
|
)
|
|
assert resp1.status_code in [200, 302, 303]
|
|
|
|
# Then give should work
|
|
resp2 = client.post(
|
|
"/actions/feed-given",
|
|
data={
|
|
"location_id": location_strip1_id,
|
|
"feed_type_code": "layer",
|
|
"amount_kg": "5",
|
|
"nonce": "test-nonce-feed-after-purchase",
|
|
},
|
|
)
|
|
assert resp2.status_code in [200, 302, 303]
|
|
|
|
|
|
class TestInventoryWarning:
|
|
"""Tests for inventory balance warnings."""
|
|
|
|
def test_negative_balance_shows_warning(
|
|
self, client, seeded_db, location_strip1_id, feed_purchase_in_db
|
|
):
|
|
"""Warning shown when give would result in negative balance."""
|
|
# Purchase was 5 bags x 20kg = 100kg
|
|
# Give 150kg (more than available) - should show warning but allow
|
|
resp = client.post(
|
|
"/actions/feed-given",
|
|
data={
|
|
"location_id": location_strip1_id,
|
|
"feed_type_code": "layer",
|
|
"amount_kg": "150",
|
|
"nonce": "test-nonce-warning-1",
|
|
},
|
|
)
|
|
# Should succeed but with warning
|
|
assert resp.status_code in [200, 302, 303]
|
|
# The response should contain a warning about negative inventory
|
|
assert "warning" in resp.text.lower() or "negative" in resp.text.lower()
|
|
|
|
|
|
class TestFeedRecentEvents:
|
|
"""Tests for recent events display on feed page."""
|
|
|
|
def test_give_tab_shows_recent_events_section(self, client):
|
|
"""Give Feed tab shows Recent Feed Given section."""
|
|
resp = client.get("/feed")
|
|
assert resp.status_code == 200
|
|
assert "Recent Feed Given" in resp.text
|
|
|
|
def test_purchase_tab_shows_recent_events_section(self, client):
|
|
"""Purchase Feed tab shows Recent Purchases section."""
|
|
resp = client.get("/feed?tab=purchase")
|
|
assert resp.status_code == 200
|
|
assert "Recent Purchases" in resp.text
|
|
|
|
def test_give_feed_event_appears_in_recent(
|
|
self, client, seeded_db, location_strip1_id, feed_purchase_in_db
|
|
):
|
|
"""Newly created feed given event appears in recent events list."""
|
|
resp = client.post(
|
|
"/actions/feed-given",
|
|
data={
|
|
"location_id": location_strip1_id,
|
|
"feed_type_code": "layer",
|
|
"amount_kg": "5",
|
|
"nonce": "test-nonce-recent-feed-1",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
# Recent events should include the newly created event
|
|
assert "/events/" in resp.text
|
|
|
|
def test_give_feed_event_links_to_detail(
|
|
self, client, seeded_db, location_strip1_id, feed_purchase_in_db
|
|
):
|
|
"""Feed given events in recent list link to event detail page."""
|
|
resp = client.post(
|
|
"/actions/feed-given",
|
|
data={
|
|
"location_id": location_strip1_id,
|
|
"feed_type_code": "layer",
|
|
"amount_kg": "5",
|
|
"nonce": "test-nonce-recent-feed-2",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
# Get the event ID from DB
|
|
event_row = seeded_db.execute(
|
|
"SELECT id FROM events WHERE type = 'FeedGiven' ORDER BY id DESC LIMIT 1"
|
|
).fetchone()
|
|
event_id = event_row[0]
|
|
|
|
# The response should contain a link to the event detail
|
|
assert f"/events/{event_id}" in resp.text
|
|
|
|
def test_purchase_event_appears_in_recent(self, client, seeded_db):
|
|
"""Newly created purchase event appears in recent events list."""
|
|
resp = client.post(
|
|
"/actions/feed-purchased",
|
|
data={
|
|
"feed_type_code": "layer",
|
|
"bag_size_kg": "20",
|
|
"bags_count": "2",
|
|
"bag_price_euros": "24.00",
|
|
"nonce": "test-nonce-recent-purchase-1",
|
|
},
|
|
)
|
|
# The route returns purchase tab active after purchase
|
|
assert resp.status_code == 200
|
|
assert "/events/" in resp.text
|
|
|
|
def test_purchase_event_links_to_detail(self, client, seeded_db):
|
|
"""Purchase events in recent list link to event detail page."""
|
|
resp = client.post(
|
|
"/actions/feed-purchased",
|
|
data={
|
|
"feed_type_code": "layer",
|
|
"bag_size_kg": "20",
|
|
"bags_count": "2",
|
|
"bag_price_euros": "24.00",
|
|
"nonce": "test-nonce-recent-purchase-2",
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
# Get the event ID from DB
|
|
event_row = seeded_db.execute(
|
|
"SELECT id FROM events WHERE type = 'FeedPurchased' ORDER BY id DESC LIMIT 1"
|
|
).fetchone()
|
|
event_id = event_row[0]
|
|
|
|
# The response should contain a link to the event detail
|
|
assert f"/events/{event_id}" in resp.text
|