# 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