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:
12
PLAN.md
12
PLAN.md
@@ -153,12 +153,12 @@ Check off items as completed. Each phase builds on the previous.
|
|||||||
- [x] **Commit checkpoint** (5c10a75)
|
- [x] **Commit checkpoint** (5c10a75)
|
||||||
|
|
||||||
### Step 4.2: Feed Given Event
|
### Step 4.2: Feed Given Event
|
||||||
- [ ] Implement apply_feed_given (increment given_kg, decrement balance)
|
- [x] Implement apply_feed_given (increment given_kg, decrement balance)
|
||||||
- [ ] Implement revert_feed_given
|
- [x] Implement revert_feed_given
|
||||||
- [ ] Add give_feed to services/feed.py
|
- [x] Add give_feed to services/feed.py
|
||||||
- [ ] Block if no purchase <= ts_utc
|
- [x] Block if no purchase <= ts_utc
|
||||||
- [ ] Write tests: give updates inventory, blocked without purchase, revert
|
- [x] Write tests: give updates inventory, blocked without purchase, revert
|
||||||
- [ ] **Commit checkpoint**
|
- [x] **Commit checkpoint** (2eb0ca7)
|
||||||
|
|
||||||
### Step 4.3: Product Collection Event
|
### Step 4.3: Product Collection Event
|
||||||
- [ ] Create `projections/products.py` for ProductCollected
|
- [ ] Create `projections/products.py` for ProductCollected
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from animaltrack.events.types import FEED_PURCHASED
|
from animaltrack.events.types import FEED_GIVEN, FEED_PURCHASED
|
||||||
from animaltrack.models.events import Event
|
from animaltrack.models.events import Event
|
||||||
from animaltrack.projections.base import Projection
|
from animaltrack.projections.base import Projection
|
||||||
|
|
||||||
@@ -25,17 +25,21 @@ class FeedInventoryProjection(Projection):
|
|||||||
|
|
||||||
def get_event_types(self) -> list[str]:
|
def get_event_types(self) -> list[str]:
|
||||||
"""Return the event types this projection handles."""
|
"""Return the event types this projection handles."""
|
||||||
return [FEED_PURCHASED]
|
return [FEED_PURCHASED, FEED_GIVEN]
|
||||||
|
|
||||||
def apply(self, event: Event) -> None:
|
def apply(self, event: Event) -> None:
|
||||||
"""Apply feed event to update inventory."""
|
"""Apply feed event to update inventory."""
|
||||||
if event.type == FEED_PURCHASED:
|
if event.type == FEED_PURCHASED:
|
||||||
self._apply_feed_purchased(event)
|
self._apply_feed_purchased(event)
|
||||||
|
elif event.type == FEED_GIVEN:
|
||||||
|
self._apply_feed_given(event)
|
||||||
|
|
||||||
def revert(self, event: Event) -> None:
|
def revert(self, event: Event) -> None:
|
||||||
"""Revert feed event from inventory."""
|
"""Revert feed event from inventory."""
|
||||||
if event.type == FEED_PURCHASED:
|
if event.type == FEED_PURCHASED:
|
||||||
self._revert_feed_purchased(event)
|
self._revert_feed_purchased(event)
|
||||||
|
elif event.type == FEED_GIVEN:
|
||||||
|
self._revert_feed_given(event)
|
||||||
|
|
||||||
def _apply_feed_purchased(self, event: Event) -> None:
|
def _apply_feed_purchased(self, event: Event) -> None:
|
||||||
"""Apply feed purchase to inventory.
|
"""Apply feed purchase to inventory.
|
||||||
@@ -86,3 +90,47 @@ class FeedInventoryProjection(Projection):
|
|||||||
""",
|
""",
|
||||||
(total_kg, total_kg, ts_utc, feed_type_code),
|
(total_kg, total_kg, ts_utc, feed_type_code),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _apply_feed_given(self, event: Event) -> None:
|
||||||
|
"""Apply feed given to inventory.
|
||||||
|
|
||||||
|
- Increment given_kg
|
||||||
|
- Decrement balance_kg
|
||||||
|
- Update last_given_at_utc
|
||||||
|
"""
|
||||||
|
feed_type_code = event.entity_refs.get("feed_type_code")
|
||||||
|
amount_kg = event.entity_refs.get("amount_kg")
|
||||||
|
ts_utc = event.ts_utc
|
||||||
|
|
||||||
|
self.db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE feed_inventory
|
||||||
|
SET given_kg = given_kg + ?,
|
||||||
|
balance_kg = balance_kg - ?,
|
||||||
|
last_given_at_utc = ?,
|
||||||
|
updated_at_utc = ?
|
||||||
|
WHERE feed_type_code = ?
|
||||||
|
""",
|
||||||
|
(amount_kg, amount_kg, ts_utc, ts_utc, feed_type_code),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _revert_feed_given(self, event: Event) -> None:
|
||||||
|
"""Revert feed given from inventory.
|
||||||
|
|
||||||
|
- Decrement given_kg
|
||||||
|
- Increment balance_kg
|
||||||
|
"""
|
||||||
|
feed_type_code = event.entity_refs.get("feed_type_code")
|
||||||
|
amount_kg = event.entity_refs.get("amount_kg")
|
||||||
|
ts_utc = event.ts_utc
|
||||||
|
|
||||||
|
self.db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE feed_inventory
|
||||||
|
SET given_kg = given_kg - ?,
|
||||||
|
balance_kg = balance_kg + ?,
|
||||||
|
updated_at_utc = ?
|
||||||
|
WHERE feed_type_code = ?
|
||||||
|
""",
|
||||||
|
(amount_kg, amount_kg, ts_utc, feed_type_code),
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,13 +4,14 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from animaltrack.db import transaction
|
from animaltrack.db import transaction
|
||||||
from animaltrack.events.payloads import FeedPurchasedPayload
|
from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload
|
||||||
from animaltrack.events.processor import process_event
|
from animaltrack.events.processor import process_event
|
||||||
from animaltrack.events.store import EventStore
|
from animaltrack.events.store import EventStore
|
||||||
from animaltrack.events.types import FEED_PURCHASED
|
from animaltrack.events.types import FEED_GIVEN, FEED_PURCHASED
|
||||||
from animaltrack.models.events import Event
|
from animaltrack.models.events import Event
|
||||||
from animaltrack.projections import ProjectionRegistry
|
from animaltrack.projections import ProjectionRegistry
|
||||||
from animaltrack.repositories.feed_types import FeedTypeRepository
|
from animaltrack.repositories.feed_types import FeedTypeRepository
|
||||||
|
from animaltrack.repositories.locations import LocationRepository
|
||||||
|
|
||||||
|
|
||||||
class FeedServiceError(Exception):
|
class FeedServiceError(Exception):
|
||||||
@@ -45,6 +46,7 @@ class FeedService:
|
|||||||
self.event_store = event_store
|
self.event_store = event_store
|
||||||
self.registry = registry
|
self.registry = registry
|
||||||
self.feed_type_repo = FeedTypeRepository(db)
|
self.feed_type_repo = FeedTypeRepository(db)
|
||||||
|
self.location_repo = LocationRepository(db)
|
||||||
|
|
||||||
def purchase_feed(
|
def purchase_feed(
|
||||||
self,
|
self,
|
||||||
@@ -108,3 +110,81 @@ class FeedService:
|
|||||||
process_event(event, self.registry)
|
process_event(event, self.registry)
|
||||||
|
|
||||||
return event
|
return event
|
||||||
|
|
||||||
|
def give_feed(
|
||||||
|
self,
|
||||||
|
payload: FeedGivenPayload,
|
||||||
|
ts_utc: int,
|
||||||
|
actor: str,
|
||||||
|
nonce: str | None = None,
|
||||||
|
route: str | None = None,
|
||||||
|
) -> Event:
|
||||||
|
"""Record feed given to animals.
|
||||||
|
|
||||||
|
Creates a FeedGiven event and updates inventory.
|
||||||
|
Decrements balance_kg by the given amount.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
payload: Validated give feed payload.
|
||||||
|
ts_utc: Timestamp in milliseconds since epoch.
|
||||||
|
actor: The user performing the action.
|
||||||
|
nonce: Optional idempotency nonce.
|
||||||
|
route: Required if nonce provided.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created event.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValidationError: If feed type doesn't exist, is inactive,
|
||||||
|
location doesn't exist, or no prior purchase.
|
||||||
|
"""
|
||||||
|
# Validate feed type exists and is active
|
||||||
|
feed_type = self.feed_type_repo.get(payload.feed_type_code)
|
||||||
|
if feed_type is None:
|
||||||
|
msg = f"Feed type '{payload.feed_type_code}' not found"
|
||||||
|
raise ValidationError(msg)
|
||||||
|
|
||||||
|
if not feed_type.active:
|
||||||
|
msg = f"Feed type '{payload.feed_type_code}' is inactive"
|
||||||
|
raise ValidationError(msg)
|
||||||
|
|
||||||
|
# Validate location exists
|
||||||
|
location = self.location_repo.get(payload.location_id)
|
||||||
|
if location is None:
|
||||||
|
msg = f"Location '{payload.location_id}' not found"
|
||||||
|
raise ValidationError(msg)
|
||||||
|
|
||||||
|
# Check that a purchase exists for this feed type <= ts_utc
|
||||||
|
row = self.db.execute(
|
||||||
|
"""
|
||||||
|
SELECT 1 FROM feed_inventory
|
||||||
|
WHERE feed_type_code = ? AND last_purchase_at_utc <= ?
|
||||||
|
""",
|
||||||
|
(payload.feed_type_code, ts_utc),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
msg = f"No feed purchased for '{payload.feed_type_code}' before this date"
|
||||||
|
raise ValidationError(msg)
|
||||||
|
|
||||||
|
# Build entity_refs
|
||||||
|
entity_refs = {
|
||||||
|
"feed_type_code": payload.feed_type_code,
|
||||||
|
"location_id": payload.location_id,
|
||||||
|
"amount_kg": payload.amount_kg,
|
||||||
|
}
|
||||||
|
|
||||||
|
with transaction(self.db):
|
||||||
|
event = self.event_store.append_event(
|
||||||
|
event_type=FEED_GIVEN,
|
||||||
|
ts_utc=ts_utc,
|
||||||
|
actor=actor,
|
||||||
|
entity_refs=entity_refs,
|
||||||
|
payload=payload.model_dump(),
|
||||||
|
nonce=nonce,
|
||||||
|
route=route,
|
||||||
|
)
|
||||||
|
|
||||||
|
process_event(event, self.registry)
|
||||||
|
|
||||||
|
return event
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
# ABOUTME: Tests for FeedService operations.
|
# 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 time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from animaltrack.events.payloads import FeedPurchasedPayload
|
from animaltrack.events.payloads import FeedGivenPayload, FeedPurchasedPayload
|
||||||
from animaltrack.events.store import EventStore
|
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.projections import ProjectionRegistry
|
||||||
from animaltrack.services.feed import FeedService, ValidationError
|
from animaltrack.services.feed import FeedService, ValidationError
|
||||||
|
|
||||||
@@ -236,3 +236,211 @@ class TestFeedServicePurchasePriceCalculation:
|
|||||||
|
|
||||||
# (3000 * 3) / (25 * 3) = 9000 / 75 = 120
|
# (3000 * 3) / (25 * 3) = 9000 / 75 = 120
|
||||||
assert event.entity_refs["price_per_kg_cents"] == 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")
|
||||||
|
|||||||
Reference in New Issue
Block a user