- 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>
447 lines
17 KiB
Python
447 lines
17 KiB
Python
# 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")
|