Add ORM models for the simulation data
- add new ORM models `ReplaySimulation` and `ReplayedOrder`
to store the data generated by the routing simulations
- add migrations script to create the corresponding database tables
+ create "replay_simulations" and "replayed_orders" tables
+ add missing check constraints to "orders" table
+ add unique constraint to "orders" table to enable compound key
+ drop unnecessary check constraints from the "orders" table
- add tests for the new ORM models
+ add `simulation`, `replayed_order`, `make_replay_order()`, and
`pre_order` fixtures
+ add `ReplayedOrderFactor` faker class
- fix some typos
This commit is contained in:
parent
41f75f507d
commit
e4e543bd40
19 changed files with 1677 additions and 10 deletions
|
|
@ -106,6 +106,7 @@ make_address = fake_data.make_address
|
|||
make_courier = fake_data.make_courier
|
||||
make_customer = fake_data.make_customer
|
||||
make_order = fake_data.make_order
|
||||
make_replay_order = fake_data.make_replay_order
|
||||
make_restaurant = fake_data.make_restaurant
|
||||
|
||||
address = fake_data.address
|
||||
|
|
@ -114,6 +115,10 @@ city_data = fake_data.city_data
|
|||
courier = fake_data.courier
|
||||
customer = fake_data.customer
|
||||
order = fake_data.order
|
||||
pre_order = fake_data.pre_order
|
||||
replayed_order = fake_data.replayed_order
|
||||
restaurant = fake_data.restaurant
|
||||
grid = fake_data.grid
|
||||
pixel = fake_data.pixel
|
||||
simulation = fake_data.simulation
|
||||
simulation_data = fake_data.simulation_data
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ from tests.db.fake_data.fixture_makers import make_address
|
|||
from tests.db.fake_data.fixture_makers import make_courier
|
||||
from tests.db.fake_data.fixture_makers import make_customer
|
||||
from tests.db.fake_data.fixture_makers import make_order
|
||||
from tests.db.fake_data.fixture_makers import make_replay_order
|
||||
from tests.db.fake_data.fixture_makers import make_restaurant
|
||||
from tests.db.fake_data.static_fixtures import address
|
||||
from tests.db.fake_data.static_fixtures import city
|
||||
|
|
@ -13,4 +14,8 @@ from tests.db.fake_data.static_fixtures import customer
|
|||
from tests.db.fake_data.static_fixtures import grid
|
||||
from tests.db.fake_data.static_fixtures import order
|
||||
from tests.db.fake_data.static_fixtures import pixel
|
||||
from tests.db.fake_data.static_fixtures import pre_order
|
||||
from tests.db.fake_data.static_fixtures import replayed_order
|
||||
from tests.db.fake_data.static_fixtures import restaurant
|
||||
from tests.db.fake_data.static_fixtures import simulation
|
||||
from tests.db.fake_data.static_fixtures import simulation_data
|
||||
|
|
|
|||
|
|
@ -4,5 +4,6 @@ from tests.db.fake_data.factories.addresses import AddressFactory
|
|||
from tests.db.fake_data.factories.couriers import CourierFactory
|
||||
from tests.db.fake_data.factories.customers import CustomerFactory
|
||||
from tests.db.fake_data.factories.orders import AdHocOrderFactory
|
||||
from tests.db.fake_data.factories.orders import ReplayedOrderFactory
|
||||
from tests.db.fake_data.factories.orders import ScheduledOrderFactory
|
||||
from tests.db.fake_data.factories.restaurants import RestaurantFactory
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
"""Factory to create `Order` instances."""
|
||||
|
||||
from tests.db.fake_data.factories.orders.ad_hoc import AdHocOrderFactory
|
||||
from tests.db.fake_data.factories.orders.replayed import ReplayedOrderFactory
|
||||
from tests.db.fake_data.factories.orders.scheduled import ScheduledOrderFactory
|
||||
|
|
|
|||
|
|
@ -98,12 +98,16 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
|
|||
lambda obj: obj.placed_at
|
||||
+ utils.random_timespan(min_seconds=30, max_seconds=90),
|
||||
)
|
||||
restaurant_notified_at_corrected = False
|
||||
restaurant_notified_at_corrected = factory.LazyAttribute(
|
||||
lambda obj: False if obj.restaurant_notified_at else None,
|
||||
)
|
||||
restaurant_confirmed_at = factory.LazyAttribute(
|
||||
lambda obj: obj.restaurant_notified_at
|
||||
+ utils.random_timespan(min_seconds=30, max_seconds=150),
|
||||
)
|
||||
restaurant_confirmed_at_corrected = False
|
||||
restaurant_confirmed_at_corrected = factory.LazyAttribute(
|
||||
lambda obj: False if obj.restaurant_confirmed_at else None,
|
||||
)
|
||||
# Use the database defaults of the historic data.
|
||||
estimated_prep_duration = 900
|
||||
estimated_prep_duration_corrected = False
|
||||
|
|
@ -115,17 +119,23 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
|
|||
lambda obj: obj.placed_at
|
||||
+ utils.random_timespan(min_seconds=600, max_seconds=1080),
|
||||
)
|
||||
dispatch_at_corrected = False
|
||||
dispatch_at_corrected = factory.LazyAttribute(
|
||||
lambda obj: False if obj.dispatch_at else None,
|
||||
)
|
||||
courier_notified_at = factory.LazyAttribute(
|
||||
lambda obj: obj.dispatch_at
|
||||
+ utils.random_timespan(min_seconds=100, max_seconds=140),
|
||||
)
|
||||
courier_notified_at_corrected = False
|
||||
courier_notified_at_corrected = factory.LazyAttribute(
|
||||
lambda obj: False if obj.courier_notified_at else None,
|
||||
)
|
||||
courier_accepted_at = factory.LazyAttribute(
|
||||
lambda obj: obj.courier_notified_at
|
||||
+ utils.random_timespan(min_seconds=15, max_seconds=45),
|
||||
)
|
||||
courier_accepted_at_corrected = False
|
||||
courier_accepted_at_corrected = factory.LazyAttribute(
|
||||
lambda obj: False if obj.courier_accepted_at else None,
|
||||
)
|
||||
# Sample a realistic utilization.
|
||||
utilization = factory.LazyFunction(lambda: random.choice([50, 60, 70, 80, 90, 100]))
|
||||
|
||||
|
|
@ -139,13 +149,17 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
|
|||
lambda obj: obj.reached_pickup_at
|
||||
+ utils.random_timespan(min_seconds=120, max_seconds=600),
|
||||
)
|
||||
pickup_at_corrected = False
|
||||
pickup_at_corrected = factory.LazyAttribute(
|
||||
lambda obj: False if obj.pickup_at else None,
|
||||
)
|
||||
pickup_not_confirmed = False
|
||||
left_pickup_at = factory.LazyAttribute(
|
||||
lambda obj: obj.pickup_at
|
||||
+ utils.random_timespan(min_seconds=60, max_seconds=180),
|
||||
)
|
||||
left_pickup_at_corrected = False
|
||||
left_pickup_at_corrected = factory.LazyAttribute(
|
||||
lambda obj: False if obj.left_pickup_at else None,
|
||||
)
|
||||
|
||||
# Delivery-related attributes
|
||||
# delivery_address -> set by the `make_order` fixture as there is only one `city`
|
||||
|
|
@ -157,7 +171,9 @@ class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory):
|
|||
lambda obj: obj.reached_delivery_at
|
||||
+ utils.random_timespan(min_seconds=240, max_seconds=660),
|
||||
)
|
||||
delivery_at_corrected = False
|
||||
delivery_at_corrected = factory.LazyAttribute(
|
||||
lambda obj: False if obj.delivery_at else None,
|
||||
)
|
||||
delivery_not_confirmed = False
|
||||
_courier_waited_at_delivery = factory.LazyAttribute(
|
||||
lambda obj: False if obj.delivery_at else None,
|
||||
|
|
|
|||
120
tests/db/fake_data/factories/orders/replayed.py
Normal file
120
tests/db/fake_data/factories/orders/replayed.py
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
"""Factory to create `Order` and `ReplayedOrder` instances."""
|
||||
|
||||
import datetime as dt
|
||||
|
||||
import factory
|
||||
from factory import alchemy
|
||||
|
||||
from tests.db.fake_data.factories import utils
|
||||
from urban_meal_delivery import db
|
||||
|
||||
|
||||
class ReplayedOrderFactory(alchemy.SQLAlchemyModelFactory):
|
||||
"""Create instances of the `db.ReplayedOrder` model.
|
||||
|
||||
For simplicity, we assume the underlying `.order` to be an ad-hoc `Order`.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = db.ReplayedOrder
|
||||
sqlalchemy_get_or_create = ('simulation_id', 'order_id')
|
||||
|
||||
# Generic columns
|
||||
# simulation -> set by the `make_replay_order` fixture for better control
|
||||
# actual (`Order`) -> set by the `make_replay_order` fixture for better control
|
||||
|
||||
# `Order`-type related columns
|
||||
ad_hoc = factory.LazyAttribute(lambda obj: obj.actual.ad_hoc)
|
||||
placed_at = factory.LazyAttribute(lambda obj: obj.actual.placed_at)
|
||||
scheduled_delivery_at = factory.LazyAttribute(
|
||||
lambda obj: obj.actual.scheduled_delivery_at,
|
||||
)
|
||||
cancelled_at = None
|
||||
|
||||
# Restaurant-related columns
|
||||
estimated_prep_duration = 1200
|
||||
restaurant_notified_at = factory.LazyAttribute(
|
||||
lambda obj: obj.placed_at
|
||||
+ utils.random_timespan(min_seconds=1, max_seconds=30),
|
||||
)
|
||||
restaurant_confirmed_at = factory.LazyAttribute(
|
||||
lambda obj: obj.restaurant_notified_at
|
||||
+ utils.random_timespan(min_seconds=1, max_seconds=60),
|
||||
)
|
||||
restaurant_ready_at = factory.LazyAttribute(
|
||||
lambda obj: obj.restaurant_confirmed_at
|
||||
+ dt.timedelta(seconds=obj.estimated_prep_duration)
|
||||
+ utils.random_timespan(min_seconds=300, max_seconds=300),
|
||||
)
|
||||
|
||||
# Dispatch-related columns
|
||||
dispatch_at = factory.LazyAttribute(
|
||||
lambda obj: obj.actual.placed_at
|
||||
+ utils.random_timespan(min_seconds=30, max_seconds=60),
|
||||
)
|
||||
first_estimated_delivery_at = factory.LazyAttribute(
|
||||
lambda obj: obj.restaurant_notified_at
|
||||
+ dt.timedelta(seconds=obj.estimated_prep_duration)
|
||||
+ dt.timedelta(minutes=10),
|
||||
)
|
||||
# courier -> set by the `make_replay_order` fixture for better control
|
||||
courier_notified_at = factory.LazyAttribute(
|
||||
lambda obj: obj.dispatch_at
|
||||
+ utils.random_timespan(min_seconds=1, max_seconds=30),
|
||||
)
|
||||
courier_accepted_at = factory.LazyAttribute(
|
||||
lambda obj: obj.courier_notified_at
|
||||
+ utils.random_timespan(min_seconds=1, max_seconds=60),
|
||||
)
|
||||
utilization = None
|
||||
|
||||
# Pickup-related columns
|
||||
reached_pickup_at = factory.LazyAttribute(
|
||||
lambda obj: obj.restaurant_ready_at
|
||||
+ utils.random_timespan(min_seconds=1, max_seconds=60),
|
||||
)
|
||||
pickup_at = factory.LazyAttribute(
|
||||
lambda obj: obj.reached_pickup_at
|
||||
+ utils.random_timespan(min_seconds=30, max_seconds=60),
|
||||
)
|
||||
left_pickup_at = factory.LazyAttribute(
|
||||
lambda obj: obj.pickup_at
|
||||
+ utils.random_timespan(min_seconds=30, max_seconds=60),
|
||||
)
|
||||
|
||||
# Delivery-related columns
|
||||
reached_delivery_at = factory.LazyAttribute(
|
||||
lambda obj: obj.left_pickup_at
|
||||
+ utils.random_timespan(min_minutes=5, max_minutes=10),
|
||||
)
|
||||
delivery_at = factory.LazyAttribute(
|
||||
lambda obj: obj.reached_delivery_at
|
||||
+ utils.random_timespan(min_seconds=30, max_seconds=60),
|
||||
)
|
||||
|
||||
@factory.post_generation
|
||||
def post(obj, _create, _extracted, **_kwargs): # noqa:B902,C901,N805
|
||||
"""Discard timestamps that occur after cancellation."""
|
||||
if obj.cancelled_at:
|
||||
if obj.cancelled_at <= obj.restaurant_notified_at:
|
||||
obj.restaurant_notified_at = None
|
||||
if obj.cancelled_at <= obj.restaurant_confirmed_at:
|
||||
obj.restaurant_confirmed_at = None
|
||||
if obj.cancelled_at <= obj.restaurant_ready_at:
|
||||
obj.restaurant_ready_at = None
|
||||
if obj.cancelled_at <= obj.dispatch_at:
|
||||
obj.dispatch_at = None
|
||||
if obj.cancelled_at <= obj.courier_notified_at:
|
||||
obj.courier_notified_at = None
|
||||
if obj.cancelled_at <= obj.courier_accepted_at:
|
||||
obj.courier_accepted_at = None
|
||||
if obj.cancelled_at <= obj.reached_pickup_at:
|
||||
obj.reached_pickup_at = None
|
||||
if obj.cancelled_at <= obj.pickup_at:
|
||||
obj.pickup_at = None
|
||||
if obj.cancelled_at <= obj.left_pickup_at:
|
||||
obj.left_pickup_at = None
|
||||
if obj.cancelled_at <= obj.reached_delivery_at:
|
||||
obj.reached_delivery_at = None
|
||||
if obj.cancelled_at <= obj.delivery_at:
|
||||
obj.delivery_at = None
|
||||
|
|
@ -103,3 +103,41 @@ def make_order(make_address, make_courier, make_customer, make_restaurant):
|
|||
)
|
||||
|
||||
return func
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_replay_order(make_order, simulation):
|
||||
"""Replaces `ReplayedOrderFactory.build()`: Create a `ReplayedOrder`."""
|
||||
# Reset the identifiers before every test.
|
||||
factories.ReplayedOrderFactory.reset_sequence(1)
|
||||
|
||||
def func(scheduled=False, order=None, **kwargs):
|
||||
"""Create a new `ReplayedOrder` object.
|
||||
|
||||
Each `ReplayOrder` is made by a new `Customer` with a unique `Address`.
|
||||
|
||||
Args:
|
||||
scheduled: if an `Order` is a pre-order
|
||||
order: the `Order` that is replayed
|
||||
kwargs: keyword arguments forwarded to the `ReplayedOrderFactory`
|
||||
|
||||
Returns:
|
||||
order
|
||||
|
||||
Raises:
|
||||
RuntimeError: if `scheduled=True` is passed in
|
||||
together with an ad-hoc `order`
|
||||
"""
|
||||
if scheduled:
|
||||
if order and order.ad_hoc is False:
|
||||
raise RuntimeError('`order` must be scheduled')
|
||||
elif order is None:
|
||||
order = make_order(scheduled=True)
|
||||
elif order is None:
|
||||
order = make_order()
|
||||
|
||||
return factories.ReplayedOrderFactory.build(
|
||||
simulation=simulation, actual=order, courier=order.courier, **kwargs,
|
||||
)
|
||||
|
||||
return func
|
||||
|
|
|
|||
|
|
@ -54,10 +54,22 @@ def restaurant(address, make_restaurant):
|
|||
|
||||
@pytest.fixture
|
||||
def order(make_order, restaurant):
|
||||
"""An `Order` object for the `restaurant`."""
|
||||
"""An ad-hoc `Order` object for the `restaurant`."""
|
||||
return make_order(restaurant=restaurant)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pre_order(make_order, restaurant):
|
||||
"""A scheduled `Order` object for the `restaurant`."""
|
||||
return make_order(restaurant=restaurant, scheduled=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def replayed_order(make_replay_order, order):
|
||||
"""A `ReplayedOrder` object for the `restaurant`."""
|
||||
return make_replay_order(order=order)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def grid(city):
|
||||
"""A `Grid` with a pixel area of 1 square kilometer."""
|
||||
|
|
@ -68,3 +80,20 @@ def grid(city):
|
|||
def pixel(grid):
|
||||
"""The `Pixel` in the lower-left corner of the `grid`."""
|
||||
return db.Pixel(id=1, grid=grid, n_x=0, n_y=0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simulation_data(city):
|
||||
"""The data for the one and only `ReplaySimulation` object as a `dict`."""
|
||||
return {
|
||||
'id': 1,
|
||||
'city': city,
|
||||
'strategy': 'best_possible_routing',
|
||||
'run': 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simulation(simulation_data):
|
||||
"""The one and only `ReplaySimulation` object."""
|
||||
return db.ReplaySimulation(**simulation_data)
|
||||
|
|
|
|||
1
tests/db/replay/__init__.py
Normal file
1
tests/db/replay/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Test the replay simulation models in the ORM layer."""
|
||||
585
tests/db/replay/test_orders.py
Normal file
585
tests/db/replay/test_orders.py
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
"""Test the ORM's `ReplayedOrder` model."""
|
||||
|
||||
import datetime as dt
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sqla
|
||||
from sqlalchemy import exc as sa_exc
|
||||
|
||||
from tests import config as test_config
|
||||
from urban_meal_delivery import db
|
||||
|
||||
|
||||
class TestSpecialMethods:
|
||||
"""Test special methods in `ReplayedOrder`."""
|
||||
|
||||
def test_create_order(self, replayed_order):
|
||||
"""Test instantiation of a new `ReplayedOrder` object."""
|
||||
assert replayed_order is not None
|
||||
|
||||
def test_text_representation(self, replayed_order):
|
||||
"""`ReplayedOrder` has a non-literal text representation."""
|
||||
result = repr(replayed_order)
|
||||
|
||||
assert result == f'<ReplayedOrder(#{replayed_order.actual.id})>'
|
||||
|
||||
|
||||
@pytest.mark.db
|
||||
@pytest.mark.no_cover
|
||||
class TestConstraints:
|
||||
"""Test the database constraints defined in `ReplayedOrder`."""
|
||||
|
||||
def test_insert_into_into_database(self, db_session, replayed_order):
|
||||
"""Insert an instance into the (empty) database."""
|
||||
assert db_session.query(db.ReplayedOrder).count() == 0
|
||||
|
||||
db_session.add(replayed_order)
|
||||
db_session.commit()
|
||||
|
||||
assert db_session.query(db.ReplayedOrder).count() == 1
|
||||
|
||||
def test_delete_a_referenced_simulation(self, db_session, replayed_order):
|
||||
"""Remove a record that is referenced with a FK."""
|
||||
db_session.add(replayed_order)
|
||||
db_session.commit()
|
||||
|
||||
# Must delete without ORM as otherwise an UPDATE statement is emitted.
|
||||
stmt = sqla.delete(db.ReplaySimulation).where(
|
||||
db.ReplaySimulation.id == replayed_order.simulation.id,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError, match='fk_replayed_orders_to_replay_simulations',
|
||||
):
|
||||
db_session.execute(stmt)
|
||||
|
||||
def test_delete_a_referenced_actual_order(self, db_session, replayed_order):
|
||||
"""Remove a record that is referenced with a FK."""
|
||||
db_session.add(replayed_order)
|
||||
db_session.commit()
|
||||
|
||||
# Must delete without ORM as otherwise an UPDATE statement is emitted.
|
||||
stmt = sqla.delete(db.Order).where(db.Order.id == replayed_order.actual.id)
|
||||
|
||||
with pytest.raises(sa_exc.IntegrityError, match='fk_replayed_orders_to_orders'):
|
||||
db_session.execute(stmt)
|
||||
|
||||
def test_delete_a_referenced_courier(
|
||||
self, db_session, replayed_order, make_courier,
|
||||
):
|
||||
"""Remove a record that is referenced with a FK."""
|
||||
# Need a second courier, one that is
|
||||
# not associated with the `.actual` order.
|
||||
replayed_order.courier = make_courier()
|
||||
|
||||
db_session.add(replayed_order)
|
||||
db_session.commit()
|
||||
|
||||
# Must delete without ORM as otherwise an UPDATE statement is emitted.
|
||||
stmt = sqla.delete(db.Courier).where(db.Courier.id == replayed_order.courier.id)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError, match='fk_replayed_orders_to_couriers',
|
||||
):
|
||||
db_session.execute(stmt)
|
||||
|
||||
def test_ad_hoc_order_with_scheduled_delivery_at(self, db_session, replayed_order):
|
||||
"""Insert an instance with invalid data."""
|
||||
assert replayed_order.ad_hoc is True
|
||||
|
||||
replayed_order.scheduled_delivery_at = dt.datetime(*test_config.DATE, 18, 0)
|
||||
|
||||
db_session.add(replayed_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError, match='either_ad_hoc_or_scheduled_order',
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
def test_scheduled_order_without_scheduled_delivery_at(
|
||||
self, db_session, make_replay_order,
|
||||
):
|
||||
"""Insert an instance with invalid data."""
|
||||
replay_order = make_replay_order(scheduled=True)
|
||||
|
||||
assert replay_order.ad_hoc is False
|
||||
|
||||
replay_order.scheduled_delivery_at = None
|
||||
|
||||
db_session.add(replay_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError, match='either_ad_hoc_or_scheduled_order',
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
def test_ad_hoc_order_too_early(self, db_session, make_replay_order):
|
||||
"""Insert an instance with invalid data."""
|
||||
replay_order = make_replay_order(
|
||||
placed_at=dt.datetime(*test_config.DATE, 10, 0),
|
||||
)
|
||||
|
||||
db_session.add(replay_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError, match='ad_hoc_orders_within_business_hours',
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
def test_ad_hoc_order_too_late(self, db_session, make_replay_order):
|
||||
"""Insert an instance with invalid data."""
|
||||
replay_order = make_replay_order(
|
||||
placed_at=dt.datetime(*test_config.DATE, 23, 0),
|
||||
)
|
||||
|
||||
db_session.add(replay_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError, match='ad_hoc_orders_within_business_hours',
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
def test_scheduled_order_way_too_early(self, db_session, make_replay_order):
|
||||
"""Insert an instance with invalid data."""
|
||||
scheduled_delivery_at = dt.datetime(*test_config.DATE, 10, 0)
|
||||
replay_pre_order = make_replay_order(
|
||||
scheduled=True,
|
||||
placed_at=scheduled_delivery_at - dt.timedelta(hours=12),
|
||||
scheduled_delivery_at=scheduled_delivery_at,
|
||||
restaurant_notified_at=scheduled_delivery_at + dt.timedelta(hours=1),
|
||||
dispatch_at=scheduled_delivery_at + dt.timedelta(hours=1),
|
||||
)
|
||||
|
||||
db_session.add(replay_pre_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError, match='scheduled_orders_within_business_hours',
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
def test_scheduled_order_a_bit_too_early(self, db_session, make_replay_order):
|
||||
"""Insert an instance with invalid data."""
|
||||
scheduled_delivery_at = dt.datetime(*test_config.DATE, 11, 30)
|
||||
replay_pre_order = make_replay_order(
|
||||
scheduled=True,
|
||||
placed_at=scheduled_delivery_at - dt.timedelta(hours=14),
|
||||
scheduled_delivery_at=scheduled_delivery_at,
|
||||
restaurant_notified_at=scheduled_delivery_at + dt.timedelta(hours=1),
|
||||
dispatch_at=scheduled_delivery_at + dt.timedelta(hours=1),
|
||||
)
|
||||
|
||||
db_session.add(replay_pre_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError, match='scheduled_orders_within_business_hours',
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
def test_scheduled_order_not_too_early(self, db_session, make_replay_order):
|
||||
"""Insert an instance with invalid data.
|
||||
|
||||
11.45 is the only time outside noon to 11 pm when a scheduled order is allowed.
|
||||
"""
|
||||
scheduled_delivery_at = dt.datetime(*test_config.DATE, 11, 45)
|
||||
replay_pre_order = make_replay_order(
|
||||
scheduled=True,
|
||||
placed_at=scheduled_delivery_at - dt.timedelta(hours=14),
|
||||
scheduled_delivery_at=scheduled_delivery_at,
|
||||
restaurant_notified_at=scheduled_delivery_at + dt.timedelta(hours=1),
|
||||
dispatch_at=scheduled_delivery_at + dt.timedelta(hours=1),
|
||||
)
|
||||
|
||||
assert db_session.query(db.Order).count() == 0
|
||||
|
||||
db_session.add(replay_pre_order)
|
||||
db_session.commit()
|
||||
|
||||
assert db_session.query(db.Order).count() == 1
|
||||
|
||||
def test_scheduled_order_too_late(self, db_session, make_replay_order):
|
||||
"""Insert an instance with invalid data."""
|
||||
scheduled_delivery_at = dt.datetime(*test_config.DATE, 23, 0)
|
||||
replay_pre_order = make_replay_order(
|
||||
scheduled=True,
|
||||
placed_at=scheduled_delivery_at - dt.timedelta(hours=10),
|
||||
scheduled_delivery_at=scheduled_delivery_at,
|
||||
restaurant_notified_at=scheduled_delivery_at + dt.timedelta(minutes=30),
|
||||
dispatch_at=scheduled_delivery_at + dt.timedelta(minutes=30),
|
||||
)
|
||||
|
||||
db_session.add(replay_pre_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError, match='scheduled_orders_within_business_hours',
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
@pytest.mark.parametrize('minute', [min_ for min_ in range(60) if min_ % 15 != 0])
|
||||
def test_scheduled_order_at_non_quarter_of_an_hour(
|
||||
self, db_session, make_replay_order, minute,
|
||||
):
|
||||
"""Insert an instance with invalid data."""
|
||||
scheduled_delivery_at = dt.datetime( # `minute` is not 0, 15, 30, or 45
|
||||
*test_config.DATE, test_config.NOON, minute,
|
||||
)
|
||||
replay_pre_order = make_replay_order(
|
||||
scheduled=True,
|
||||
placed_at=scheduled_delivery_at - dt.timedelta(hours=10),
|
||||
scheduled_delivery_at=scheduled_delivery_at,
|
||||
restaurant_notified_at=scheduled_delivery_at + dt.timedelta(minutes=30),
|
||||
dispatch_at=scheduled_delivery_at + dt.timedelta(minutes=30),
|
||||
)
|
||||
|
||||
db_session.add(replay_pre_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError,
|
||||
match='scheduled_orders_must_be_at_quart', # constraint name too long
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
@pytest.mark.parametrize('second', list(range(1, 60)))
|
||||
def test_scheduled_order_at_non_quarter_of_an_hour_by_seconds(
|
||||
self, db_session, make_replay_order, second,
|
||||
):
|
||||
"""Insert an instance with invalid data."""
|
||||
scheduled_delivery_at = dt.datetime(
|
||||
*test_config.DATE, test_config.NOON, 0, second,
|
||||
)
|
||||
replay_pre_order = make_replay_order(
|
||||
scheduled=True,
|
||||
placed_at=scheduled_delivery_at - dt.timedelta(hours=10),
|
||||
scheduled_delivery_at=scheduled_delivery_at,
|
||||
restaurant_notified_at=scheduled_delivery_at + dt.timedelta(minutes=30),
|
||||
dispatch_at=scheduled_delivery_at + dt.timedelta(minutes=30),
|
||||
)
|
||||
|
||||
db_session.add(replay_pre_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError,
|
||||
match='scheduled_orders_must_be_at_quart', # constraint name too long
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
def test_scheduled_order_too_soon(self, db_session, make_replay_order):
|
||||
"""Insert an instance with invalid data.
|
||||
|
||||
Scheduled orders must be at least 30 minutes into the future.
|
||||
"""
|
||||
# Create an ad-hoc order first and then make that a scheduled order.
|
||||
# This way, it is less work to keep the timestamps consistent.
|
||||
replay_pre_order = make_replay_order(scheduled=False)
|
||||
|
||||
# Make the `scheduled_delivery_at` the quarter of an hour
|
||||
# following the next quarter of an hour (i.e., the timestamp
|
||||
# is between 15 and 30 minutes into the future).
|
||||
replay_pre_order.ad_hoc = False
|
||||
replay_pre_order.actual.ad_hoc = False
|
||||
minutes_to_next_quarter = 15 - (replay_pre_order.placed_at.minute % 15)
|
||||
scheduled_delivery_at = (
|
||||
# `.placed_at` may have non-0 seconds.
|
||||
replay_pre_order.placed_at.replace(second=0)
|
||||
+ dt.timedelta(minutes=(minutes_to_next_quarter + 15))
|
||||
)
|
||||
replay_pre_order.scheduled_delivery_at = scheduled_delivery_at
|
||||
replay_pre_order.actual.scheduled_delivery_at = scheduled_delivery_at
|
||||
replay_pre_order.actual.scheduled_delivery_at_corrected = False
|
||||
|
||||
db_session.add(replay_pre_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError, match='scheduled_orders_not_within_30_minutes',
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'column',
|
||||
[
|
||||
'restaurant_notified_at',
|
||||
'restaurant_confirmed_at',
|
||||
'restaurant_ready_at',
|
||||
'dispatch_at',
|
||||
'courier', # not `.courier_id`
|
||||
'courier_notified_at',
|
||||
'courier_accepted_at',
|
||||
'reached_pickup_at',
|
||||
'pickup_at',
|
||||
'left_pickup_at',
|
||||
'reached_delivery_at',
|
||||
'delivery_at',
|
||||
],
|
||||
)
|
||||
def test_not_fully_simulated_order(self, db_session, replayed_order, column):
|
||||
"""Insert an instance with invalid data."""
|
||||
assert replayed_order.cancelled_at is None
|
||||
|
||||
setattr(replayed_order, column, None)
|
||||
|
||||
db_session.add(replayed_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError,
|
||||
match='either_cancelled_o', # constraint name too long
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'column',
|
||||
[
|
||||
'restaurant_notified_at',
|
||||
'restaurant_confirmed_at',
|
||||
'restaurant_ready_at',
|
||||
'dispatch_at',
|
||||
'courier_id',
|
||||
'courier_notified_at',
|
||||
'courier_accepted_at',
|
||||
'reached_pickup_at',
|
||||
],
|
||||
)
|
||||
def test_simulated_cancellation(self, db_session, replayed_order, column):
|
||||
"""Insert an instance with invalid data.
|
||||
|
||||
Cancelled orders may have missing timestamps
|
||||
"""
|
||||
replayed_order.cancelled_at = replayed_order.pickup_at
|
||||
|
||||
replayed_order.pickup_at = None
|
||||
replayed_order.left_pickup_at = None
|
||||
replayed_order.reached_delivery_at = None
|
||||
replayed_order.delivery_at = None
|
||||
|
||||
setattr(replayed_order, column, None)
|
||||
|
||||
db_session.add(replayed_order)
|
||||
db_session.commit()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'column', ['pickup_at', 'left_pickup_at', 'reached_delivery_at', 'delivery_at'],
|
||||
)
|
||||
def test_no_simulated_cancellation_after_pickup(
|
||||
self, db_session, replayed_order, column,
|
||||
):
|
||||
"""Insert an instance with invalid data."""
|
||||
# Setting `.cancelled_at` to the end of a day
|
||||
# ensures the timestamps are logically ok.
|
||||
replayed_order.cancelled_at = dt.datetime(*test_config.DATE, 23)
|
||||
|
||||
# Set all timestamps after `.reached_pickup_at` to NULL
|
||||
# except the one under test.
|
||||
for unset_column in (
|
||||
'pickup_at',
|
||||
'left_pickup_at',
|
||||
'reached_delivery_at',
|
||||
'delivery_at',
|
||||
):
|
||||
if unset_column != column:
|
||||
setattr(replayed_order, unset_column, None)
|
||||
|
||||
db_session.add(replayed_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError,
|
||||
match='cancellations_may_only_occur_befo', # constraint name too long
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
@pytest.mark.parametrize('duration', [-1, 3601])
|
||||
def test_estimated_prep_duration_out_of_range(
|
||||
self, db_session, replayed_order, duration,
|
||||
):
|
||||
"""Insert an instance with invalid data."""
|
||||
replayed_order.estimated_prep_duration = duration
|
||||
db_session.add(replayed_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError, match='between_0', # constraint name too long
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
@pytest.mark.parametrize('duration', [1, 59, 119, 3599])
|
||||
def test_estimated_prep_duration_not_whole_minute(
|
||||
self, db_session, replayed_order, duration,
|
||||
):
|
||||
"""Insert an instance with invalid data."""
|
||||
replayed_order.estimated_prep_duration = duration
|
||||
db_session.add(replayed_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError, match='must_be_w', # constraint name too long
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
@pytest.mark.parametrize('utilization', [-1, 101])
|
||||
def test_utilization_out_of_range(self, db_session, replayed_order, utilization):
|
||||
"""Insert an instance with invalid data."""
|
||||
replayed_order.utilization = utilization
|
||||
db_session.add(replayed_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError, match='between_0_and_100',
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
@pytest.mark.parametrize('column', ['restaurant_notified_at', 'dispatch_at'])
|
||||
@pytest.mark.parametrize('hour', [0, 10])
|
||||
def test_order_dispatched_in_non_business_hour(
|
||||
self, db_session, replayed_order, column, hour,
|
||||
):
|
||||
"""Insert an instance with invalid data."""
|
||||
orig_timestamp = getattr(replayed_order, column)
|
||||
new_timestamp = orig_timestamp.replace(hour=hour)
|
||||
|
||||
replayed_order.placed_at = new_timestamp - dt.timedelta(minutes=1)
|
||||
setattr(replayed_order, column, new_timestamp)
|
||||
|
||||
db_session.add(replayed_order)
|
||||
|
||||
with pytest.raises(
|
||||
sa_exc.IntegrityError, match='business_hours',
|
||||
):
|
||||
db_session.commit()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'comparison',
|
||||
[
|
||||
'placed_at < restaurant_notified_at',
|
||||
'placed_at < restaurant_confirmed_at',
|
||||
'placed_at < restaurant_ready_at',
|
||||
'placed_at < dispatch_at',
|
||||
'placed_at < first_estimated_delivery_at',
|
||||
'placed_at < courier_notified_at',
|
||||
'placed_at < courier_accepted_at',
|
||||
'placed_at < reached_pickup_at',
|
||||
'placed_at < pickup_at',
|
||||
'placed_at < left_pickup_at',
|
||||
'placed_at < reached_delivery_at',
|
||||
'placed_at < delivery_at',
|
||||
'restaurant_notified_at < restaurant_confirmed_at',
|
||||
'restaurant_notified_at < restaurant_ready_at',
|
||||
'restaurant_notified_at < pickup_at',
|
||||
'restaurant_confirmed_at < restaurant_ready_at',
|
||||
'restaurant_confirmed_at < pickup_at',
|
||||
'restaurant_ready_at < pickup_at',
|
||||
'dispatch_at < first_estimated_delivery_at',
|
||||
'dispatch_at < courier_notified_at',
|
||||
'dispatch_at < courier_accepted_at',
|
||||
'dispatch_at < reached_pickup_at',
|
||||
'dispatch_at < pickup_at',
|
||||
'dispatch_at < left_pickup_at',
|
||||
'dispatch_at < reached_delivery_at',
|
||||
'dispatch_at < delivery_at',
|
||||
'courier_notified_at < courier_accepted_at',
|
||||
'courier_notified_at < reached_pickup_at',
|
||||
'courier_notified_at < pickup_at',
|
||||
'courier_notified_at < left_pickup_at',
|
||||
'courier_notified_at < reached_delivery_at',
|
||||
'courier_notified_at < delivery_at',
|
||||
'courier_accepted_at < reached_pickup_at',
|
||||
'courier_accepted_at < pickup_at',
|
||||
'courier_accepted_at < left_pickup_at',
|
||||
'courier_accepted_at < reached_delivery_at',
|
||||
'courier_accepted_at < delivery_at',
|
||||
'reached_pickup_at < pickup_at',
|
||||
'reached_pickup_at < left_pickup_at',
|
||||
'reached_pickup_at < reached_delivery_at',
|
||||
'reached_pickup_at < delivery_at',
|
||||
'pickup_at < left_pickup_at',
|
||||
'pickup_at < reached_delivery_at',
|
||||
'pickup_at < delivery_at',
|
||||
'left_pickup_at < reached_delivery_at',
|
||||
'left_pickup_at < delivery_at',
|
||||
'reached_delivery_at < delivery_at',
|
||||
],
|
||||
)
|
||||
def test_timestamps_unordered(
|
||||
self, db_session, replayed_order, comparison,
|
||||
):
|
||||
"""Insert an instance with invalid data.
|
||||
|
||||
There are two special cases for this test case below,
|
||||
where other attributes on `replayed_order` must be unset.
|
||||
"""
|
||||
smaller, bigger = comparison.split(' < ')
|
||||
|
||||
assert smaller is not None
|
||||
|
||||
violating_timestamp = getattr(replayed_order, smaller) - dt.timedelta(seconds=1)
|
||||
setattr(replayed_order, bigger, violating_timestamp)
|
||||
|
||||
db_session.add(replayed_order)
|
||||
|
||||
with pytest.raises(sa_exc.IntegrityError, match='ordered_timestamps'):
|
||||
db_session.commit()
|
||||
|
||||
def test_timestamps_unordered_scheduled(self, db_session, make_replay_order):
|
||||
"""Insert an instance with invalid data.
|
||||
|
||||
This is one of two special cases. See the generic case above.
|
||||
"""
|
||||
replayed_pre_order = make_replay_order(scheduled=True)
|
||||
# As we subtract 1 second in the generic case,
|
||||
# choose one second after a quarter of an hour.
|
||||
replayed_pre_order.placed_at = dt.datetime(*test_config.DATE, 11, 45, 1)
|
||||
self.test_timestamps_unordered(
|
||||
db_session, replayed_pre_order, 'placed_at < scheduled_delivery_at',
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'comparison',
|
||||
[
|
||||
'placed_at < cancelled_at',
|
||||
'restaurant_notified_at < cancelled_at',
|
||||
'restaurant_confirmed_at < cancelled_at',
|
||||
'restaurant_ready_at < cancelled_at',
|
||||
'dispatch_at < cancelled_at',
|
||||
'courier_notified_at < cancelled_at',
|
||||
'courier_accepted_at < cancelled_at',
|
||||
'reached_pickup_at < cancelled_at',
|
||||
],
|
||||
)
|
||||
def test_timestamps_unordered_cancelled(
|
||||
self, db_session, replayed_order, comparison,
|
||||
):
|
||||
"""Insert an instance with invalid data.
|
||||
|
||||
This is one of two special cases. See the generic case above.
|
||||
"""
|
||||
replayed_order.pickup_at = None
|
||||
replayed_order.left_pickup_at = None
|
||||
replayed_order.reached_delivery_at = None
|
||||
replayed_order.delivery_at = None
|
||||
|
||||
self.test_timestamps_unordered(db_session, replayed_order, comparison)
|
||||
|
||||
|
||||
class TestProperties:
|
||||
"""Test properties in `ReplayedOrder`.
|
||||
|
||||
The `replayed_order` fixture uses the defaults specified in
|
||||
`factories.ReplayedOrderFactory` and provided by the `make_replay_order` fixture.
|
||||
"""
|
||||
|
||||
def test_has_customer(self, replayed_order):
|
||||
"""Test `ReplayedOrder.customer` property."""
|
||||
result = replayed_order.customer
|
||||
|
||||
assert result is replayed_order.actual.customer
|
||||
|
||||
def test_has_restaurant(self, replayed_order):
|
||||
"""Test `ReplayedOrder.restaurant` property."""
|
||||
result = replayed_order.restaurant
|
||||
|
||||
assert result is replayed_order.actual.restaurant
|
||||
|
||||
def test_has_pickup_address(self, replayed_order):
|
||||
"""Test `ReplayedOrder.pickup_address` property."""
|
||||
result = replayed_order.pickup_address
|
||||
|
||||
assert result is replayed_order.actual.pickup_address
|
||||
|
||||
def test_has_delivery_address(self, replayed_order):
|
||||
"""Test `ReplayedOrder.delivery_address` property."""
|
||||
result = replayed_order.delivery_address
|
||||
|
||||
assert result is replayed_order.actual.delivery_address
|
||||
17
tests/db/replay/test_simulations.py
Normal file
17
tests/db/replay/test_simulations.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
"""Test the ORM's `ReplaySimulation` model."""
|
||||
|
||||
|
||||
class TestSpecialMethods:
|
||||
"""Test special methods in `ReplaySimulation`."""
|
||||
|
||||
def test_create_simulation(self, simulation):
|
||||
"""Test instantiation of a new `ReplaySimulation` object."""
|
||||
assert simulation is not None
|
||||
|
||||
def test_text_representation(self, simulation):
|
||||
"""`ReplaySimulation` has a non-literal text representation."""
|
||||
result = repr(simulation)
|
||||
|
||||
assert result.startswith('<ReplaySimulation(')
|
||||
assert simulation.city.name in result
|
||||
assert simulation.strategy in result
|
||||
Loading…
Add table
Add a link
Reference in a new issue