feat: implement Feed Quick Capture form (Step 7.4)

Add /feed page with tabbed forms for Give Feed and Purchase Feed:
- GET /feed renders page with tabs (Give Feed default, Purchase Feed)
- POST /actions/feed-given records feed given to a location
- POST /actions/feed-purchased records feed purchases to inventory

Also adopts idiomatic FastHTML/HTMX pattern:
- Add hx-boost to base template for automatic AJAX on forms
- Refactor egg form to use action/method instead of hx_post

Spec §22 compliance:
- Integer kg only, min=1
- Warn if inventory negative (but allow)
- Toast + stay on page after submit
- Location/type stick, amount resets to default bag size

🤖 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-30 10:43:28 +00:00
parent 3ce694b15d
commit 68e1a59ec7
9 changed files with 1128 additions and 14 deletions

362
tests/test_web_feed.py Normal file
View File

@@ -0,0 +1,362 @@
# 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()