# ABOUTME: Tests for Product Sold web routes. # ABOUTME: Covers GET /sell form rendering and POST /actions/product-sold. import os import pytest from starlette.testclient import TestClient 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) class TestProductSoldFormRendering: """Tests for GET /sell product sold form.""" def test_sell_form_renders(self, client): """GET /sell returns 200 with form elements.""" resp = client.get("/sell") assert resp.status_code == 200 assert "Record Sale" in resp.text or "Sell" in resp.text def test_sell_form_shows_products(self, client): """Form has product dropdown with sellable products.""" resp = client.get("/sell") assert resp.status_code == 200 # Check for seeded sellable product names assert "Duck Egg" in resp.text or "egg.duck" in resp.text def test_sell_form_has_quantity_field(self, client): """Form has quantity input field.""" resp = client.get("/sell") assert resp.status_code == 200 assert 'name="quantity"' in resp.text or 'id="quantity"' in resp.text def test_sell_form_has_total_price_field(self, client): """Form has total_price_euros input field.""" resp = client.get("/sell") assert resp.status_code == 200 assert 'name="total_price_euros"' in resp.text or 'id="total_price_euros"' in resp.text def test_sell_form_has_buyer_field(self, client): """Form has optional buyer input field.""" resp = client.get("/sell") assert resp.status_code == 200 assert 'name="buyer"' in resp.text or 'id="buyer"' in resp.text def test_sell_form_default_product_egg_duck(self, client): """Form defaults to egg.duck product (selected).""" resp = client.get("/sell") assert resp.status_code == 200 # egg.duck should be the selected option # Check for either value="egg.duck" with selected, or selected before egg.duck assert "egg.duck" in resp.text class TestProductSold: """Tests for POST /actions/product-sold.""" def test_product_sold_creates_event(self, client, seeded_db): """POST creates ProductSold event with correct data.""" resp = client.post( "/actions/product-sold", data={ "product_code": "egg.duck", "quantity": "30", "total_price_euros": "15.00", "buyer": "Local Market", "notes": "Weekly sale", "nonce": "test-nonce-sold-1", }, ) # Should succeed (200 or redirect) assert resp.status_code in [200, 302, 303] # Verify event was created in database event_row = seeded_db.execute( "SELECT type, entity_refs FROM events WHERE type = 'ProductSold' ORDER BY id DESC LIMIT 1" ).fetchone() assert event_row is not None assert event_row[0] == "ProductSold" def test_product_sold_unit_price_calculated(self, client, seeded_db): """Unit price is calculated as floor(total/qty).""" resp = client.post( "/actions/product-sold", data={ "product_code": "egg.duck", "quantity": "30", "total_price_euros": "15.00", "nonce": "test-nonce-sold-2", }, ) assert resp.status_code in [200, 302, 303] # Verify unit_price_cents in entity_refs import json event_row = seeded_db.execute( "SELECT entity_refs FROM events WHERE type = 'ProductSold' ORDER BY id DESC LIMIT 1" ).fetchone() entity_refs = json.loads(event_row[0]) assert entity_refs["unit_price_cents"] == 50 # 1500 / 30 = 50 def test_product_sold_unit_price_floor_division(self, client, seeded_db): """Unit price uses floor division (rounds down).""" resp = client.post( "/actions/product-sold", data={ "product_code": "egg.duck", "quantity": "3", "total_price_euros": "10.00", "nonce": "test-nonce-sold-3", }, ) assert resp.status_code in [200, 302, 303] import json event_row = seeded_db.execute( "SELECT entity_refs FROM events WHERE type = 'ProductSold' ORDER BY id DESC LIMIT 1" ).fetchone() entity_refs = json.loads(event_row[0]) assert entity_refs["unit_price_cents"] == 333 # floor(1000 / 3) = 333 def test_product_sold_validation_quantity_zero(self, client): """quantity=0 returns 422.""" resp = client.post( "/actions/product-sold", data={ "product_code": "egg.duck", "quantity": "0", "total_price_euros": "10.00", "nonce": "test-nonce-sold-4", }, ) assert resp.status_code == 422 def test_product_sold_validation_quantity_negative(self, client): """quantity=-1 returns 422.""" resp = client.post( "/actions/product-sold", data={ "product_code": "egg.duck", "quantity": "-1", "total_price_euros": "10.00", "nonce": "test-nonce-sold-5", }, ) assert resp.status_code == 422 def test_product_sold_validation_price_negative(self, client): """Negative price returns 422.""" resp = client.post( "/actions/product-sold", data={ "product_code": "egg.duck", "quantity": "10", "total_price_euros": "-1.00", "nonce": "test-nonce-sold-6", }, ) assert resp.status_code == 422 def test_product_sold_validation_missing_product(self, client): """Missing product_code returns 422.""" resp = client.post( "/actions/product-sold", data={ "quantity": "10", "total_price_euros": "10.00", "nonce": "test-nonce-sold-7", }, ) assert resp.status_code == 422 def test_product_sold_invalid_product(self, client): """Non-existent product returns 422.""" resp = client.post( "/actions/product-sold", data={ "product_code": "invalid.product", "quantity": "10", "total_price_euros": "10.00", "nonce": "test-nonce-sold-8", }, ) assert resp.status_code == 422 def test_product_sold_success_returns_full_page(self, client): """Successful sale returns full eggs page with tabs.""" resp = client.post( "/actions/product-sold", data={ "product_code": "egg.duck", "quantity": "12", "total_price_euros": "6.00", "nonce": "test-nonce-sold-9", }, ) assert resp.status_code == 200 # Should return full eggs page with tabs (toast via session) assert "Harvest" in resp.text assert "Sell" in resp.text def test_product_sold_optional_buyer(self, client, seeded_db): """Buyer field is optional.""" resp = client.post( "/actions/product-sold", data={ "product_code": "egg.duck", "quantity": "10", "total_price_euros": "5.00", "nonce": "test-nonce-sold-10", }, ) assert resp.status_code in [200, 302, 303] # Event should still be created event_row = seeded_db.execute( "SELECT type FROM events WHERE type = 'ProductSold' ORDER BY id DESC LIMIT 1" ).fetchone() assert event_row is not None def test_product_sold_optional_notes(self, client, seeded_db): """Notes field is optional.""" resp = client.post( "/actions/product-sold", data={ "product_code": "egg.duck", "quantity": "10", "total_price_euros": "5.00", "buyer": "Test Buyer", "nonce": "test-nonce-sold-11", }, ) assert resp.status_code in [200, 302, 303] # Event should still be created event_row = seeded_db.execute( "SELECT type FROM events WHERE type = 'ProductSold' ORDER BY id DESC LIMIT 1" ).fetchone() assert event_row is not None