feat: add feed given event handling

- Add FEED_GIVEN to FeedInventoryProjection with apply/revert
- Add give_feed method to FeedService
- Block feed given if no purchase exists <= ts_utc
- Validate feed type and location existence
- 13 new tests for give_feed functionality

🤖 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-29 08:10:37 +00:00
parent ff8f3bb3f5
commit fa3c99b755
4 changed files with 349 additions and 13 deletions

View File

@@ -1,13 +1,13 @@
# ABOUTME: Tests for FeedService operations.
# ABOUTME: Tests purchase_feed with inventory tracking and price storage.
# ABOUTME: Tests purchase_feed and give_feed with inventory tracking.
import time
import pytest
from animaltrack.events.payloads import FeedPurchasedPayload
from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload
from animaltrack.events.store import EventStore
from animaltrack.events.types import FEED_PURCHASED
from animaltrack.events.types import FEED_GIVEN, FEED_PURCHASED
from animaltrack.projections import ProjectionRegistry
from animaltrack.services.feed import FeedService, ValidationError
@@ -236,3 +236,211 @@ class TestFeedServicePurchasePriceCalculation:
# (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")