From 78dba23d5d21bad3671f0bd3734dbaec00316882 Mon Sep 17 00:00:00 2001 From: Alexander Hess Date: Tue, 29 Dec 2020 14:37:37 +0100 Subject: [PATCH] Re-factor the ORM tests to use randomized fake data - create `*Factory` classes with fakerboy and faker that generate randomized instances of the ORM models - add new pytest marker: "db" are the integration tests involving the database whereas "e2e" will be all other integration tests - streamline the docstrings in the ORM models --- noxfile.py | 7 +- setup.cfg | 16 +- src/urban_meal_delivery/db/addresses.py | 8 +- src/urban_meal_delivery/db/cities.py | 4 +- src/urban_meal_delivery/db/couriers.py | 4 +- src/urban_meal_delivery/db/customers.py | 4 +- src/urban_meal_delivery/db/orders.py | 76 ++-- src/urban_meal_delivery/db/restaurants.py | 9 +- tests/db/conftest.py | 232 ++-------- tests/db/fake_data/__init__.py | 14 + tests/db/fake_data/factories.py | 366 ++++++++++++++++ tests/db/fake_data/fixture_makers.py | 105 +++++ tests/db/fake_data/static_fixtures.py | 58 +++ tests/db/test_addresses.py | 119 +++-- tests/db/test_cities.py | 86 ++-- tests/db/test_couriers.py | 83 ++-- tests/db/test_customer.py | 46 +- tests/db/test_orders.py | 502 +++++++++++++--------- tests/db/test_restaurants.py | 74 ++-- 19 files changed, 1092 insertions(+), 721 deletions(-) create mode 100644 tests/db/fake_data/__init__.py create mode 100644 tests/db/fake_data/factories.py create mode 100644 tests/db/fake_data/fixture_makers.py create mode 100644 tests/db/fake_data/static_fixtures.py diff --git a/noxfile.py b/noxfile.py index b516587..cd65168 100644 --- a/noxfile.py +++ b/noxfile.py @@ -222,6 +222,9 @@ def test(session): session.run('poetry', 'install', '--no-dev', external=True) _install_packages( session, + 'Faker', + 'factory-boy', + 'geopy', 'packaging', 'pytest', 'pytest-cov', @@ -242,8 +245,8 @@ def test(session): '--cov-fail-under=100', '--cov-report=term-missing:skip-covered', '--randomly-seed=4287', - '-k', - 'not e2e', + '-m', + 'not (db or e2e)', PYTEST_LOCATION, ) session.run('pytest', '--version') diff --git a/setup.cfg b/setup.cfg index c3342a7..cfc1969 100644 --- a/setup.cfg +++ b/setup.cfg @@ -89,6 +89,8 @@ extend-ignore = # Comply with black's style. # Source: https://github.com/psf/black/blob/master/docs/compatible_configs.md#flake8 E203, W503, WPS348, + # Allow underscores in numbers. + WPS303, # f-strings are ok. WPS305, # Classes should not have to specify a base class. @@ -139,14 +141,24 @@ per-file-ignores = tests/*.py: # Type annotations are not strictly enforced. ANN0, ANN2, + # The `Meta` class inside the factory_boy models do not need a docstring. + D106, # `assert` statements are ok in the test suite. S101, + # The `random` module is not used for cryptography. + S311, # Shadowing outer scopes occurs naturally with mocks. WPS442, # Modules may have many test cases. WPS202,WPS204,WPS214, + # Do not check for Jones complexity in the test suite. + WPS221, # No overuse of string constants (e.g., '__version__'). WPS226, + # We do not care about the number of "# noqa"s in the test suite. + WPS402, + # Allow closures. + WPS430, # Numbers are normal in test cases as expected results. WPS432, @@ -166,6 +178,7 @@ show-source = true # wemake-python-styleguide's settings # =================================== allowed-domain-names = + data, obj, param, result, @@ -274,4 +287,5 @@ console_output_style = count env = TESTING=true markers = - e2e: integration tests, incl., for example, tests touching the database + db: tests touching the database + e2e: non-db integration tests diff --git a/src/urban_meal_delivery/db/addresses.py b/src/urban_meal_delivery/db/addresses.py index d9bfa48..8a9337b 100644 --- a/src/urban_meal_delivery/db/addresses.py +++ b/src/urban_meal_delivery/db/addresses.py @@ -1,4 +1,4 @@ -"""Provide the ORM's Address model.""" +"""Provide the ORM's `Address` model.""" import sqlalchemy as sa from sqlalchemy import orm @@ -9,7 +9,7 @@ from urban_meal_delivery.db import meta class Address(meta.Base): - """An Address of a Customer or a Restaurant on the UDP.""" + """An address of a `Customer` or a `Restaurant` on the UDP.""" __tablename__ = 'addresses' @@ -72,11 +72,11 @@ class Address(meta.Base): @hybrid.hybrid_property def is_primary(self) -> bool: - """If an Address object is the earliest one entered at its location. + """If an `Address` object is the earliest one entered at its location. Street addresses may have been entered several times with different versions/spellings of the street name and/or different floors. - `is_primary` indicates the first in a group of addresses. + `.is_primary` indicates the first in a group of `Address` objects. """ return self.id == self._primary_id diff --git a/src/urban_meal_delivery/db/cities.py b/src/urban_meal_delivery/db/cities.py index 00305b2..2a36ced 100644 --- a/src/urban_meal_delivery/db/cities.py +++ b/src/urban_meal_delivery/db/cities.py @@ -1,4 +1,4 @@ -"""Provide the ORM's City model.""" +"""Provide the ORM's `City` model.""" from typing import Dict @@ -10,7 +10,7 @@ from urban_meal_delivery.db import meta class City(meta.Base): - """A City where the UDP operates in.""" + """A city where the UDP operates in.""" __tablename__ = 'cities' diff --git a/src/urban_meal_delivery/db/couriers.py b/src/urban_meal_delivery/db/couriers.py index 9087982..a4c85ca 100644 --- a/src/urban_meal_delivery/db/couriers.py +++ b/src/urban_meal_delivery/db/couriers.py @@ -1,4 +1,4 @@ -"""Provide the ORM's Courier model.""" +"""Provide the ORM's `Courier` model.""" import sqlalchemy as sa from sqlalchemy import orm @@ -8,7 +8,7 @@ from urban_meal_delivery.db import meta class Courier(meta.Base): - """A Courier working for the UDP.""" + """A courier working for the UDP.""" __tablename__ = 'couriers' diff --git a/src/urban_meal_delivery/db/customers.py b/src/urban_meal_delivery/db/customers.py index 451ec92..2a96d9a 100644 --- a/src/urban_meal_delivery/db/customers.py +++ b/src/urban_meal_delivery/db/customers.py @@ -1,4 +1,4 @@ -"""Provide the ORM's Customer model.""" +"""Provide the ORM's `Customer` model.""" import sqlalchemy as sa from sqlalchemy import orm @@ -7,7 +7,7 @@ from urban_meal_delivery.db import meta class Customer(meta.Base): - """A Customer of the UDP.""" + """A customer of the UDP.""" __tablename__ = 'customers' diff --git a/src/urban_meal_delivery/db/orders.py b/src/urban_meal_delivery/db/orders.py index 5bb617c..d3adcdf 100644 --- a/src/urban_meal_delivery/db/orders.py +++ b/src/urban_meal_delivery/db/orders.py @@ -1,4 +1,4 @@ -"""Provide the ORM's Order model.""" +"""Provide the ORM's `Order` model.""" import datetime @@ -10,7 +10,7 @@ from urban_meal_delivery.db import meta class Order(meta.Base): # noqa:WPS214 - """An Order by a Customer of the UDP.""" + """An order by a `Customer` of the UDP.""" __tablename__ = 'orders' @@ -325,12 +325,12 @@ class Order(meta.Base): # noqa:WPS214 @property def scheduled(self) -> bool: - """Inverse of Order.ad_hoc.""" + """Inverse of `.ad_hoc`.""" return not self.ad_hoc @property def completed(self) -> bool: - """Inverse of Order.cancelled.""" + """Inverse of `.cancelled`.""" return not self.cancelled @property @@ -353,9 +353,9 @@ class Order(meta.Base): # noqa:WPS214 @property def time_to_accept(self) -> datetime.timedelta: - """Time until a courier accepted an order. + """Time until the `.courier` accepted the order. - This adds the time it took the UDP to notify a courier. + This measures the time it took the UDP to notify the `.courier` after dispatch. """ if not self.dispatch_at: raise RuntimeError('dispatch_at is not set') @@ -365,9 +365,9 @@ class Order(meta.Base): # noqa:WPS214 @property def time_to_react(self) -> datetime.timedelta: - """Time a courier took to accept an order. + """Time the `.courier` took to accept an order. - This time is a subset of Order.time_to_accept. + A subset of `.time_to_accept`. """ if not self.courier_notified_at: raise RuntimeError('courier_notified_at is not set') @@ -377,7 +377,7 @@ class Order(meta.Base): # noqa:WPS214 @property def time_to_pickup(self) -> datetime.timedelta: - """Time from a courier's acceptance to arrival at the pickup location.""" + """Time from the `.courier`'s acceptance to arrival at `.pickup_address`.""" if not self.courier_accepted_at: raise RuntimeError('courier_accepted_at is not set') if not self.reached_pickup_at: @@ -386,7 +386,7 @@ class Order(meta.Base): # noqa:WPS214 @property def time_at_pickup(self) -> datetime.timedelta: - """Time a courier stayed at the pickup location.""" + """Time the `.courier` stayed at the `.pickup_address`.""" if not self.reached_pickup_at: raise RuntimeError('reached_pickup_at is not set') if not self.pickup_at: @@ -405,13 +405,13 @@ class Order(meta.Base): # noqa:WPS214 @property def courier_early(self) -> datetime.timedelta: - """Time by which a courier is early for pickup. + """Time by which the `.courier` is early for pickup. - Measured relative to Order.scheduled_pickup_at. + Measured relative to `.scheduled_pickup_at`. - 0 if the courier is on time or late. + `datetime.timedelta(seconds=0)` if the `.courier` is on time or late. - Goes together with Order.courier_late. + Goes together with `.courier_late`. """ return max( datetime.timedelta(), self.scheduled_pickup_at - self.reached_pickup_at, @@ -419,13 +419,13 @@ class Order(meta.Base): # noqa:WPS214 @property def courier_late(self) -> datetime.timedelta: - """Time by which a courier is late for pickup. + """Time by which the `.courier` is late for pickup. - Measured relative to Order.scheduled_pickup_at. + Measured relative to `.scheduled_pickup_at`. - 0 if the courier is on time or early. + `datetime.timedelta(seconds=0)` if the `.courier` is on time or early. - Goes together with Order.courier_early. + Goes together with `.courier_early`. """ return max( datetime.timedelta(), self.reached_pickup_at - self.scheduled_pickup_at, @@ -433,31 +433,31 @@ class Order(meta.Base): # noqa:WPS214 @property def restaurant_early(self) -> datetime.timedelta: - """Time by which a restaurant is early for pickup. + """Time by which the `.restaurant` is early for pickup. - Measured relative to Order.scheduled_pickup_at. + Measured relative to `.scheduled_pickup_at`. - 0 if the restaurant is on time or late. + `datetime.timedelta(seconds=0)` if the `.restaurant` is on time or late. - Goes together with Order.restaurant_late. + Goes together with `.restaurant_late`. """ return max(datetime.timedelta(), self.scheduled_pickup_at - self.pickup_at) @property def restaurant_late(self) -> datetime.timedelta: - """Time by which a restaurant is late for pickup. + """Time by which the `.restaurant` is late for pickup. - Measured relative to Order.scheduled_pickup_at. + Measured relative to `.scheduled_pickup_at`. - 0 if the restaurant is on time or early. + `datetime.timedelta(seconds=0)` if the `.restaurant` is on time or early. - Goes together with Order.restaurant_early. + Goes together with `.restaurant_early`. """ return max(datetime.timedelta(), self.pickup_at - self.scheduled_pickup_at) @property def time_to_delivery(self) -> datetime.timedelta: - """Time a courier took from pickup to delivery location.""" + """Time the `.courier` took from `.pickup_address` to `.delivery_address`.""" if not self.pickup_at: raise RuntimeError('pickup_at is not set') if not self.reached_delivery_at: @@ -466,7 +466,7 @@ class Order(meta.Base): # noqa:WPS214 @property def time_at_delivery(self) -> datetime.timedelta: - """Time a courier stayed at the delivery location.""" + """Time the `.courier` stayed at the `.delivery_address`.""" if not self.reached_delivery_at: raise RuntimeError('reached_delivery_at is not set') if not self.delivery_at: @@ -475,20 +475,20 @@ class Order(meta.Base): # noqa:WPS214 @property def courier_waited_at_delivery(self) -> datetime.timedelta: - """Time a courier waited at the delivery location.""" + """Time the `.courier` waited at the `.delivery_address`.""" if self._courier_waited_at_delivery: return self.time_at_delivery return datetime.timedelta() @property def delivery_early(self) -> datetime.timedelta: - """Time by which a scheduled order was early. + """Time by which a `.scheduled` order was early. - Measured relative to Order.scheduled_delivery_at. + Measured relative to `.scheduled_delivery_at`. - 0 if the delivery is on time or late. + `datetime.timedelta(seconds=0)` if the delivery is on time or late. - Goes together with Order.delivery_late. + Goes together with `.delivery_late`. """ if not self.scheduled: raise AttributeError('Makes sense only for scheduled orders') @@ -496,13 +496,13 @@ class Order(meta.Base): # noqa:WPS214 @property def delivery_late(self) -> datetime.timedelta: - """Time by which a scheduled order was late. + """Time by which a `.scheduled` order was late. - Measured relative to Order.scheduled_delivery_at. + Measured relative to `.scheduled_delivery_at`. - 0 if the delivery is on time or early. + `datetime.timedelta(seconds=0)` if the delivery is on time or early. - Goes together with Order.delivery_early. + Goes together with `.delivery_early`. """ if not self.scheduled: raise AttributeError('Makes sense only for scheduled orders') @@ -510,7 +510,7 @@ class Order(meta.Base): # noqa:WPS214 @property def total_time(self) -> datetime.timedelta: - """Time from order placement to delivery for an ad-hoc order.""" + """Time from order placement to delivery for an `.ad_hoc` order.""" if self.scheduled: raise AttributeError('Scheduled orders have no total_time') if self.cancelled: diff --git a/src/urban_meal_delivery/db/restaurants.py b/src/urban_meal_delivery/db/restaurants.py index f31d7af..1319b56 100644 --- a/src/urban_meal_delivery/db/restaurants.py +++ b/src/urban_meal_delivery/db/restaurants.py @@ -1,4 +1,4 @@ -"""Provide the ORM's Restaurant model.""" +"""Provide the ORM's `Restaurant` model.""" import sqlalchemy as sa from sqlalchemy import orm @@ -7,7 +7,12 @@ from urban_meal_delivery.db import meta class Restaurant(meta.Base): - """A Restaurant selling meals on the UDP.""" + """A restaurant selling meals on the UDP. + + In the historic dataset, a `Restaurant` may have changed its `Address` + throughout its life time. The ORM model only stores the current one, + which in most cases is also the only one. + """ __tablename__ = 'restaurants' diff --git a/tests/db/conftest.py b/tests/db/conftest.py index 2508161..fcacfe7 100644 --- a/tests/db/conftest.py +++ b/tests/db/conftest.py @@ -1,23 +1,23 @@ """Utils for testing the ORM layer.""" -import datetime - import pytest from alembic import command as migrations_cmd from alembic import config as migrations_config +from sqlalchemy import orm +from tests.db import fake_data from urban_meal_delivery import config from urban_meal_delivery import db @pytest.fixture(scope='session', params=['all_at_once', 'sequentially']) -def db_engine(request): +def db_connection(request): """Create all tables given the ORM models. The tables are put into a distinct PostgreSQL schema that is removed after all tests are over. - The engine used to do that is yielded. + The database connection used to do that is yielded. There are two modes for this fixture: @@ -27,38 +27,40 @@ def db_engine(request): This ensures that Alembic's migration files are consistent. """ engine = db.make_engine() + connection = engine.connect() if request.param == 'all_at_once': engine.execute(f'CREATE SCHEMA {config.CLEAN_SCHEMA};') - db.Base.metadata.create_all(engine) + db.Base.metadata.create_all(connection) else: cfg = migrations_config.Config('alembic.ini') migrations_cmd.upgrade(cfg, 'head') try: - yield engine + yield connection finally: - engine.execute(f'DROP SCHEMA {config.CLEAN_SCHEMA} CASCADE;') + connection.execute(f'DROP SCHEMA {config.CLEAN_SCHEMA} CASCADE;') if request.param == 'sequentially': tmp_alembic_version = f'{config.ALEMBIC_TABLE}_{config.CLEAN_SCHEMA}' - engine.execute( + connection.execute( f'DROP TABLE {config.ALEMBIC_TABLE_SCHEMA}.{tmp_alembic_version};', ) + connection.close() + @pytest.fixture -def db_session(db_engine): +def db_session(db_connection): """A SQLAlchemy session that rolls back everything after a test case.""" - connection = db_engine.connect() # Begin the outer most transaction # that is rolled back at the end of the test. - transaction = connection.begin() + transaction = db_connection.begin() # Create a session bound on the same connection as the transaction. # Using any other session would not work. - Session = db.make_session_factory() # noqa:N806 - session = Session(bind=connection) + session_factory = orm.sessionmaker() + session = session_factory(bind=db_connection) try: yield session @@ -66,198 +68,20 @@ def db_session(db_engine): finally: session.close() transaction.rollback() - connection.close() -@pytest.fixture -def address_data(): - """The data for an Address object in Paris.""" - return { - 'id': 1, - '_primary_id': 1, # => "itself" - 'created_at': datetime.datetime(2020, 1, 2, 3, 4, 5), - 'place_id': 'ChIJxSr71vZt5kcRoFHY4caCCxw', - 'latitude': 48.85313, - 'longitude': 2.37461, - '_city_id': 1, - 'city_name': 'St. German', - 'zip_code': '75011', - 'street': '42 Rue De Charonne', - 'floor': None, - } +# Import the fixtures from the `fake_data` sub-package. +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_restaurant = fake_data.make_restaurant -@pytest.fixture -def address(address_data, city): - """An Address object.""" - address = db.Address(**address_data) - address.city = city - return address - - -@pytest.fixture -def address2_data(): - """The data for an Address object in Paris.""" - return { - 'id': 2, - '_primary_id': 2, # => "itself" - 'created_at': datetime.datetime(2020, 1, 2, 4, 5, 6), - 'place_id': 'ChIJs-9a6QZy5kcRY8Wwk9Ywzl8', - 'latitude': 48.852196, - 'longitude': 2.373937, - '_city_id': 1, - 'city_name': 'Paris', - 'zip_code': '75011', - 'street': 'Rue De Charonne 3', - 'floor': 2, - } - - -@pytest.fixture -def address2(address2_data, city): - """An Address object.""" - address2 = db.Address(**address2_data) - address2.city = city - return address2 - - -@pytest.fixture -def city_data(): - """The data for the City object modeling Paris.""" - return { - 'id': 1, - 'name': 'Paris', - 'kml': " ...", - '_center_latitude': 48.856614, - '_center_longitude': 2.3522219, - '_northeast_latitude': 48.9021449, - '_northeast_longitude': 2.4699208, - '_southwest_latitude': 48.815573, - '_southwest_longitude': 2.225193, - 'initial_zoom': 12, - } - - -@pytest.fixture -def city(city_data): - """A City object.""" - return db.City(**city_data) - - -@pytest.fixture -def courier_data(): - """The data for a Courier object.""" - return { - 'id': 1, - 'created_at': datetime.datetime(2020, 1, 2, 3, 4, 5), - 'vehicle': 'bicycle', - 'historic_speed': 7.89, - 'capacity': 100, - 'pay_per_hour': 750, - 'pay_per_order': 200, - } - - -@pytest.fixture -def courier(courier_data): - """A Courier object.""" - return db.Courier(**courier_data) - - -@pytest.fixture -def customer_data(): - """The data for the Customer object.""" - return {'id': 1} - - -@pytest.fixture -def customer(customer_data): - """A Customer object.""" - return db.Customer(**customer_data) - - -@pytest.fixture -def order_data(): - """The data for an ad-hoc Order object.""" - return { - 'id': 1, - '_delivery_id': 1, - '_customer_id': 1, - 'placed_at': datetime.datetime(2020, 1, 2, 11, 55, 11), - 'ad_hoc': True, - 'scheduled_delivery_at': None, - 'scheduled_delivery_at_corrected': None, - 'first_estimated_delivery_at': datetime.datetime(2020, 1, 2, 12, 35, 0), - 'cancelled': False, - 'cancelled_at': None, - 'cancelled_at_corrected': None, - 'sub_total': 2000, - 'delivery_fee': 250, - 'total': 2250, - '_restaurant_id': 1, - 'restaurant_notified_at': datetime.datetime(2020, 1, 2, 12, 5, 5), - 'restaurant_notified_at_corrected': False, - 'restaurant_confirmed_at': datetime.datetime(2020, 1, 2, 12, 5, 25), - 'restaurant_confirmed_at_corrected': False, - 'estimated_prep_duration': 900, - 'estimated_prep_duration_corrected': False, - 'estimated_prep_buffer': 480, - '_courier_id': 1, - 'dispatch_at': datetime.datetime(2020, 1, 2, 12, 5, 1), - 'dispatch_at_corrected': False, - 'courier_notified_at': datetime.datetime(2020, 1, 2, 12, 6, 2), - 'courier_notified_at_corrected': False, - 'courier_accepted_at': datetime.datetime(2020, 1, 2, 12, 6, 17), - 'courier_accepted_at_corrected': False, - 'utilization': 50, - '_pickup_address_id': 1, - 'reached_pickup_at': datetime.datetime(2020, 1, 2, 12, 16, 21), - 'pickup_at': datetime.datetime(2020, 1, 2, 12, 18, 1), - 'pickup_at_corrected': False, - 'pickup_not_confirmed': False, - 'left_pickup_at': datetime.datetime(2020, 1, 2, 12, 19, 45), - 'left_pickup_at_corrected': False, - '_delivery_address_id': 2, - 'reached_delivery_at': datetime.datetime(2020, 1, 2, 12, 27, 33), - 'delivery_at': datetime.datetime(2020, 1, 2, 12, 29, 55), - 'delivery_at_corrected': False, - 'delivery_not_confirmed': False, - '_courier_waited_at_delivery': False, - 'logged_delivery_distance': 500, - 'logged_avg_speed': 7.89, - 'logged_avg_speed_distance': 490, - } - - -@pytest.fixture -def order( # noqa:WPS211 pylint:disable=too-many-arguments - order_data, customer, restaurant, courier, address, address2, -): - """An Order object.""" - order = db.Order(**order_data) - order.customer = customer - order.restaurant = restaurant - order.courier = courier - order.pickup_address = address - order.delivery_address = address2 - return order - - -@pytest.fixture -def restaurant_data(): - """The data for the Restaurant object.""" - return { - 'id': 1, - 'created_at': datetime.datetime(2020, 1, 2, 3, 4, 5), - 'name': 'Vevay', - '_address_id': 1, - 'estimated_prep_duration': 1000, - } - - -@pytest.fixture -def restaurant(restaurant_data, address): - """A Restaurant object.""" - restaurant = db.Restaurant(**restaurant_data) - restaurant.address = address - return restaurant +address = fake_data.address +city = fake_data.city +city_data = fake_data.city_data +courier = fake_data.courier +customer = fake_data.customer +order = fake_data.order +restaurant = fake_data.restaurant diff --git a/tests/db/fake_data/__init__.py b/tests/db/fake_data/__init__.py new file mode 100644 index 0000000..f6b879c --- /dev/null +++ b/tests/db/fake_data/__init__.py @@ -0,0 +1,14 @@ +"""Fixtures for testing the ORM layer with fake data.""" + +from tests.db.fake_data.fixture_makers import make_address # noqa:F401 +from tests.db.fake_data.fixture_makers import make_courier # noqa:F401 +from tests.db.fake_data.fixture_makers import make_customer # noqa:F401 +from tests.db.fake_data.fixture_makers import make_order # noqa:F401 +from tests.db.fake_data.fixture_makers import make_restaurant # noqa:F401 +from tests.db.fake_data.static_fixtures import address # noqa:F401 +from tests.db.fake_data.static_fixtures import city # noqa:F401 +from tests.db.fake_data.static_fixtures import city_data # noqa:F401 +from tests.db.fake_data.static_fixtures import courier # noqa:F401 +from tests.db.fake_data.static_fixtures import customer # noqa:F401 +from tests.db.fake_data.static_fixtures import order # noqa:F401 +from tests.db.fake_data.static_fixtures import restaurant # noqa:F401 diff --git a/tests/db/fake_data/factories.py b/tests/db/fake_data/factories.py new file mode 100644 index 0000000..0758667 --- /dev/null +++ b/tests/db/fake_data/factories.py @@ -0,0 +1,366 @@ +"""Factories to create instances for the SQLAlchemy models.""" + +import datetime as dt +import random +import string + +import factory +import faker +from factory import alchemy +from geopy import distance + +from urban_meal_delivery import db + + +def _random_timespan( # noqa:WPS211 + *, + min_hours=0, + min_minutes=0, + min_seconds=0, + max_hours=0, + max_minutes=0, + max_seconds=0, +): + """A randomized `timedelta` object between the specified arguments.""" + total_min_seconds = min_hours * 3600 + min_minutes * 60 + min_seconds + total_max_seconds = max_hours * 3600 + max_minutes * 60 + max_seconds + return dt.timedelta(seconds=random.randint(total_min_seconds, total_max_seconds)) + + +# The test day. +_YEAR, _MONTH, _DAY = 2020, 1, 1 + + +def _early_in_the_morning(): + """A randomized `datetime` object early in the morning.""" + return dt.datetime(_YEAR, _MONTH, _DAY, 3, 0) + _random_timespan(max_hours=2) + + +class AddressFactory(alchemy.SQLAlchemyModelFactory): + """Create instances of the `db.Address` model.""" + + class Meta: + model = db.Address + sqlalchemy_get_or_create = ('id',) + + id = factory.Sequence(lambda num: num) # noqa:WPS125 + created_at = factory.LazyFunction(_early_in_the_morning) + + # When testing, all addresses are considered primary ones. + # As non-primary addresses have no different behavior and + # the property is only kept from the original dataset for + # completeness sake, that is ok to do. + _primary_id = factory.LazyAttribute(lambda obj: obj.id) + + # Mimic a Google Maps Place ID with just random characters. + place_id = factory.LazyFunction( + lambda: ''.join(random.choice(string.ascii_lowercase) for _ in range(20)), + ) + + # Place the addresses somewhere in downtown Paris. + latitude = factory.Faker('coordinate', center=48.855, radius=0.01) + longitude = factory.Faker('coordinate', center=2.34, radius=0.03) + # city -> set by the `make_address` fixture as there is only one `city` + city_name = 'Paris' + zip_code = factory.LazyFunction(lambda: random.randint(75001, 75020)) + street = factory.Faker('street_address', locale='fr_FR') + + +class CourierFactory(alchemy.SQLAlchemyModelFactory): + """Create instances of the `db.Courier` model.""" + + class Meta: + model = db.Courier + sqlalchemy_get_or_create = ('id',) + + id = factory.Sequence(lambda num: num) # noqa:WPS125 + created_at = factory.LazyFunction(_early_in_the_morning) + vehicle = 'bicycle' + historic_speed = 7.89 + capacity = 100 + pay_per_hour = 750 + pay_per_order = 200 + + +class CustomerFactory(alchemy.SQLAlchemyModelFactory): + """Create instances of the `db.Customer` model.""" + + class Meta: + model = db.Customer + sqlalchemy_get_or_create = ('id',) + + id = factory.Sequence(lambda num: num) # noqa:WPS125 + + +_restaurant_names = faker.Faker() + + +class RestaurantFactory(alchemy.SQLAlchemyModelFactory): + """Create instances of the `db.Restaurant` model.""" + + class Meta: + model = db.Restaurant + sqlalchemy_get_or_create = ('id',) + + id = factory.Sequence(lambda num: num) # noqa:WPS125 + created_at = factory.LazyFunction(_early_in_the_morning) + name = factory.LazyFunction( + lambda: f"{_restaurant_names.first_name()}'s Restaurant", + ) + # address -> set by the `make_restaurant` fixture as there is only one `city` + estimated_prep_duration = 1000 + + +class AdHocOrderFactory(alchemy.SQLAlchemyModelFactory): + """Create instances of the `db.Order` model. + + This factory creates ad-hoc `Order`s while the `ScheduledOrderFactory` + below creates pre-orders. They are split into two classes mainly + because the logic regarding how the timestamps are calculated from + each other differs. + + See the docstring in the contained `Params` class for + flags to adapt how the `Order` is created. + """ + + # pylint:disable=too-many-instance-attributes + + class Meta: + model = db.Order + sqlalchemy_get_or_create = ('id',) + + class Params: + """Define flags that overwrite some attributes. + + The `factory.Trait` objects in this class are executed after all + the normal attributes in the `OrderFactory` classes were evaluated. + + Flags: + cancel_before_pickup + cancel_after_pickup + """ + + # Timestamps after `cancelled_at` are discarded + # by the `post_generation` hook at the end of the `OrderFactory`. + cancel_ = factory.Trait( # noqa:WPS120 -> leading underscore does not work + cancelled=True, cancelled_at_corrected=False, + ) + cancel_before_pickup = factory.Trait( + cancel_=True, + cancelled_at=factory.LazyAttribute( + lambda obj: obj.dispatch_at + + _random_timespan( + max_seconds=(obj.pickup_at - obj.dispatch_at).total_seconds(), + ), + ), + ) + cancel_after_pickup = factory.Trait( + cancel_=True, + cancelled_at=factory.LazyAttribute( + lambda obj: obj.pickup_at + + _random_timespan( + max_seconds=(obj.delivery_at - obj.pickup_at).total_seconds(), + ), + ), + ) + + # Generic attributes + id = factory.Sequence(lambda num: num) # noqa:WPS125 + # customer -> set by the `make_order` fixture for better control + + # Attributes regarding the specialization of an `Order`: ad-hoc or scheduled. + # Ad-hoc `Order`s are placed between 11.45 and 14.15. + placed_at = factory.LazyFunction( + lambda: dt.datetime(_YEAR, _MONTH, _DAY, 11, 45) + + _random_timespan(max_hours=2, max_minutes=30), + ) + ad_hoc = True + scheduled_delivery_at = None + scheduled_delivery_at_corrected = None + # Without statistical info, we assume an ad-hoc `Order` delivered after 45 minutes. + first_estimated_delivery_at = factory.LazyAttribute( + lambda obj: obj.placed_at + dt.timedelta(minutes=45), + ) + + # Attributes regarding the cancellation of an `Order`. + # May be overwritten with the `cancel_before_pickup` or `cancel_after_pickup` flags. + cancelled = False + cancelled_at = None + cancelled_at_corrected = None + + # Price-related attributes -> sample realistic prices + sub_total = factory.LazyFunction(lambda: 100 * random.randint(15, 25)) + delivery_fee = 250 + total = factory.LazyAttribute(lambda obj: obj.sub_total + obj.delivery_fee) + + # Restaurant-related attributes + # restaurant -> set by the `make_order` fixture for better control + restaurant_notified_at = factory.LazyAttribute( + lambda obj: obj.placed_at + _random_timespan(min_seconds=30, max_seconds=90), + ) + restaurant_notified_at_corrected = False + restaurant_confirmed_at = factory.LazyAttribute( + lambda obj: obj.restaurant_notified_at + + _random_timespan(min_seconds=30, max_seconds=150), + ) + restaurant_confirmed_at_corrected = False + # Use the database defaults of the historic data. + estimated_prep_duration = 900 + estimated_prep_duration_corrected = False + estimated_prep_buffer = 480 + + # Dispatch-related columns + # courier -> set by the `make_order` fixture for better control + dispatch_at = factory.LazyAttribute( + lambda obj: obj.placed_at + _random_timespan(min_seconds=600, max_seconds=1080), + ) + dispatch_at_corrected = False + courier_notified_at = factory.LazyAttribute( + lambda obj: obj.dispatch_at + + _random_timespan(min_seconds=100, max_seconds=140), + ) + courier_notified_at_corrected = False + courier_accepted_at = factory.LazyAttribute( + lambda obj: obj.courier_notified_at + + _random_timespan(min_seconds=15, max_seconds=45), + ) + courier_accepted_at_corrected = False + # Sample a realistic utilization. + utilization = factory.LazyFunction(lambda: random.choice([50, 60, 70, 80, 90, 100])) + + # Pickup-related attributes + # pickup_address -> aligned with `restaurant.address` by the `make_order` fixture + reached_pickup_at = factory.LazyAttribute( + lambda obj: obj.courier_accepted_at + + _random_timespan(min_seconds=300, max_seconds=600), + ) + pickup_at = factory.LazyAttribute( + lambda obj: obj.reached_pickup_at + + _random_timespan(min_seconds=120, max_seconds=600), + ) + pickup_at_corrected = False + pickup_not_confirmed = False + left_pickup_at = factory.LazyAttribute( + lambda obj: obj.pickup_at + _random_timespan(min_seconds=60, max_seconds=180), + ) + left_pickup_at_corrected = False + + # Delivery-related attributes + # delivery_address -> set by the `make_order` fixture as there is only one `city` + reached_delivery_at = factory.LazyAttribute( + lambda obj: obj.left_pickup_at + + _random_timespan(min_seconds=240, max_seconds=480), + ) + delivery_at = factory.LazyAttribute( + lambda obj: obj.reached_delivery_at + + _random_timespan(min_seconds=240, max_seconds=660), + ) + delivery_at_corrected = False + delivery_not_confirmed = False + _courier_waited_at_delivery = factory.LazyAttribute( + lambda obj: False if obj.delivery_at else None, + ) + + # Statistical attributes -> calculate realistic stats + logged_delivery_distance = factory.LazyAttribute( + lambda obj: distance.great_circle( # noqa:WPS317 + (obj.pickup_address.latitude, obj.pickup_address.longitude), + (obj.delivery_address.latitude, obj.delivery_address.longitude), + ).meters, + ) + logged_avg_speed = factory.LazyAttribute( # noqa:ECE001 + lambda obj: round( + ( + obj.logged_avg_speed_distance + / (obj.delivery_at - obj.pickup_at).total_seconds() + ), + 2, + ), + ) + logged_avg_speed_distance = factory.LazyAttribute( + lambda obj: 0.95 * obj.logged_delivery_distance, + ) + + @factory.post_generation + def post( # noqa:C901,WPS23 pylint:disable=unused-argument + obj, create, extracted, **kwargs, # noqa:B902,N805 + ): + """Discard timestamps that occur after cancellation.""" + if obj.cancelled: + if obj.cancelled_at <= obj.restaurant_notified_at: + obj.restaurant_notified_at = None + obj.restaurant_notified_at_corrected = None + if obj.cancelled_at <= obj.restaurant_confirmed_at: + obj.restaurant_confirmed_at = None + obj.restaurant_confirmed_at_corrected = None + if obj.cancelled_at <= obj.dispatch_at: + obj.dispatch_at = None + obj.dispatch_at_corrected = None + if obj.cancelled_at <= obj.courier_notified_at: + obj.courier_notified_at = None + obj.courier_notified_at_corrected = None + if obj.cancelled_at <= obj.courier_accepted_at: + obj.courier_accepted_at = None + obj.courier_accepted_at_corrected = 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 + obj.pickup_at_corrected = None + obj.pickup_not_confirmed = None + if obj.cancelled_at <= obj.left_pickup_at: + obj.left_pickup_at = None + obj.left_pickup_at_corrected = 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 + obj.delivery_at_corrected = None + obj.delivery_not_confirmed = None + obj._courier_waited_at_delivery = None # noqa:WPS437 + + +class ScheduledOrderFactory(AdHocOrderFactory): + """Create instances of the `db.Order` model. + + This class takes care of the various timestamps for pre-orders. + + Pre-orders are placed long before the test day's lunch time starts. + All timestamps are relative to either `.dispatch_at` or `.restaurant_notified_at` + and calculated backwards from `.scheduled_delivery_at`. + """ + + # Attributes regarding the specialization of an `Order`: ad-hoc or scheduled. + placed_at = factory.LazyFunction(_early_in_the_morning) + ad_hoc = False + # Discrete `datetime` objects in the "core" lunch time are enough. + scheduled_delivery_at = factory.LazyFunction( + lambda: random.choice( + [ + dt.datetime(_YEAR, _MONTH, _DAY, 12, 0), + dt.datetime(_YEAR, _MONTH, _DAY, 12, 15), + dt.datetime(_YEAR, _MONTH, _DAY, 12, 30), + dt.datetime(_YEAR, _MONTH, _DAY, 12, 45), + dt.datetime(_YEAR, _MONTH, _DAY, 13, 0), + dt.datetime(_YEAR, _MONTH, _DAY, 13, 15), + dt.datetime(_YEAR, _MONTH, _DAY, 13, 30), + ], + ), + ) + scheduled_delivery_at_corrected = False + # Assume the `Order` is on time. + first_estimated_delivery_at = factory.LazyAttribute( + lambda obj: obj.scheduled_delivery_at, + ) + + # Restaurant-related attributes + restaurant_notified_at = factory.LazyAttribute( + lambda obj: obj.scheduled_delivery_at + - _random_timespan(min_minutes=45, max_minutes=50), + ) + + # Dispatch-related attributes + dispatch_at = factory.LazyAttribute( + lambda obj: obj.scheduled_delivery_at + - _random_timespan(min_minutes=40, max_minutes=45), + ) diff --git a/tests/db/fake_data/fixture_makers.py b/tests/db/fake_data/fixture_makers.py new file mode 100644 index 0000000..9a5419b --- /dev/null +++ b/tests/db/fake_data/fixture_makers.py @@ -0,0 +1,105 @@ +"""Fixture factories for testing the ORM layer with fake data.""" + +import pytest + +from tests.db.fake_data import factories + + +@pytest.fixture +def make_address(city): + """Replaces `AddressFactory.build()`: Create an `Address` in the `city`.""" + # Reset the identifiers before every test. + factories.AddressFactory.reset_sequence(1) + + def func(**kwargs): + """Create an `Address` object in the `city`.""" + return factories.AddressFactory.build(city=city, **kwargs) + + return func + + +@pytest.fixture +def make_courier(): + """Replaces `CourierFactory.build()`: Create a `Courier`.""" + # Reset the identifiers before every test. + factories.CourierFactory.reset_sequence(1) + + def func(**kwargs): + """Create a new `Courier` object.""" + return factories.CourierFactory.build(**kwargs) + + return func + + +@pytest.fixture +def make_customer(): + """Replaces `CustomerFactory.build()`: Create a `Customer`.""" + # Reset the identifiers before every test. + factories.CustomerFactory.reset_sequence(1) + + def func(**kwargs): + """Create a new `Customer` object.""" + return factories.CustomerFactory.build(**kwargs) + + return func + + +@pytest.fixture +def make_restaurant(make_address): + """Replaces `RestaurantFactory.build()`: Create a `Restaurant`.""" + # Reset the identifiers before every test. + factories.RestaurantFactory.reset_sequence(1) + + def func(address=None, **kwargs): + """Create a new `Restaurant` object. + + If no `address` is provided, a new `Address` is created. + """ + if address is None: + address = make_address() + + return factories.RestaurantFactory.build(address=address, **kwargs) + + return func + + +@pytest.fixture +def make_order(make_address, make_courier, make_customer, make_restaurant): + """Replaces `OrderFactory.build()`: Create a `Order`.""" + # Reset the identifiers before every test. + factories.AdHocOrderFactory.reset_sequence(1) + + def func(scheduled=False, restaurant=None, courier=None, **kwargs): + """Create a new `Order` object. + + Each `Order` is made by a new `Customer` with a unique `Address` for delivery. + + Args: + scheduled: if an `Order` is a pre-order + restaurant: who receives the `Order`; defaults to a new `Restaurant` + courier: who delivered the `Order`; defaults to a new `Courier` + kwargs: additional keyword arguments forwarded to the `OrderFactory` + + Returns: + order + """ + if scheduled: + factory_cls = factories.ScheduledOrderFactory + else: + factory_cls = factories.AdHocOrderFactory + + if restaurant is None: + restaurant = make_restaurant() + if courier is None: + courier = make_courier() + + return factory_cls.build( + customer=make_customer(), # assume a unique `Customer` per order + restaurant=restaurant, + courier=courier, + pickup_address=restaurant.address, # no `Address` history + delivery_address=make_address(), # unique `Customer` => new `Address` + **kwargs, + ) + + return func diff --git a/tests/db/fake_data/static_fixtures.py b/tests/db/fake_data/static_fixtures.py new file mode 100644 index 0000000..df7d5b7 --- /dev/null +++ b/tests/db/fake_data/static_fixtures.py @@ -0,0 +1,58 @@ +"""Fake data for testing the ORM layer.""" + +import pytest + +from urban_meal_delivery import db + + +@pytest.fixture +def city_data(): + """The data for the one and only `City` object as a `dict`.""" + return { + 'id': 1, + 'name': 'Paris', + 'kml': " ...", + '_center_latitude': 48.856614, + '_center_longitude': 2.3522219, + '_northeast_latitude': 48.9021449, + '_northeast_longitude': 2.4699208, + '_southwest_latitude': 48.815573, + '_southwest_longitude': 2.225193, + 'initial_zoom': 12, + } + + +@pytest.fixture +def city(city_data): + """The one and only `City` object.""" + return db.City(**city_data) + + +@pytest.fixture +def address(make_address): + """An `Address` object in the `city`.""" + return make_address() + + +@pytest.fixture +def courier(make_courier): + """A `Courier` object.""" + return make_courier() + + +@pytest.fixture +def customer(make_customer): + """A `Customer` object.""" + return make_customer() + + +@pytest.fixture +def restaurant(address, make_restaurant): + """A `Restaurant` object located at the `address`.""" + return make_restaurant(address=address) + + +@pytest.fixture +def order(make_order, restaurant): + """An `Order` object for the `restaurant`.""" + return make_order(restaurant=restaurant) diff --git a/tests/db/test_addresses.py b/tests/db/test_addresses.py index ffb5618..4086f9c 100644 --- a/tests/db/test_addresses.py +++ b/tests/db/test_addresses.py @@ -1,140 +1,123 @@ -"""Test the ORM's Address model.""" +"""Test the ORM's `Address` model.""" +# pylint:disable=no-self-use,protected-access import pytest +import sqlalchemy as sqla from sqlalchemy import exc as sa_exc -from sqlalchemy.orm import exc as orm_exc from urban_meal_delivery import db class TestSpecialMethods: - """Test special methods in Address.""" + """Test special methods in `Address`.""" - # pylint:disable=no-self-use - - def test_create_address(self, address_data): - """Test instantiation of a new Address object.""" - result = db.Address(**address_data) - - assert result is not None - - def test_text_representation(self, address_data): - """Address has a non-literal text representation.""" - address = db.Address(**address_data) - street = address_data['street'] - city_name = address_data['city_name'] + def test_create_address(self, address): + """Test instantiation of a new `Address` object.""" + assert address is not None + def test_text_representation(self, address): + """`Address` has a non-literal text representation.""" result = repr(address) - assert result == f'' + assert result == f'' -@pytest.mark.e2e +@pytest.mark.db @pytest.mark.no_cover class TestConstraints: - """Test the database constraints defined in Address.""" + """Test the database constraints defined in `Address`.""" - # pylint:disable=no-self-use + def test_insert_into_database(self, db_session, address): + """Insert an instance into the (empty) database.""" + assert db_session.query(db.Address).count() == 0 - def test_insert_into_database(self, address, db_session): - """Insert an instance into the database.""" db_session.add(address) db_session.commit() - def test_dublicate_primary_key(self, address, address_data, city, db_session): - """Can only add a record once.""" + assert db_session.query(db.Address).count() == 1 + + def test_delete_a_referenced_address(self, db_session, address, make_address): + """Remove a record that is referenced with a FK.""" db_session.add(address) + # Fake another_address that has the same `._primary_id` as `address`. + db_session.add(make_address(_primary_id=address.id)) db_session.commit() - another_address = db.Address(**address_data) - another_address.city = city - db_session.add(another_address) + db_session.delete(address) - with pytest.raises(orm_exc.FlushError): + with pytest.raises( + sa_exc.IntegrityError, match='fk_addresses_to_addresses_via_primary_id', + ): db_session.commit() - def test_delete_a_referenced_address(self, address, address_data, db_session): + def test_delete_a_referenced_city(self, db_session, address): """Remove a record that is referenced with a FK.""" db_session.add(address) db_session.commit() - # Fake a second address that belongs to the same primary address. - address_data['id'] += 1 - another_address = db.Address(**address_data) - db_session.add(another_address) - db_session.commit() + # Must delete without ORM as otherwise an UPDATE statement is emitted. + stmt = sqla.delete(db.City).where(db.City.id == address.city.id) - with pytest.raises(sa_exc.IntegrityError): - db_session.execute( - db.Address.__table__.delete().where( # noqa:WPS609 - db.Address.id == address.id, - ), - ) - - def test_delete_a_referenced_city(self, address, city, db_session): - """Remove a record that is referenced with a FK.""" - db_session.add(address) - db_session.commit() - - with pytest.raises(sa_exc.IntegrityError): - db_session.execute( - db.City.__table__.delete().where(db.City.id == city.id), # noqa:WPS609 - ) + with pytest.raises( + sa_exc.IntegrityError, match='fk_addresses_to_cities_via_city_id', + ): + db_session.execute(stmt) @pytest.mark.parametrize('latitude', [-91, 91]) - def test_invalid_latitude(self, address, db_session, latitude): + def test_invalid_latitude(self, db_session, address, latitude): """Insert an instance with invalid data.""" address.latitude = latitude db_session.add(address) - with pytest.raises(sa_exc.IntegrityError): + with pytest.raises( + sa_exc.IntegrityError, match='latitude_between_90_degrees', + ): db_session.commit() @pytest.mark.parametrize('longitude', [-181, 181]) - def test_invalid_longitude(self, address, db_session, longitude): + def test_invalid_longitude(self, db_session, address, longitude): """Insert an instance with invalid data.""" address.longitude = longitude db_session.add(address) - with pytest.raises(sa_exc.IntegrityError): + with pytest.raises( + sa_exc.IntegrityError, match='longitude_between_180_degrees', + ): db_session.commit() @pytest.mark.parametrize('zip_code', [-1, 0, 9999, 100000]) - def test_invalid_zip_code(self, address, db_session, zip_code): + def test_invalid_zip_code(self, db_session, address, zip_code): """Insert an instance with invalid data.""" address.zip_code = zip_code db_session.add(address) - with pytest.raises(sa_exc.IntegrityError): + with pytest.raises(sa_exc.IntegrityError, match='valid_zip_code'): db_session.commit() @pytest.mark.parametrize('floor', [-1, 41]) - def test_invalid_floor(self, address, db_session, floor): + def test_invalid_floor(self, db_session, address, floor): """Insert an instance with invalid data.""" address.floor = floor db_session.add(address) - with pytest.raises(sa_exc.IntegrityError): + with pytest.raises(sa_exc.IntegrityError, match='realistic_floor'): db_session.commit() class TestProperties: - """Test properties in Address.""" + """Test properties in `Address`.""" - # pylint:disable=no-self-use - - def test_is_primary(self, address_data): - """Test Address.is_primary property.""" - address = db.Address(**address_data) + def test_is_primary(self, address): + """Test `Address.is_primary` property.""" + assert address.id == address._primary_id # noqa:WPS437 result = address.is_primary assert result is True - def test_is_not_primary(self, address_data): - """Test Address.is_primary property.""" - address_data['_primary_id'] = 999 - address = db.Address(**address_data) + def test_is_not_primary(self, address): + """Test `Address.is_primary` property.""" + address._primary_id = 999 # noqa:WPS437 result = address.is_primary diff --git a/tests/db/test_cities.py b/tests/db/test_cities.py index 50a7ecb..51aefc7 100644 --- a/tests/db/test_cities.py +++ b/tests/db/test_cities.py @@ -1,65 +1,45 @@ -"""Test the ORM's City model.""" +"""Test the ORM's `City` model.""" +# pylint:disable=no-self-use import pytest -from sqlalchemy.orm import exc as orm_exc from urban_meal_delivery import db class TestSpecialMethods: - """Test special methods in City.""" + """Test special methods in `City`.""" - # pylint:disable=no-self-use - - def test_create_city(self, city_data): - """Test instantiation of a new City object.""" - result = db.City(**city_data) - - assert result is not None - - def test_text_representation(self, city_data): - """City has a non-literal text representation.""" - city = db.City(**city_data) - name = city_data['name'] + def test_create_city(self, city): + """Test instantiation of a new `City` object.""" + assert city is not None + def test_text_representation(self, city): + """`City` has a non-literal text representation.""" result = repr(city) - assert result == f'' + assert result == f'' -@pytest.mark.e2e +@pytest.mark.db @pytest.mark.no_cover class TestConstraints: - """Test the database constraints defined in City.""" + """Test the database constraints defined in `City`.""" - # pylint:disable=no-self-use + def test_insert_into_database(self, db_session, city): + """Insert an instance into the (empty) database.""" + assert db_session.query(db.City).count() == 0 - def test_insert_into_database(self, city, db_session): - """Insert an instance into the database.""" db_session.add(city) db_session.commit() - def test_dublicate_primary_key(self, city, city_data, db_session): - """Can only add a record once.""" - db_session.add(city) - db_session.commit() - - another_city = db.City(**city_data) - db_session.add(another_city) - - with pytest.raises(orm_exc.FlushError): - db_session.commit() + assert db_session.query(db.City).count() == 1 class TestProperties: - """Test properties in City.""" - - # pylint:disable=no-self-use - - def test_location_data(self, city_data): - """Test City.location property.""" - city = db.City(**city_data) + """Test properties in `City`.""" + def test_location_data(self, city, city_data): + """Test `City.location` property.""" result = city.location assert isinstance(result, dict) @@ -67,33 +47,19 @@ class TestProperties: assert result['latitude'] == pytest.approx(city_data['_center_latitude']) assert result['longitude'] == pytest.approx(city_data['_center_longitude']) - def test_viewport_data_overall(self, city_data): - """Test City.viewport property.""" - city = db.City(**city_data) - + def test_viewport_data_overall(self, city): + """Test `City.viewport` property.""" result = city.viewport assert isinstance(result, dict) assert len(result) == 2 - def test_viewport_data_northeast(self, city_data): - """Test City.viewport property.""" - city = db.City(**city_data) - - result = city.viewport['northeast'] + @pytest.mark.parametrize('corner', ['northeast', 'southwest']) + def test_viewport_data_corners(self, city, city_data, corner): + """Test `City.viewport` property.""" + result = city.viewport[corner] assert isinstance(result, dict) assert len(result) == 2 - assert result['latitude'] == pytest.approx(city_data['_northeast_latitude']) - assert result['longitude'] == pytest.approx(city_data['_northeast_longitude']) - - def test_viewport_data_southwest(self, city_data): - """Test City.viewport property.""" - city = db.City(**city_data) - - result = city.viewport['southwest'] - - assert isinstance(result, dict) - assert len(result) == 2 - assert result['latitude'] == pytest.approx(city_data['_southwest_latitude']) - assert result['longitude'] == pytest.approx(city_data['_southwest_longitude']) + assert result['latitude'] == pytest.approx(city_data[f'_{corner}_latitude']) + assert result['longitude'] == pytest.approx(city_data[f'_{corner}_longitude']) diff --git a/tests/db/test_couriers.py b/tests/db/test_couriers.py index a3ba103..3db047e 100644 --- a/tests/db/test_couriers.py +++ b/tests/db/test_couriers.py @@ -1,125 +1,108 @@ -"""Test the ORM's Courier model.""" +"""Test the ORM's `Courier` model.""" +# pylint:disable=no-self-use import pytest from sqlalchemy import exc as sa_exc -from sqlalchemy.orm import exc as orm_exc from urban_meal_delivery import db class TestSpecialMethods: - """Test special methods in Courier.""" + """Test special methods in `Courier`.""" - # pylint:disable=no-self-use - - def test_create_courier(self, courier_data): - """Test instantiation of a new Courier object.""" - result = db.Courier(**courier_data) - - assert result is not None - - def test_text_representation(self, courier_data): - """Courier has a non-literal text representation.""" - courier_data['id'] = 1 - courier = db.Courier(**courier_data) - id_ = courier_data['id'] + def test_create_courier(self, courier): + """Test instantiation of a new `Courier` object.""" + assert courier is not None + def test_text_representation(self, courier): + """`Courier` has a non-literal text representation.""" result = repr(courier) - assert result == f'' + assert result == f'' -@pytest.mark.e2e +@pytest.mark.db @pytest.mark.no_cover class TestConstraints: - """Test the database constraints defined in Courier.""" + """Test the database constraints defined in `Courier`.""" - # pylint:disable=no-self-use + def test_insert_into_database(self, db_session, courier): + """Insert an instance into the (empty) database.""" + assert db_session.query(db.Courier).count() == 0 - def test_insert_into_database(self, courier, db_session): - """Insert an instance into the database.""" db_session.add(courier) db_session.commit() - def test_dublicate_primary_key(self, courier, courier_data, db_session): - """Can only add a record once.""" - db_session.add(courier) - db_session.commit() + assert db_session.query(db.Courier).count() == 1 - another_courier = db.Courier(**courier_data) - db_session.add(another_courier) - - with pytest.raises(orm_exc.FlushError): - db_session.commit() - - def test_invalid_vehicle(self, courier, db_session): + def test_invalid_vehicle(self, db_session, courier): """Insert an instance with invalid data.""" courier.vehicle = 'invalid' db_session.add(courier) - with pytest.raises(sa_exc.IntegrityError): + with pytest.raises(sa_exc.IntegrityError, match='available_vehicle_types'): db_session.commit() - def test_negative_speed(self, courier, db_session): + def test_negative_speed(self, db_session, courier): """Insert an instance with invalid data.""" courier.historic_speed = -1 db_session.add(courier) - with pytest.raises(sa_exc.IntegrityError): + with pytest.raises(sa_exc.IntegrityError, match='realistic_speed'): db_session.commit() - def test_unrealistic_speed(self, courier, db_session): + def test_unrealistic_speed(self, db_session, courier): """Insert an instance with invalid data.""" courier.historic_speed = 999 db_session.add(courier) - with pytest.raises(sa_exc.IntegrityError): + with pytest.raises(sa_exc.IntegrityError, match='realistic_speed'): db_session.commit() - def test_negative_capacity(self, courier, db_session): + def test_negative_capacity(self, db_session, courier): """Insert an instance with invalid data.""" courier.capacity = -1 db_session.add(courier) - with pytest.raises(sa_exc.IntegrityError): + with pytest.raises(sa_exc.IntegrityError, match='capacity_under_200_liters'): db_session.commit() - def test_too_much_capacity(self, courier, db_session): + def test_too_much_capacity(self, db_session, courier): """Insert an instance with invalid data.""" courier.capacity = 999 db_session.add(courier) - with pytest.raises(sa_exc.IntegrityError): + with pytest.raises(sa_exc.IntegrityError, match='capacity_under_200_liters'): db_session.commit() - def test_negative_pay_per_hour(self, courier, db_session): + def test_negative_pay_per_hour(self, db_session, courier): """Insert an instance with invalid data.""" courier.pay_per_hour = -1 db_session.add(courier) - with pytest.raises(sa_exc.IntegrityError): + with pytest.raises(sa_exc.IntegrityError, match='realistic_pay_per_hour'): db_session.commit() - def test_too_much_pay_per_hour(self, courier, db_session): + def test_too_much_pay_per_hour(self, db_session, courier): """Insert an instance with invalid data.""" courier.pay_per_hour = 9999 db_session.add(courier) - with pytest.raises(sa_exc.IntegrityError): + with pytest.raises(sa_exc.IntegrityError, match='realistic_pay_per_hour'): db_session.commit() - def test_negative_pay_per_order(self, courier, db_session): + def test_negative_pay_per_order(self, db_session, courier): """Insert an instance with invalid data.""" courier.pay_per_order = -1 db_session.add(courier) - with pytest.raises(sa_exc.IntegrityError): + with pytest.raises(sa_exc.IntegrityError, match='realistic_pay_per_order'): db_session.commit() - def test_too_much_pay_per_order(self, courier, db_session): + def test_too_much_pay_per_order(self, db_session, courier): """Insert an instance with invalid data.""" courier.pay_per_order = 999 db_session.add(courier) - with pytest.raises(sa_exc.IntegrityError): + with pytest.raises(sa_exc.IntegrityError, match='realistic_pay_per_order'): db_session.commit() diff --git a/tests/db/test_customer.py b/tests/db/test_customer.py index 487a11c..5c74f68 100644 --- a/tests/db/test_customer.py +++ b/tests/db/test_customer.py @@ -1,51 +1,35 @@ -"""Test the ORM's Customer model.""" +"""Test the ORM's `Customer` model.""" +# pylint:disable=no-self-use import pytest -from sqlalchemy.orm import exc as orm_exc from urban_meal_delivery import db class TestSpecialMethods: - """Test special methods in Customer.""" + """Test special methods in `Customer`.""" - # pylint:disable=no-self-use - - def test_create_customer(self, customer_data): - """Test instantiation of a new Customer object.""" - result = db.Customer(**customer_data) - - assert result is not None - - def test_text_representation(self, customer_data): - """Customer has a non-literal text representation.""" - customer = db.Customer(**customer_data) - id_ = customer_data['id'] + def test_create_customer(self, customer): + """Test instantiation of a new `Customer` object.""" + assert customer is not None + def test_text_representation(self, customer): + """`Customer` has a non-literal text representation.""" result = repr(customer) - assert result == f'' + assert result == f'' -@pytest.mark.e2e +@pytest.mark.db @pytest.mark.no_cover class TestConstraints: - """Test the database constraints defined in Customer.""" + """Test the database constraints defined in `Customer`.""" - # pylint:disable=no-self-use + def test_insert_into_database(self, db_session, customer): + """Insert an instance into the (empty) database.""" + assert db_session.query(db.Customer).count() == 0 - def test_insert_into_database(self, customer, db_session): - """Insert an instance into the database.""" db_session.add(customer) db_session.commit() - def test_dublicate_primary_key(self, customer, customer_data, db_session): - """Can only add a record once.""" - db_session.add(customer) - db_session.commit() - - another_customer = db.Customer(**customer_data) - db_session.add(another_customer) - - with pytest.raises(orm_exc.FlushError): - db_session.commit() + assert db_session.query(db.Customer).count() == 1 diff --git a/tests/db/test_orders.py b/tests/db/test_orders.py index fa36072..f23e9bb 100644 --- a/tests/db/test_orders.py +++ b/tests/db/test_orders.py @@ -1,57 +1,41 @@ -"""Test the ORM's Order model.""" +"""Test the ORM's `Order` model.""" +# pylint:disable=no-self-use,protected-access import datetime +import random import pytest -from sqlalchemy.orm import exc as orm_exc from urban_meal_delivery import db class TestSpecialMethods: - """Test special methods in Order.""" + """Test special methods in `Order`.""" - # pylint:disable=no-self-use - - def test_create_order(self, order_data): - """Test instantiation of a new Order object.""" - result = db.Order(**order_data) - - assert result is not None - - def test_text_representation(self, order_data): - """Order has a non-literal text representation.""" - order = db.Order(**order_data) - id_ = order_data['id'] + def test_create_order(self, order): + """Test instantiation of a new `Order` object.""" + assert order is not None + def test_text_representation(self, order): + """`Order` has a non-literal text representation.""" result = repr(order) - assert result == f'' + assert result == f'' -@pytest.mark.e2e +@pytest.mark.db @pytest.mark.no_cover class TestConstraints: - """Test the database constraints defined in Order.""" + """Test the database constraints defined in `Order`.""" - # pylint:disable=no-self-use + def test_insert_into_database(self, db_session, order): + """Insert an instance into the (empty) database.""" + assert db_session.query(db.Order).count() == 0 - def test_insert_into_database(self, order, db_session): - """Insert an instance into the database.""" db_session.add(order) db_session.commit() - def test_dublicate_primary_key(self, order, order_data, city, db_session): - """Can only add a record once.""" - db_session.add(order) - db_session.commit() - - another_order = db.Order(**order_data) - another_order.city = city - db_session.add(another_order) - - with pytest.raises(orm_exc.FlushError): - db_session.commit() + assert db_session.query(db.Order).count() == 1 # TODO (order-constraints): the various Foreign Key and Check Constraints # should be tested eventually. This is not of highest importance as @@ -59,339 +43,431 @@ class TestConstraints: class TestProperties: - """Test properties in Order.""" + """Test properties in `Order`. + + The `order` fixture uses the defaults specified in `factories.OrderFactory` + and provided by the `make_order` fixture. + """ # pylint:disable=no-self-use,too-many-public-methods - def test_is_not_scheduled(self, order_data): - """Test Order.scheduled property.""" - order = db.Order(**order_data) + def test_is_ad_hoc(self, order): + """Test `Order.scheduled` property.""" + assert order.ad_hoc is True result = order.scheduled assert result is False - def test_is_scheduled(self, order_data): - """Test Order.scheduled property.""" - order_data['ad_hoc'] = False - order_data['scheduled_delivery_at'] = datetime.datetime(2020, 1, 2, 12, 30, 0) - order_data['scheduled_delivery_at_corrected'] = False - order = db.Order(**order_data) + def test_is_scheduled(self, make_order): + """Test `Order.scheduled` property.""" + order = make_order(scheduled=True) + assert order.ad_hoc is False result = order.scheduled assert result is True - def test_is_completed(self, order_data): - """Test Order.completed property.""" - order = db.Order(**order_data) - + def test_is_completed(self, order): + """Test `Order.completed` property.""" result = order.completed assert result is True - def test_is_not_completed(self, order_data): - """Test Order.completed property.""" - order_data['cancelled'] = True - order_data['cancelled_at'] = datetime.datetime(2020, 1, 2, 12, 15, 0) - order_data['cancelled_at_corrected'] = False - order = db.Order(**order_data) + def test_is_not_completed1(self, make_order): + """Test `Order.completed` property.""" + order = make_order(cancel_before_pickup=True) + assert order.cancelled is True result = order.completed assert result is False - def test_is_corrected(self, order_data): - """Test Order.corrected property.""" - order_data['dispatch_at_corrected'] = True - order = db.Order(**order_data) + def test_is_not_completed2(self, make_order): + """Test `Order.completed` property.""" + order = make_order(cancel_after_pickup=True) + assert order.cancelled is True + + result = order.completed + + assert result is False + + def test_is_not_corrected(self, order): + """Test `Order.corrected` property.""" + # By default, the `OrderFactory` sets all `.*_corrected` attributes to `False`. + result = order.corrected + + assert result is False + + @pytest.mark.parametrize( + 'column', + [ + 'scheduled_delivery_at', + 'cancelled_at', + 'restaurant_notified_at', + 'restaurant_confirmed_at', + 'dispatch_at', + 'courier_notified_at', + 'courier_accepted_at', + 'pickup_at', + 'left_pickup_at', + 'delivery_at', + ], + ) + def test_is_corrected(self, order, column): + """Test `Order.corrected` property.""" + setattr(order, f'{column}_corrected', True) result = order.corrected assert result is True - def test_time_to_accept_no_dispatch_at(self, order_data): - """Test Order.time_to_accept property.""" - order_data['dispatch_at'] = None - order = db.Order(**order_data) + def test_time_to_accept_no_dispatch_at(self, order): + """Test `Order.time_to_accept` property.""" + order.dispatch_at = None with pytest.raises(RuntimeError, match='not set'): int(order.time_to_accept) - def test_time_to_accept_no_courier_accepted(self, order_data): - """Test Order.time_to_accept property.""" - order_data['courier_accepted_at'] = None - order = db.Order(**order_data) + def test_time_to_accept_no_courier_accepted(self, order): + """Test `Order.time_to_accept` property.""" + order.courier_accepted_at = None with pytest.raises(RuntimeError, match='not set'): int(order.time_to_accept) - def test_time_to_accept_success(self, order_data): - """Test Order.time_to_accept property.""" - order = db.Order(**order_data) - + def test_time_to_accept_success(self, order): + """Test `Order.time_to_accept` property.""" result = order.time_to_accept - assert isinstance(result, datetime.timedelta) + assert result > datetime.timedelta(0) - def test_time_to_react_no_courier_notified(self, order_data): - """Test Order.time_to_react property.""" - order_data['courier_notified_at'] = None - order = db.Order(**order_data) + def test_time_to_react_no_courier_notified(self, order): + """Test `Order.time_to_react` property.""" + order.courier_notified_at = None with pytest.raises(RuntimeError, match='not set'): int(order.time_to_react) - def test_time_to_react_no_courier_accepted(self, order_data): - """Test Order.time_to_react property.""" - order_data['courier_accepted_at'] = None - order = db.Order(**order_data) + def test_time_to_react_no_courier_accepted(self, order): + """Test `Order.time_to_react` property.""" + order.courier_accepted_at = None with pytest.raises(RuntimeError, match='not set'): int(order.time_to_react) - def test_time_to_react_success(self, order_data): - """Test Order.time_to_react property.""" - order = db.Order(**order_data) - + def test_time_to_react_success(self, order): + """Test `Order.time_to_react` property.""" result = order.time_to_react - assert isinstance(result, datetime.timedelta) + assert result > datetime.timedelta(0) - def test_time_to_pickup_no_reached_pickup_at(self, order_data): - """Test Order.time_to_pickup property.""" - order_data['reached_pickup_at'] = None - order = db.Order(**order_data) + def test_time_to_pickup_no_reached_pickup_at(self, order): + """Test `Order.time_to_pickup` property.""" + order.reached_pickup_at = None with pytest.raises(RuntimeError, match='not set'): int(order.time_to_pickup) - def test_time_to_pickup_no_courier_accepted(self, order_data): - """Test Order.time_to_pickup property.""" - order_data['courier_accepted_at'] = None - order = db.Order(**order_data) + def test_time_to_pickup_no_courier_accepted(self, order): + """Test `Order.time_to_pickup` property.""" + order.courier_accepted_at = None with pytest.raises(RuntimeError, match='not set'): int(order.time_to_pickup) - def test_time_to_pickup_success(self, order_data): - """Test Order.time_to_pickup property.""" - order = db.Order(**order_data) - + def test_time_to_pickup_success(self, order): + """Test `Order.time_to_pickup` property.""" result = order.time_to_pickup - assert isinstance(result, datetime.timedelta) + assert result > datetime.timedelta(0) - def test_time_at_pickup_no_reached_pickup_at(self, order_data): - """Test Order.time_at_pickup property.""" - order_data['reached_pickup_at'] = None - order = db.Order(**order_data) + def test_time_at_pickup_no_reached_pickup_at(self, order): + """Test `Order.time_at_pickup` property.""" + order.reached_pickup_at = None with pytest.raises(RuntimeError, match='not set'): int(order.time_at_pickup) - def test_time_at_pickup_no_pickup_at(self, order_data): - """Test Order.time_at_pickup property.""" - order_data['pickup_at'] = None - order = db.Order(**order_data) + def test_time_at_pickup_no_pickup_at(self, order): + """Test `Order.time_at_pickup` property.""" + order.pickup_at = None with pytest.raises(RuntimeError, match='not set'): int(order.time_at_pickup) - def test_time_at_pickup_success(self, order_data): - """Test Order.time_at_pickup property.""" - order = db.Order(**order_data) - + def test_time_at_pickup_success(self, order): + """Test `Order.time_at_pickup` property.""" result = order.time_at_pickup - assert isinstance(result, datetime.timedelta) + assert result > datetime.timedelta(0) - def test_scheduled_pickup_at_no_restaurant_notified( # noqa:WPS118 - self, order_data, - ): - """Test Order.scheduled_pickup_at property.""" - order_data['restaurant_notified_at'] = None - order = db.Order(**order_data) + def test_scheduled_pickup_at_no_restaurant_notified(self, order): # noqa:WPS118 + """Test `Order.scheduled_pickup_at` property.""" + order.restaurant_notified_at = None with pytest.raises(RuntimeError, match='not set'): int(order.scheduled_pickup_at) - def test_scheduled_pickup_at_no_est_prep_duration(self, order_data): # noqa:WPS118 - """Test Order.scheduled_pickup_at property.""" - order_data['estimated_prep_duration'] = None - order = db.Order(**order_data) + def test_scheduled_pickup_at_no_est_prep_duration(self, order): # noqa:WPS118 + """Test `Order.scheduled_pickup_at` property.""" + order.estimated_prep_duration = None with pytest.raises(RuntimeError, match='not set'): int(order.scheduled_pickup_at) - def test_scheduled_pickup_at_success(self, order_data): - """Test Order.scheduled_pickup_at property.""" - order = db.Order(**order_data) - + def test_scheduled_pickup_at_success(self, order): + """Test `Order.scheduled_pickup_at` property.""" result = order.scheduled_pickup_at - assert isinstance(result, datetime.datetime) + assert order.placed_at < result < order.delivery_at - def test_if_courier_early_at_pickup(self, order_data): - """Test Order.courier_early property.""" - order = db.Order(**order_data) + def test_courier_is_early_at_pickup(self, order): + """Test `Order.courier_early` property.""" + # Manipulate the attribute that determines `Order.scheduled_pickup_at`. + order.estimated_prep_duration = 999_999 result = order.courier_early assert bool(result) is True - def test_if_courier_late_at_pickup(self, order_data): - """Test Order.courier_late property.""" - # Opposite of test case before. - order = db.Order(**order_data) + def test_courier_is_not_early_at_pickup(self, order): + """Test `Order.courier_early` property.""" + # Manipulate the attribute that determines `Order.scheduled_pickup_at`. + order.estimated_prep_duration = 1 + + result = order.courier_early + + assert bool(result) is False + + def test_courier_is_late_at_pickup(self, order): + """Test `Order.courier_late` property.""" + # Manipulate the attribute that determines `Order.scheduled_pickup_at`. + order.estimated_prep_duration = 1 + + result = order.courier_late + + assert bool(result) is True + + def test_courier_is_not_late_at_pickup(self, order): + """Test `Order.courier_late` property.""" + # Manipulate the attribute that determines `Order.scheduled_pickup_at`. + order.estimated_prep_duration = 999_999 result = order.courier_late assert bool(result) is False - def test_if_restaurant_early_at_pickup(self, order_data): - """Test Order.restaurant_early property.""" - order = db.Order(**order_data) + def test_restaurant_early_at_pickup(self, order): + """Test `Order.restaurant_early` property.""" + # Manipulate the attribute that determines `Order.scheduled_pickup_at`. + order.estimated_prep_duration = 999_999 result = order.restaurant_early assert bool(result) is True - def test_if_restaurant_late_at_pickup(self, order_data): - """Test Order.restaurant_late property.""" - # Opposite of test case before. - order = db.Order(**order_data) + def test_restaurant_is_not_early_at_pickup(self, order): + """Test `Order.restaurant_early` property.""" + # Manipulate the attribute that determines `Order.scheduled_pickup_at`. + order.estimated_prep_duration = 1 + + result = order.restaurant_early + + assert bool(result) is False + + def test_restaurant_is_late_at_pickup(self, order): + """Test `Order.restaurant_late` property.""" + # Manipulate the attribute that determines `Order.scheduled_pickup_at`. + order.estimated_prep_duration = 1 + + result = order.restaurant_late + + assert bool(result) is True + + def test_restaurant_is_not_late_at_pickup(self, order): + """Test `Order.restaurant_late` property.""" + # Manipulate the attribute that determines `Order.scheduled_pickup_at`. + order.estimated_prep_duration = 999_999 result = order.restaurant_late assert bool(result) is False - def test_time_to_delivery_no_reached_delivery_at(self, order_data): # noqa:WPS118 - """Test Order.time_to_delivery property.""" - order_data['reached_delivery_at'] = None - order = db.Order(**order_data) + def test_time_to_delivery_no_reached_delivery_at(self, order): # noqa:WPS118 + """Test `Order.time_to_delivery` property.""" + order.reached_delivery_at = None with pytest.raises(RuntimeError, match='not set'): int(order.time_to_delivery) - def test_time_to_delivery_no_pickup_at(self, order_data): - """Test Order.time_to_delivery property.""" - order_data['pickup_at'] = None - order = db.Order(**order_data) + def test_time_to_delivery_no_pickup_at(self, order): + """Test `Order.time_to_delivery` property.""" + order.pickup_at = None with pytest.raises(RuntimeError, match='not set'): int(order.time_to_delivery) - def test_time_to_delivery_success(self, order_data): - """Test Order.time_to_delivery property.""" - order = db.Order(**order_data) - + def test_time_to_delivery_success(self, order): + """Test `Order.time_to_delivery` property.""" result = order.time_to_delivery - assert isinstance(result, datetime.timedelta) + assert result > datetime.timedelta(0) - def test_time_at_delivery_no_reached_delivery_at(self, order_data): # noqa:WPS118 - """Test Order.time_at_delivery property.""" - order_data['reached_delivery_at'] = None - order = db.Order(**order_data) + def test_time_at_delivery_no_reached_delivery_at(self, order): # noqa:WPS118 + """Test `Order.time_at_delivery` property.""" + order.reached_delivery_at = None with pytest.raises(RuntimeError, match='not set'): int(order.time_at_delivery) - def test_time_at_delivery_no_delivery_at(self, order_data): - """Test Order.time_at_delivery property.""" - order_data['delivery_at'] = None - order = db.Order(**order_data) + def test_time_at_delivery_no_delivery_at(self, order): + """Test `Order.time_at_delivery` property.""" + order.delivery_at = None with pytest.raises(RuntimeError, match='not set'): int(order.time_at_delivery) - def test_time_at_delivery_success(self, order_data): - """Test Order.time_at_delivery property.""" - order = db.Order(**order_data) - + def test_time_at_delivery_success(self, order): + """Test `Order.time_at_delivery` property.""" result = order.time_at_delivery - assert isinstance(result, datetime.timedelta) + assert result > datetime.timedelta(0) - def test_courier_waited_at_delviery(self, order_data): - """Test Order.courier_waited_at_delivery property.""" - order_data['_courier_waited_at_delivery'] = True - order = db.Order(**order_data) + def test_courier_waited_at_delviery(self, order): + """Test `Order.courier_waited_at_delivery` property.""" + order._courier_waited_at_delivery = True # noqa:WPS437 - result = int(order.courier_waited_at_delivery.total_seconds()) + result = order.courier_waited_at_delivery.total_seconds() assert result > 0 - def test_courier_did_not_wait_at_delivery(self, order_data): - """Test Order.courier_waited_at_delivery property.""" - order_data['_courier_waited_at_delivery'] = False - order = db.Order(**order_data) + def test_courier_did_not_wait_at_delivery(self, order): + """Test `Order.courier_waited_at_delivery` property.""" + order._courier_waited_at_delivery = False # noqa:WPS437 - result = int(order.courier_waited_at_delivery.total_seconds()) + result = order.courier_waited_at_delivery.total_seconds() assert result == 0 - def test_if_delivery_early_success(self, order_data): - """Test Order.delivery_early property.""" - order_data['ad_hoc'] = False - order_data['scheduled_delivery_at'] = datetime.datetime(2020, 1, 2, 12, 30, 0) - order_data['scheduled_delivery_at_corrected'] = False - order = db.Order(**order_data) + def test_ad_hoc_order_cannot_be_early(self, order): + """Test `Order.delivery_early` property.""" + # By default, the `OrderFactory` creates ad-hoc orders. + with pytest.raises(AttributeError, match='scheduled'): + int(order.delivery_early) + + def test_scheduled_order_delivered_early(self, make_order): + """Test `Order.delivery_early` property.""" + order = make_order(scheduled=True) + # Schedule the order to a lot later. + order.scheduled_delivery_at += datetime.timedelta(hours=2) result = order.delivery_early assert bool(result) is True - def test_if_delivery_early_failure(self, order_data): - """Test Order.delivery_early property.""" - order = db.Order(**order_data) + def test_scheduled_order_not_delivered_early(self, make_order): + """Test `Order.delivery_early` property.""" + order = make_order(scheduled=True) + # Schedule the order to a lot earlier. + order.scheduled_delivery_at -= datetime.timedelta(hours=2) - with pytest.raises(AttributeError, match='scheduled'): - int(order.delivery_early) + result = order.delivery_early - def test_if_delivery_late_success(self, order_data): + assert bool(result) is False + + def test_ad_hoc_order_cannot_be_late(self, order): """Test Order.delivery_late property.""" - order_data['ad_hoc'] = False - order_data['scheduled_delivery_at'] = datetime.datetime(2020, 1, 2, 12, 30, 0) - order_data['scheduled_delivery_at_corrected'] = False - order = db.Order(**order_data) + # By default, the `OrderFactory` creates ad-hoc orders. + with pytest.raises(AttributeError, match='scheduled'): + int(order.delivery_late) + + def test_scheduled_order_delivered_late(self, make_order): + """Test `Order.delivery_early` property.""" + order = make_order(scheduled=True) + # Schedule the order to a lot earlier. + order.scheduled_delivery_at -= datetime.timedelta(hours=2) + + result = order.delivery_late + + assert bool(result) is True + + def test_scheduled_order_not_delivered_late(self, make_order): + """Test `Order.delivery_early` property.""" + order = make_order(scheduled=True) + # Schedule the order to a lot later. + order.scheduled_delivery_at += datetime.timedelta(hours=2) result = order.delivery_late assert bool(result) is False - def test_if_delivery_late_failure(self, order_data): - """Test Order.delivery_late property.""" - order = db.Order(**order_data) - - with pytest.raises(AttributeError, match='scheduled'): - int(order.delivery_late) - - def test_no_total_time_for_pre_order(self, order_data): - """Test Order.total_time property.""" - order_data['ad_hoc'] = False - order_data['scheduled_delivery_at'] = datetime.datetime(2020, 1, 2, 12, 30, 0) - order_data['scheduled_delivery_at_corrected'] = False - order = db.Order(**order_data) + def test_no_total_time_for_scheduled_order(self, make_order): + """Test `Order.total_time` property.""" + order = make_order(scheduled=True) with pytest.raises(AttributeError, match='Scheduled'): int(order.total_time) - def test_no_total_time_for_cancelled_order(self, order_data): - """Test Order.total_time property.""" - order_data['cancelled'] = True - order_data['cancelled_at'] = datetime.datetime(2020, 1, 2, 12, 15, 0) - order_data['cancelled_at_corrected'] = False - order = db.Order(**order_data) + def test_no_total_time_for_cancelled_order(self, make_order): + """Test `Order.total_time` property.""" + order = make_order(cancel_before_pickup=True) with pytest.raises(RuntimeError, match='Cancelled'): int(order.total_time) - def test_total_time_success(self, order_data): - """Test Order.total_time property.""" - order = db.Order(**order_data) - + def test_total_time_success(self, order): + """Test `Order.total_time` property.""" result = order.total_time - assert isinstance(result, datetime.timedelta) + assert result > datetime.timedelta(0) + + +@pytest.mark.db +@pytest.mark.no_cover +def test_make_random_orders( # noqa:C901,WPS211,WPS210,WPS213,WPS231 + db_session, make_address, make_courier, make_restaurant, make_order, +): + """Sanity check the all the `make_*` fixtures. + + Ensure that all generated `Address`, `Courier`, `Customer`, `Restauarant`, + and `Order` objects adhere to the database constraints. + """ # noqa:D202 + # Generate a large number of `Order`s to obtain a large variance of data. + for _ in range(1_000): # noqa:WPS122 + + # Ad-hoc `Order`s are far more common than pre-orders. + scheduled = random.choice([True, False, False, False, False]) + + # Randomly pass a `address` argument to `make_restaurant()` and + # a `restaurant` argument to `make_order()`. + if random.random() < 0.5: + address = random.choice([None, make_address()]) + restaurant = make_restaurant(address=address) + else: + restaurant = None + + # Randomly pass a `courier` argument to `make_order()`. + courier = random.choice([None, make_courier()]) + + # A tiny fraction of `Order`s get cancelled. + if random.random() < 0.05: + if random.random() < 0.5: + cancel_before_pickup, cancel_after_pickup = True, False + else: + cancel_before_pickup, cancel_after_pickup = False, True + else: + cancel_before_pickup, cancel_after_pickup = False, False + + # Write all the generated objects to the database. + # This should already trigger an `IntegrityError` if the data are flawed. + order = make_order( + scheduled=scheduled, + restaurant=restaurant, + courier=courier, + cancel_before_pickup=cancel_before_pickup, + cancel_after_pickup=cancel_after_pickup, + ) + db_session.add(order) + + db_session.commit() diff --git a/tests/db/test_restaurants.py b/tests/db/test_restaurants.py index 4662346..536d6f0 100644 --- a/tests/db/test_restaurants.py +++ b/tests/db/test_restaurants.py @@ -1,80 +1,70 @@ -"""Test the ORM's Restaurant model.""" +"""Test the ORM's `Restaurant` model.""" +# pylint:disable=no-self-use import pytest +import sqlalchemy as sqla from sqlalchemy import exc as sa_exc -from sqlalchemy.orm import exc as orm_exc from urban_meal_delivery import db class TestSpecialMethods: - """Test special methods in Restaurant.""" + """Test special methods in `Restaurant`.""" - # pylint:disable=no-self-use - - def test_create_restaurant(self, restaurant_data): - """Test instantiation of a new Restaurant object.""" - result = db.Restaurant(**restaurant_data) - - assert result is not None - - def test_text_representation(self, restaurant_data): - """Restaurant has a non-literal text representation.""" - restaurant = db.Restaurant(**restaurant_data) - name = restaurant_data['name'] + def test_create_restaurant(self, restaurant): + """Test instantiation of a new `Restaurant` object.""" + assert restaurant is not None + def test_text_representation(self, restaurant): + """`Restaurant` has a non-literal text representation.""" result = repr(restaurant) - assert result == f'' + assert result == f'' -@pytest.mark.e2e +@pytest.mark.db @pytest.mark.no_cover class TestConstraints: - """Test the database constraints defined in Restaurant.""" + """Test the database constraints defined in `Restaurant`.""" - # pylint:disable=no-self-use + def test_insert_into_database(self, db_session, restaurant): + """Insert an instance into the (empty) database.""" + assert db_session.query(db.Restaurant).count() == 0 - def test_insert_into_database(self, restaurant, db_session): - """Insert an instance into the database.""" db_session.add(restaurant) db_session.commit() - def test_dublicate_primary_key(self, restaurant, restaurant_data, db_session): - """Can only add a record once.""" - db_session.add(restaurant) - db_session.commit() + assert db_session.query(db.Restaurant).count() == 1 - another_restaurant = db.Restaurant(**restaurant_data) - db_session.add(another_restaurant) - - with pytest.raises(orm_exc.FlushError): - db_session.commit() - - def test_delete_a_referenced_address(self, restaurant, address, db_session): + def test_delete_a_referenced_address(self, db_session, restaurant): """Remove a record that is referenced with a FK.""" db_session.add(restaurant) db_session.commit() - with pytest.raises(sa_exc.IntegrityError): - db_session.execute( - db.Address.__table__.delete().where( # noqa:WPS609 - db.Address.id == address.id, - ), - ) + # Must delete without ORM as otherwise an UPDATE statement is emitted. + stmt = sqla.delete(db.Address).where(db.Address.id == restaurant.address.id) - def test_negative_prep_duration(self, restaurant, db_session): + with pytest.raises( + sa_exc.IntegrityError, match='fk_restaurants_to_addresses_via_address_id', + ): + db_session.execute(stmt) + + def test_negative_prep_duration(self, db_session, restaurant): """Insert an instance with invalid data.""" restaurant.estimated_prep_duration = -1 db_session.add(restaurant) - with pytest.raises(sa_exc.IntegrityError): + with pytest.raises( + sa_exc.IntegrityError, match='realistic_estimated_prep_duration', + ): db_session.commit() - def test_too_high_prep_duration(self, restaurant, db_session): + def test_too_high_prep_duration(self, db_session, restaurant): """Insert an instance with invalid data.""" restaurant.estimated_prep_duration = 2500 db_session.add(restaurant) - with pytest.raises(sa_exc.IntegrityError): + with pytest.raises( + sa_exc.IntegrityError, match='realistic_estimated_prep_duration', + ): db_session.commit()