# ABOUTME: Tests for FeedService operations. # ABOUTME: Tests purchase_feed and give_feed with inventory tracking. import time import pytest from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload from animaltrack.events.store import EventStore from animaltrack.events.types import FEED_GIVEN, FEED_PURCHASED from animaltrack.projections import ProjectionRegistry from animaltrack.services.feed import FeedService, ValidationError @pytest.fixture def event_store(seeded_db): """Create an EventStore for testing.""" return EventStore(seeded_db) @pytest.fixture def projection_registry(seeded_db): """Create a ProjectionRegistry with feed projections registered.""" from animaltrack.projections.feed import FeedInventoryProjection registry = ProjectionRegistry() registry.register(FeedInventoryProjection(seeded_db)) return registry @pytest.fixture def feed_service(seeded_db, event_store, projection_registry): """Create a FeedService for testing.""" return FeedService(seeded_db, event_store, projection_registry) def make_purchase_payload( feed_type_code: str = "layer", bag_size_kg: int = 20, bags_count: int = 2, bag_price_cents: int = 2400, vendor: str | None = None, ) -> FeedPurchasedPayload: """Create a purchase payload for testing.""" return FeedPurchasedPayload( feed_type_code=feed_type_code, bag_size_kg=bag_size_kg, bags_count=bags_count, bag_price_cents=bag_price_cents, vendor=vendor, ) # ============================================================================= # purchase_feed Tests # ============================================================================= class TestFeedServicePurchase: """Tests for purchase_feed().""" def test_creates_feed_purchased_event(self, seeded_db, feed_service): """purchase_feed creates a FeedPurchased event.""" payload = make_purchase_payload() ts_utc = int(time.time() * 1000) event = feed_service.purchase_feed(payload, ts_utc, "test_user") assert event.type == FEED_PURCHASED assert event.actor == "test_user" assert event.ts_utc == ts_utc def test_event_has_feed_type_in_entity_refs(self, seeded_db, feed_service): """Event entity_refs contains feed_type_code.""" payload = make_purchase_payload(feed_type_code="starter") ts_utc = int(time.time() * 1000) event = feed_service.purchase_feed(payload, ts_utc, "test_user") assert event.entity_refs["feed_type_code"] == "starter" def test_event_has_total_kg_in_entity_refs(self, seeded_db, feed_service): """Event entity_refs contains calculated total_kg.""" payload = make_purchase_payload(bag_size_kg=20, bags_count=3) ts_utc = int(time.time() * 1000) event = feed_service.purchase_feed(payload, ts_utc, "test_user") assert event.entity_refs["total_kg"] == 60 # 20 * 3 def test_event_has_price_per_kg_cents_in_entity_refs(self, seeded_db, feed_service): """Event entity_refs contains calculated price_per_kg_cents.""" # 2 bags of 20kg at €24 each = €48 total / 40kg = €1.20/kg = 120 cents payload = make_purchase_payload(bag_size_kg=20, bags_count=2, bag_price_cents=2400) ts_utc = int(time.time() * 1000) event = feed_service.purchase_feed(payload, ts_utc, "test_user") assert event.entity_refs["price_per_kg_cents"] == 120 def test_increments_inventory_purchased_kg(self, seeded_db, feed_service): """purchase_feed increments purchased_kg in feed_inventory.""" payload = make_purchase_payload(feed_type_code="layer", bag_size_kg=20, bags_count=2) ts_utc = int(time.time() * 1000) feed_service.purchase_feed(payload, ts_utc, "test_user") row = seeded_db.execute( "SELECT purchased_kg, balance_kg FROM feed_inventory WHERE feed_type_code = ?", ("layer",), ).fetchone() assert row[0] == 40 # purchased_kg assert row[1] == 40 # balance_kg (no given yet) def test_updates_last_purchase_price(self, seeded_db, feed_service): """purchase_feed updates last_purchase_price_per_kg_cents.""" payload = make_purchase_payload(bag_size_kg=20, bags_count=2, bag_price_cents=2400) ts_utc = int(time.time() * 1000) feed_service.purchase_feed(payload, ts_utc, "test_user") row = seeded_db.execute( "SELECT last_purchase_price_per_kg_cents FROM feed_inventory WHERE feed_type_code = ?", ("layer",), ).fetchone() assert row[0] == 120 # €1.20/kg in cents def test_updates_last_purchase_at_utc(self, seeded_db, feed_service): """purchase_feed updates last_purchase_at_utc.""" payload = make_purchase_payload() ts_utc = int(time.time() * 1000) feed_service.purchase_feed(payload, ts_utc, "test_user") row = seeded_db.execute( "SELECT last_purchase_at_utc FROM feed_inventory WHERE feed_type_code = ?", ("layer",), ).fetchone() assert row[0] == ts_utc def test_multiple_purchases_accumulate(self, seeded_db, feed_service): """Multiple purchases accumulate in inventory.""" ts_utc = int(time.time() * 1000) # First purchase: 40kg payload1 = make_purchase_payload(bag_size_kg=20, bags_count=2) feed_service.purchase_feed(payload1, ts_utc, "test_user") # Second purchase: 60kg payload2 = make_purchase_payload(bag_size_kg=20, bags_count=3) feed_service.purchase_feed(payload2, ts_utc + 1000, "test_user") row = seeded_db.execute( "SELECT purchased_kg, balance_kg FROM feed_inventory WHERE feed_type_code = ?", ("layer",), ).fetchone() assert row[0] == 100 # purchased_kg: 40 + 60 assert row[1] == 100 # balance_kg def test_latest_purchase_updates_price(self, seeded_db, feed_service): """Latest purchase updates the price per kg.""" ts_utc = int(time.time() * 1000) # First purchase at €1.20/kg payload1 = make_purchase_payload(bag_size_kg=20, bags_count=2, bag_price_cents=2400) feed_service.purchase_feed(payload1, ts_utc, "test_user") # Second purchase at €1.50/kg payload2 = make_purchase_payload(bag_size_kg=20, bags_count=2, bag_price_cents=3000) feed_service.purchase_feed(payload2, ts_utc + 1000, "test_user") row = seeded_db.execute( "SELECT last_purchase_price_per_kg_cents FROM feed_inventory WHERE feed_type_code = ?", ("layer",), ).fetchone() assert row[0] == 150 # €1.50/kg in cents class TestFeedServicePurchaseValidation: """Tests for purchase_feed() validation.""" def test_rejects_nonexistent_feed_type(self, seeded_db, feed_service): """Raises ValidationError for non-existent feed_type_code.""" payload = make_purchase_payload(feed_type_code="nonexistent") with pytest.raises(ValidationError, match="not found"): feed_service.purchase_feed(payload, int(time.time() * 1000), "test_user") def test_rejects_inactive_feed_type(self, seeded_db, feed_service): """Raises ValidationError for inactive feed_type.""" # Deactivate the layer feed type seeded_db.execute("UPDATE feed_types SET active = 0 WHERE code = 'layer'") payload = make_purchase_payload(feed_type_code="layer") with pytest.raises(ValidationError, match="inactive"): feed_service.purchase_feed(payload, int(time.time() * 1000), "test_user") class TestFeedServicePurchasePriceCalculation: """Tests for price per kg calculation.""" def test_price_per_kg_whole_number(self, seeded_db, feed_service): """Price per kg calculated correctly for whole number result.""" # 1 bag of 20kg at €24 = €1.20/kg = 120 cents payload = make_purchase_payload(bag_size_kg=20, bags_count=1, bag_price_cents=2400) ts_utc = int(time.time() * 1000) event = feed_service.purchase_feed(payload, ts_utc, "test_user") assert event.entity_refs["price_per_kg_cents"] == 120 def test_price_per_kg_rounds_down(self, seeded_db, feed_service): """Price per kg rounds down for fractional cents.""" # 1 bag of 15kg at €20 = €1.333.../kg = 133.33 cents, rounds to 133 payload = make_purchase_payload(bag_size_kg=15, bags_count=1, bag_price_cents=2000) ts_utc = int(time.time() * 1000) event = feed_service.purchase_feed(payload, ts_utc, "test_user") # 2000 cents / 15 kg = 133.33... -> 133 (floor) assert event.entity_refs["price_per_kg_cents"] == 133 def test_price_per_kg_multiple_bags(self, seeded_db, feed_service): """Price per kg calculated correctly for multiple bags.""" # 3 bags of 25kg at €30 each = €90 total / 75kg = €1.20/kg = 120 cents payload = make_purchase_payload(bag_size_kg=25, bags_count=3, bag_price_cents=3000) ts_utc = int(time.time() * 1000) event = feed_service.purchase_feed(payload, ts_utc, "test_user") # (3000 * 3) / (25 * 3) = 9000 / 75 = 120 assert event.entity_refs["price_per_kg_cents"] == 120 # ============================================================================= # give_feed Tests # ============================================================================= @pytest.fixture def location_id(seeded_db): """Get a valid location_id from seeded data.""" row = seeded_db.execute("SELECT id FROM locations LIMIT 1").fetchone() return row[0] def make_given_payload( location_id: str, feed_type_code: str = "layer", amount_kg: int = 5, notes: str | None = None, ) -> FeedGivenPayload: """Create a give feed payload for testing.""" return FeedGivenPayload( location_id=location_id, feed_type_code=feed_type_code, amount_kg=amount_kg, notes=notes, ) class TestFeedServiceGive: """Tests for give_feed().""" def test_creates_feed_given_event(self, seeded_db, feed_service, location_id): """give_feed creates a FeedGiven event.""" # Setup: first purchase some feed purchase_payload = make_purchase_payload(bag_size_kg=20, bags_count=2) ts_utc = int(time.time() * 1000) feed_service.purchase_feed(purchase_payload, ts_utc, "test_user") # Give feed give_payload = make_given_payload(location_id, amount_kg=5) event = feed_service.give_feed(give_payload, ts_utc + 1000, "test_user") assert event.type == FEED_GIVEN assert event.actor == "test_user" assert event.ts_utc == ts_utc + 1000 def test_event_has_feed_type_in_entity_refs(self, seeded_db, feed_service, location_id): """Event entity_refs contains feed_type_code.""" ts_utc = int(time.time() * 1000) feed_service.purchase_feed( make_purchase_payload(feed_type_code="starter"), ts_utc, "test_user" ) give_payload = make_given_payload(location_id, feed_type_code="starter") event = feed_service.give_feed(give_payload, ts_utc + 1000, "test_user") assert event.entity_refs["feed_type_code"] == "starter" def test_event_has_location_id_in_entity_refs(self, seeded_db, feed_service, location_id): """Event entity_refs contains location_id.""" ts_utc = int(time.time() * 1000) feed_service.purchase_feed(make_purchase_payload(), ts_utc, "test_user") give_payload = make_given_payload(location_id, amount_kg=5) event = feed_service.give_feed(give_payload, ts_utc + 1000, "test_user") assert event.entity_refs["location_id"] == location_id def test_event_has_amount_kg_in_entity_refs(self, seeded_db, feed_service, location_id): """Event entity_refs contains amount_kg.""" ts_utc = int(time.time() * 1000) feed_service.purchase_feed(make_purchase_payload(), ts_utc, "test_user") give_payload = make_given_payload(location_id, amount_kg=7) event = feed_service.give_feed(give_payload, ts_utc + 1000, "test_user") assert event.entity_refs["amount_kg"] == 7 def test_increments_given_kg(self, seeded_db, feed_service, location_id): """give_feed increments given_kg in feed_inventory.""" ts_utc = int(time.time() * 1000) feed_service.purchase_feed( make_purchase_payload(bag_size_kg=20, bags_count=2), ts_utc, "test_user" ) give_payload = make_given_payload(location_id, amount_kg=10) feed_service.give_feed(give_payload, ts_utc + 1000, "test_user") row = seeded_db.execute( "SELECT given_kg FROM feed_inventory WHERE feed_type_code = ?", ("layer",), ).fetchone() assert row[0] == 10 def test_decrements_balance_kg(self, seeded_db, feed_service, location_id): """give_feed decrements balance_kg in feed_inventory.""" ts_utc = int(time.time() * 1000) # Purchase 40kg feed_service.purchase_feed( make_purchase_payload(bag_size_kg=20, bags_count=2), ts_utc, "test_user" ) # Give 15kg give_payload = make_given_payload(location_id, amount_kg=15) feed_service.give_feed(give_payload, ts_utc + 1000, "test_user") row = seeded_db.execute( "SELECT purchased_kg, given_kg, balance_kg FROM feed_inventory " "WHERE feed_type_code = ?", ("layer",), ).fetchone() assert row[0] == 40 # purchased_kg unchanged assert row[1] == 15 # given_kg assert row[2] == 25 # balance_kg = 40 - 15 def test_updates_last_given_at_utc(self, seeded_db, feed_service, location_id): """give_feed updates last_given_at_utc.""" ts_utc = int(time.time() * 1000) feed_service.purchase_feed(make_purchase_payload(), ts_utc, "test_user") give_ts = ts_utc + 5000 give_payload = make_given_payload(location_id, amount_kg=5) feed_service.give_feed(give_payload, give_ts, "test_user") row = seeded_db.execute( "SELECT last_given_at_utc FROM feed_inventory WHERE feed_type_code = ?", ("layer",), ).fetchone() assert row[0] == give_ts def test_multiple_gives_accumulate(self, seeded_db, feed_service, location_id): """Multiple gives accumulate in given_kg.""" ts_utc = int(time.time() * 1000) feed_service.purchase_feed( make_purchase_payload(bag_size_kg=20, bags_count=5), ts_utc, "test_user" ) # First give: 10kg feed_service.give_feed( make_given_payload(location_id, amount_kg=10), ts_utc + 1000, "test_user" ) # Second give: 15kg feed_service.give_feed( make_given_payload(location_id, amount_kg=15), ts_utc + 2000, "test_user" ) row = seeded_db.execute( "SELECT purchased_kg, given_kg, balance_kg FROM feed_inventory " "WHERE feed_type_code = ?", ("layer",), ).fetchone() assert row[0] == 100 # purchased_kg (5 bags * 20kg) assert row[1] == 25 # given_kg: 10 + 15 assert row[2] == 75 # balance_kg: 100 - 25 class TestFeedServiceGiveValidation: """Tests for give_feed() validation.""" def test_rejects_nonexistent_feed_type(self, seeded_db, feed_service, location_id): """Raises ValidationError for non-existent feed_type_code.""" give_payload = make_given_payload(location_id, feed_type_code="nonexistent") with pytest.raises(ValidationError, match="not found"): feed_service.give_feed(give_payload, int(time.time() * 1000), "test_user") def test_rejects_inactive_feed_type(self, seeded_db, feed_service, location_id): """Raises ValidationError for inactive feed_type.""" seeded_db.execute("UPDATE feed_types SET active = 0 WHERE code = 'layer'") give_payload = make_given_payload(location_id) with pytest.raises(ValidationError, match="inactive"): feed_service.give_feed(give_payload, int(time.time() * 1000), "test_user") def test_rejects_nonexistent_location(self, seeded_db, feed_service): """Raises ValidationError for non-existent location_id.""" fake_location_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV" give_payload = make_given_payload(fake_location_id) with pytest.raises(ValidationError, match="not found"): feed_service.give_feed(give_payload, int(time.time() * 1000), "test_user") def test_blocks_without_prior_purchase(self, seeded_db, feed_service, location_id): """Raises ValidationError if no purchase exists for feed type.""" give_payload = make_given_payload(location_id) with pytest.raises(ValidationError, match="No feed purchased"): feed_service.give_feed(give_payload, int(time.time() * 1000), "test_user") def test_blocks_purchase_after_give_timestamp(self, seeded_db, feed_service, location_id): """Raises ValidationError if purchase is after give timestamp.""" ts_utc = int(time.time() * 1000) # Purchase at ts_utc + 1000 feed_service.purchase_feed(make_purchase_payload(), ts_utc + 1000, "test_user") # Try to give at ts_utc (before purchase) give_payload = make_given_payload(location_id) with pytest.raises(ValidationError, match="No feed purchased"): feed_service.give_feed(give_payload, ts_utc, "test_user")