diff --git a/.github/workflows/run_tests_on_pull_request.yml b/.github/workflows/run_tests_on_pull_request.yml new file mode 100644 index 00000000..940e6afa --- /dev/null +++ b/.github/workflows/run_tests_on_pull_request.yml @@ -0,0 +1,11 @@ +on: + pull_request: + types: [opened, synchronize, reopened] +jobs: + run_tests: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: run tests + run: make all \ No newline at end of file diff --git a/.travis.yml b/.travis.yml index fcd3ceea..8b4550c1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,13 @@ -dist: xenial +dist: focal language: python -python: 3.8 - +python: 3.9 +before_install: + - echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin script: - make all - branches: except: - - /.*_exercise$/ + - "/.*_exercise$/" +env: + global: + secure: cNctlzVCjUj1oOrRW0aryxhJHB/u0b6vmn532jcgyCRS1aQjMJtT61O7tW6yMk4wOaH4Lr0kAI0J6+1lnRYf5g11H1M+IpEHGMWgJgImsysDpLRWUGmDJTez/ii8psk0SfOP/0ZwQp+QxOB92CHdPeOPOmu2HFa00V82/H7gousXR7ywQRNthLHwso36O8+UoHc4qw8nIbjcHzbfD6ysJNynmaUMlB3mRTU1hkjGKKpA2Xyl4tmkIhp3NCPJc0WR4SgB3y0u3dVOC+RtbRzl/XpEbjsZHHNloBirK+8ERn9ISBBh/mvfo6qTix743e+xvhtBlLJjk3o4H0VMH+wQ3zIpIh4TKbhPCMqWY3gvtKDVRHD+Sywk2TE6zSz0sDPWk248MC2QsL7sgeFwcnFHOWy2iKf4YyuZtoaJuX+2tw23cDCdMS6wbARlT8Kb5QwMlsxuKYN/04kQB+9nXTVsWKJGIwLKdYRzshnlzqB/UEe2vrjZcbBixCp4pbZ2jSzw2881he4KSbVGIJdZYSFetMuaN0P9obtdaJU4V+IhwzFyyapjZhEGCTl+l/m8uGdJ5DOhFlZ7OczHja7DKUuQvB3AbnMGvN518C+fJkJpWxAn5UeIp3d0ZZm32XVKt3k8PJaP7LBYdxnr3JCRit7+kNnlP7Ho0NjvX6GTHQ+r+JM= diff --git a/README.md b/README.md index df5c823c..8b35464c 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,25 @@ -# Example application code for the python architecture book +# Example application code for the "Architecture Patterns with Python" book ## Chapters Each chapter has its own branch which contains all the commits for that chapter, -so it has the state that corresponds to the _end_ of that chapter. If you want -to try and code along with a chapter, you'll want to check out the branch for the -previous chapter. +so it has the state that corresponds to the _end_ of that chapter. +If you want to try and code along with a chapter, +you'll want to check out the branch for the previous chapter. -https://github.com/python-leap/code/branches/all +https://github.com/cosmicpython/code/branches/all ## Exercises -Branches for the exercises follow the convention `{chatper_name}_exercise`, eg -https://github.com/python-leap/code/tree/chapter_04_service_layer_exercise +Branches for the exercises follow the convention `{chapter_name}_exercise`, +eg https://github.com/cosmicpython/code/tree/chapter_04_service_layer_exercise ## Requirements * docker with docker-compose -* for chapters 1 and 2, and optionally for the rest: a local python3.7 virtualenv +* for chapters 1 and 2, and optionally for the rest: a local python3.8 virtualenv ## Building the containers @@ -45,10 +45,10 @@ pip install pytest pip install pytest sqlalchemy # for chapter 4+5 -pip install requirements.txt +pip install -r requirements.txt # for chapter 6+ -pip install requirements.txt +pip install -r requirements.txt pip install -e src/ ``` @@ -60,9 +60,9 @@ pip install -e src/ ```sh make test # or, to run individual test types -make unit -make integration -make e2e +make unit-tests +make integration-tests +make e2e-tests # or, if you have a local virtualenv make up pytest tests/unit diff --git a/docker-compose.yml b/docker-compose.yml index dc2cc369..f964ab74 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,10 +10,12 @@ services: depends_on: - postgres - redis + - mailhog environment: - DB_HOST=postgres - DB_PASSWORD=abc123 - REDIS_HOST=redis + - EMAIL_HOST=mailhog - PYTHONDONTWRITEBYTECODE=1 volumes: - ./src:/src @@ -26,11 +28,13 @@ services: image: allocation-image depends_on: - redis_pubsub + - mailhog environment: - DB_HOST=postgres - DB_PASSWORD=abc123 - API_HOST=api - REDIS_HOST=redis + - EMAIL_HOST=mailhog - PYTHONDONTWRITEBYTECODE=1 - FLASK_APP=allocation/entrypoints/flask_app.py - FLASK_DEBUG=1 @@ -59,3 +63,8 @@ services: ports: - "63791:6379" + mailhog: + image: mailhog/mailhog + ports: + - "11025:1025" + - "18025:8025" diff --git a/requirements.txt b/requirements.txt index 882cb352..789a24fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # app -sqlalchemy +sqlalchemy<2 flask psycopg2-binary redis diff --git a/src/allocation/adapters/email.py b/src/allocation/adapters/email.py deleted file mode 100644 index 1c37d427..00000000 --- a/src/allocation/adapters/email.py +++ /dev/null @@ -1,2 +0,0 @@ -def send(*args): - print("SENDING EMAIL:", *args) diff --git a/src/allocation/adapters/notifications.py b/src/allocation/adapters/notifications.py new file mode 100644 index 00000000..db29f7c8 --- /dev/null +++ b/src/allocation/adapters/notifications.py @@ -0,0 +1,28 @@ +# pylint: disable=too-few-public-methods +import abc +import smtplib +from allocation import config + + +class AbstractNotifications(abc.ABC): + @abc.abstractmethod + def send(self, destination, message): + raise NotImplementedError + + +DEFAULT_HOST = config.get_email_host_and_port()["host"] +DEFAULT_PORT = config.get_email_host_and_port()["port"] + + +class EmailNotifications(AbstractNotifications): + def __init__(self, smtp_host=DEFAULT_HOST, port=DEFAULT_PORT): + self.server = smtplib.SMTP(smtp_host, port=port) + self.server.noop() + + def send(self, destination, message): + msg = f"Subject: allocation service notification\n{message}" + self.server.sendmail( + from_addr="allocations@example.com", + to_addrs=[destination], + msg=msg, + ) diff --git a/src/allocation/adapters/orm.py b/src/allocation/adapters/orm.py index a87068a5..81d704ca 100644 --- a/src/allocation/adapters/orm.py +++ b/src/allocation/adapters/orm.py @@ -1,3 +1,4 @@ +import logging from sqlalchemy import ( Table, MetaData, @@ -12,6 +13,7 @@ from allocation.domain import model +logger = logging.getLogger(__name__) metadata = MetaData() @@ -59,6 +61,7 @@ def start_mappers(): + logger.info("Starting mappers") lines_mapper = mapper(model.OrderLine, order_lines) batches_mapper = mapper( model.Batch, diff --git a/src/allocation/adapters/redis_eventpublisher.py b/src/allocation/adapters/redis_eventpublisher.py index 6100956f..d607d6ac 100644 --- a/src/allocation/adapters/redis_eventpublisher.py +++ b/src/allocation/adapters/redis_eventpublisher.py @@ -12,5 +12,5 @@ def publish(channel, event: events.Event): - logging.debug("publishing: channel=%s, event=%s", channel, event) + logging.info("publishing: channel=%s, event=%s", channel, event) r.publish(channel, json.dumps(asdict(event))) diff --git a/src/allocation/bootstrap.py b/src/allocation/bootstrap.py new file mode 100644 index 00000000..22112a06 --- /dev/null +++ b/src/allocation/bootstrap.py @@ -0,0 +1,51 @@ +import inspect +from typing import Callable +from allocation.adapters import orm, redis_eventpublisher +from allocation.adapters.notifications import ( + AbstractNotifications, + EmailNotifications, +) +from allocation.service_layer import handlers, messagebus, unit_of_work + + +def bootstrap( + start_orm: bool = True, + uow: unit_of_work.AbstractUnitOfWork = unit_of_work.SqlAlchemyUnitOfWork(), + notifications: AbstractNotifications = None, + publish: Callable = redis_eventpublisher.publish, +) -> messagebus.MessageBus: + + if notifications is None: + notifications = EmailNotifications() + + if start_orm: + orm.start_mappers() + + dependencies = {"uow": uow, "notifications": notifications, "publish": publish} + injected_event_handlers = { + event_type: [ + inject_dependencies(handler, dependencies) + for handler in event_handlers + ] + for event_type, event_handlers in handlers.EVENT_HANDLERS.items() + } + injected_command_handlers = { + command_type: inject_dependencies(handler, dependencies) + for command_type, handler in handlers.COMMAND_HANDLERS.items() + } + + return messagebus.MessageBus( + uow=uow, + event_handlers=injected_event_handlers, + command_handlers=injected_command_handlers, + ) + + +def inject_dependencies(handler, dependencies): + params = inspect.signature(handler).parameters + deps = { + name: dependency + for name, dependency in dependencies.items() + if name in params + } + return lambda message: handler(message, **deps) diff --git a/src/allocation/config.py b/src/allocation/config.py index 30a8eb07..bda1bbf2 100644 --- a/src/allocation/config.py +++ b/src/allocation/config.py @@ -19,3 +19,10 @@ def get_redis_host_and_port(): host = os.environ.get("REDIS_HOST", "localhost") port = 63791 if host == "localhost" else 6379 return dict(host=host, port=port) + + +def get_email_host_and_port(): + host = os.environ.get("EMAIL_HOST", "localhost") + port = 11025 if host == "localhost" else 1025 + http_port = 18025 if host == "localhost" else 8025 + return dict(host=host, port=port, http_port=http_port) diff --git a/src/allocation/entrypoints/flask_app.py b/src/allocation/entrypoints/flask_app.py index ed482390..f50f3edd 100644 --- a/src/allocation/entrypoints/flask_app.py +++ b/src/allocation/entrypoints/flask_app.py @@ -1,14 +1,11 @@ from datetime import datetime from flask import Flask, jsonify, request - from allocation.domain import commands -from allocation.adapters import orm -from allocation.service_layer import messagebus, unit_of_work from allocation.service_layer.handlers import InvalidSku -from allocation import views +from allocation import bootstrap, views app = Flask(__name__) -orm.start_mappers() +bus = bootstrap.bootstrap() @app.route("/add_batch", methods=["POST"]) @@ -19,8 +16,7 @@ def add_batch(): cmd = commands.CreateBatch( request.json["ref"], request.json["sku"], request.json["qty"], eta ) - uow = unit_of_work.SqlAlchemyUnitOfWork() - messagebus.handle(cmd, uow) + bus.handle(cmd) return "OK", 201 @@ -30,8 +26,7 @@ def allocate_endpoint(): cmd = commands.Allocate( request.json["orderid"], request.json["sku"], request.json["qty"] ) - uow = unit_of_work.SqlAlchemyUnitOfWork() - messagebus.handle(cmd, uow) + bus.handle(cmd) except InvalidSku as e: return {"message": str(e)}, 400 @@ -40,8 +35,7 @@ def allocate_endpoint(): @app.route("/allocations/", methods=["GET"]) def allocations_view_endpoint(orderid): - uow = unit_of_work.SqlAlchemyUnitOfWork() - result = views.allocations(orderid, uow) + result = views.allocations(orderid, bus.uow) if not result: return "not found", 404 return jsonify(result), 200 diff --git a/src/allocation/entrypoints/redis_eventconsumer.py b/src/allocation/entrypoints/redis_eventconsumer.py index e04a8142..6d0d49a7 100644 --- a/src/allocation/entrypoints/redis_eventconsumer.py +++ b/src/allocation/entrypoints/redis_eventconsumer.py @@ -2,10 +2,8 @@ import logging import redis -from allocation import config +from allocation import bootstrap, config from allocation.domain import commands -from allocation.adapters import orm -from allocation.service_layer import messagebus, unit_of_work logger = logging.getLogger(__name__) @@ -13,19 +11,20 @@ def main(): - orm.start_mappers() + logger.info("Redis pubsub starting") + bus = bootstrap.bootstrap() pubsub = r.pubsub(ignore_subscribe_messages=True) pubsub.subscribe("change_batch_quantity") for m in pubsub.listen(): - handle_change_batch_quantity(m) + handle_change_batch_quantity(m, bus) -def handle_change_batch_quantity(m): - logging.debug("handling %s", m) +def handle_change_batch_quantity(m, bus): + logger.info("handling %s", m) data = json.loads(m["data"]) cmd = commands.ChangeBatchQuantity(ref=data["batchref"], qty=data["qty"]) - messagebus.handle(cmd, uow=unit_of_work.SqlAlchemyUnitOfWork()) + bus.handle(cmd) if __name__ == "__main__": diff --git a/src/allocation/service_layer/handlers.py b/src/allocation/service_layer/handlers.py index fa2698a4..2d7aa8d4 100644 --- a/src/allocation/service_layer/handlers.py +++ b/src/allocation/service_layer/handlers.py @@ -1,12 +1,12 @@ # pylint: disable=unused-argument from __future__ import annotations from dataclasses import asdict -from typing import TYPE_CHECKING -from allocation.adapters import email, redis_eventpublisher +from typing import List, Dict, Callable, Type, TYPE_CHECKING from allocation.domain import commands, events, model from allocation.domain.model import OrderLine if TYPE_CHECKING: + from allocation.adapters import notifications from . import unit_of_work @@ -44,10 +44,7 @@ def reallocate( event: events.Deallocated, uow: unit_of_work.AbstractUnitOfWork, ): - with uow: - product = uow.products.get(sku=event.sku) - product.events.append(commands.Allocate(**asdict(event))) - uow.commit() + allocate(commands.Allocate(**asdict(event)), uow=uow) def change_batch_quantity( @@ -65,9 +62,9 @@ def change_batch_quantity( def send_out_of_stock_notification( event: events.OutOfStock, - uow: unit_of_work.AbstractUnitOfWork, + notifications: notifications.AbstractNotifications, ): - email.send( + notifications.send( "stock@made.com", f"Out of stock for {event.sku}", ) @@ -75,9 +72,9 @@ def send_out_of_stock_notification( def publish_allocated_event( event: events.Allocated, - uow: unit_of_work.AbstractUnitOfWork, + publish: Callable, ): - redis_eventpublisher.publish("line_allocated", event) + publish("line_allocated", event) def add_allocation_to_read_model( @@ -108,3 +105,16 @@ def remove_allocation_from_read_model( dict(orderid=event.orderid, sku=event.sku), ) uow.commit() + + +EVENT_HANDLERS = { + events.Allocated: [publish_allocated_event, add_allocation_to_read_model], + events.Deallocated: [remove_allocation_from_read_model, reallocate], + events.OutOfStock: [send_out_of_stock_notification], +} # type: Dict[Type[events.Event], List[Callable]] + +COMMAND_HANDLERS = { + commands.Allocate: allocate, + commands.CreateBatch: add_batch, + commands.ChangeBatchQuantity: change_batch_quantity, +} # type: Dict[Type[commands.Command], Callable] diff --git a/src/allocation/service_layer/messagebus.py b/src/allocation/service_layer/messagebus.py index 104111fa..45679341 100644 --- a/src/allocation/service_layer/messagebus.py +++ b/src/allocation/service_layer/messagebus.py @@ -1,9 +1,8 @@ -# pylint: disable=broad-except +# pylint: disable=broad-except, attribute-defined-outside-init from __future__ import annotations import logging -from typing import List, Dict, Callable, Type, Union, TYPE_CHECKING +from typing import Callable, Dict, List, Union, Type, TYPE_CHECKING from allocation.domain import commands, events -from . import handlers if TYPE_CHECKING: from . import unit_of_work @@ -13,62 +12,44 @@ Message = Union[commands.Command, events.Event] -def handle(message: Message, uow: unit_of_work.AbstractUnitOfWork): - queue = [message] - while queue: - message = queue.pop(0) - if isinstance(message, events.Event): - handle_event(message, queue, uow) - elif isinstance(message, commands.Command): - handle_command(message, queue, uow) - else: - raise Exception(f"{message} was not an Event or Command") - - -def handle_event( - event: events.Event, - queue: List[Message], - uow: unit_of_work.AbstractUnitOfWork, -): - for handler in EVENT_HANDLERS[type(event)]: +class MessageBus: + def __init__( + self, + uow: unit_of_work.AbstractUnitOfWork, + event_handlers: Dict[Type[events.Event], List[Callable]], + command_handlers: Dict[Type[commands.Command], Callable], + ): + self.uow = uow + self.event_handlers = event_handlers + self.command_handlers = command_handlers + + def handle(self, message: Message): + self.queue = [message] + while self.queue: + message = self.queue.pop(0) + if isinstance(message, events.Event): + self.handle_event(message) + elif isinstance(message, commands.Command): + self.handle_command(message) + else: + raise Exception(f"{message} was not an Event or Command") + + def handle_event(self, event: events.Event): + for handler in self.event_handlers[type(event)]: + try: + logger.debug("handling event %s with handler %s", event, handler) + handler(event) + self.queue.extend(self.uow.collect_new_events()) + except Exception: + logger.exception("Exception handling event %s", event) + continue + + def handle_command(self, command: commands.Command): + logger.debug("handling command %s", command) try: - logger.debug("handling event %s with handler %s", event, handler) - handler(event, uow=uow) - queue.extend(uow.collect_new_events()) + handler = self.command_handlers[type(command)] + handler(command) + self.queue.extend(self.uow.collect_new_events()) except Exception: - logger.exception("Exception handling event %s", event) - continue - - -def handle_command( - command: commands.Command, - queue: List[Message], - uow: unit_of_work.AbstractUnitOfWork, -): - logger.debug("handling command %s", command) - try: - handler = COMMAND_HANDLERS[type(command)] - handler(command, uow=uow) - queue.extend(uow.collect_new_events()) - except Exception: - logger.exception("Exception handling command %s", command) - raise - - -EVENT_HANDLERS = { - events.Allocated: [ - handlers.publish_allocated_event, - handlers.add_allocation_to_read_model, - ], - events.Deallocated: [ - handlers.remove_allocation_from_read_model, - handlers.reallocate, - ], - events.OutOfStock: [handlers.send_out_of_stock_notification], -} # type: Dict[Type[events.Event], List[Callable]] - -COMMAND_HANDLERS = { - commands.Allocate: handlers.allocate, - commands.CreateBatch: handlers.add_batch, - commands.ChangeBatchQuantity: handlers.change_batch_quantity, -} # type: Dict[Type[commands.Command], Callable] + logger.exception("Exception handling command %s", command) + raise diff --git a/tests/conftest.py b/tests/conftest.py index 3b2a8066..f91f93a0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,22 +18,22 @@ @pytest.fixture -def in_memory_db(): +def in_memory_sqlite_db(): engine = create_engine("sqlite:///:memory:") metadata.create_all(engine) return engine @pytest.fixture -def sqlite_session_factory(in_memory_db): - start_mappers() - yield sessionmaker(bind=in_memory_db) - clear_mappers() +def sqlite_session_factory(in_memory_sqlite_db): + yield sessionmaker(bind=in_memory_sqlite_db) @pytest.fixture -def sqlite_session(sqlite_session_factory): - return sqlite_session_factory() +def mappers(): + start_mappers() + yield + clear_mappers() @retry(stop=stop_after_delay(10)) @@ -54,7 +54,7 @@ def wait_for_redis_to_come_up(): @pytest.fixture(scope="session") def postgres_db(): - engine = create_engine(config.get_postgres_uri()) + engine = create_engine(config.get_postgres_uri(), isolation_level="SERIALIZABLE") wait_for_postgres_to_come_up(engine) metadata.create_all(engine) return engine @@ -62,9 +62,7 @@ def postgres_db(): @pytest.fixture def postgres_session_factory(postgres_db): - start_mappers() yield sessionmaker(bind=postgres_db) - clear_mappers() @pytest.fixture diff --git a/tests/integration/test_email.py b/tests/integration/test_email.py new file mode 100644 index 00000000..4aade37b --- /dev/null +++ b/tests/integration/test_email.py @@ -0,0 +1,37 @@ +# pylint: disable=redefined-outer-name +import pytest +import requests +from sqlalchemy.orm import clear_mappers +from allocation import bootstrap, config +from allocation.domain import commands +from allocation.adapters import notifications +from allocation.service_layer import unit_of_work +from ..random_refs import random_sku + + +@pytest.fixture +def bus(sqlite_session_factory): + bus = bootstrap.bootstrap( + start_orm=True, + uow=unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory), + notifications=notifications.EmailNotifications(), + publish=lambda *args: None, + ) + yield bus + clear_mappers() + + +def get_email_from_mailhog(sku): + host, port = map(config.get_email_host_and_port().get, ["host", "http_port"]) + all_emails = requests.get(f"http://{host}:{port}/api/v2/messages").json() + return next(m for m in all_emails["items"] if sku in str(m)) + + +def test_out_of_stock_email(bus): + sku = random_sku() + bus.handle(commands.CreateBatch("batch1", sku, 9, None)) + bus.handle(commands.Allocate("order1", sku, 10)) + email = get_email_from_mailhog(sku) + assert email["Raw"]["From"] == "allocations@example.com" + assert email["Raw"]["To"] == ["stock@made.com"] + assert f"Out of stock for {sku}" in email["Raw"]["Data"] diff --git a/tests/integration/test_repository.py b/tests/integration/test_repository.py index 9b637f1a..7961be2a 100644 --- a/tests/integration/test_repository.py +++ b/tests/integration/test_repository.py @@ -1,9 +1,13 @@ +import pytest from allocation.adapters import repository from allocation.domain import model +pytestmark = pytest.mark.usefixtures("mappers") -def test_get_by_batchref(sqlite_session): - repo = repository.SqlAlchemyRepository(sqlite_session) + +def test_get_by_batchref(sqlite_session_factory): + session = sqlite_session_factory() + repo = repository.SqlAlchemyRepository(session) b1 = model.Batch(ref="b1", sku="sku1", qty=100, eta=None) b2 = model.Batch(ref="b2", sku="sku1", qty=100, eta=None) b3 = model.Batch(ref="b3", sku="sku2", qty=100, eta=None) diff --git a/tests/integration/test_uow.py b/tests/integration/test_uow.py index 55c4f24d..61f3aae5 100644 --- a/tests/integration/test_uow.py +++ b/tests/integration/test_uow.py @@ -1,13 +1,16 @@ -# pylint: disable=broad-except +# pylint: disable=broad-except, too-many-arguments import threading import time import traceback from typing import List +from unittest.mock import Mock import pytest from allocation.domain import model from allocation.service_layer import unit_of_work from ..random_refs import random_sku, random_batchref, random_orderid +pytestmark = pytest.mark.usefixtures("mappers") + def insert_batch(session, ref, sku, qty, eta, product_version=1): session.execute( @@ -75,15 +78,15 @@ class MyException(Exception): assert rows == [] -def try_to_allocate(orderid, sku, exceptions): +def try_to_allocate(orderid, sku, exceptions, session_factory): line = model.OrderLine(orderid, sku, 10) try: - with unit_of_work.SqlAlchemyUnitOfWork() as uow: + with unit_of_work.SqlAlchemyUnitOfWork(session_factory) as uow: product = uow.products.get(sku=sku) product.allocate(line) time.sleep(0.2) uow.commit() - except Exception as e: + except Exception as e: # pylint: disable=broad-except print(traceback.format_exc()) exceptions.append(e) @@ -96,8 +99,12 @@ def test_concurrent_updates_to_version_are_not_allowed(postgres_session_factory) order1, order2 = random_orderid(1), random_orderid(2) exceptions = [] # type: List[Exception] - try_to_allocate_order1 = lambda: try_to_allocate(order1, sku, exceptions) - try_to_allocate_order2 = lambda: try_to_allocate(order2, sku, exceptions) + try_to_allocate_order1 = lambda: try_to_allocate( + order1, sku, exceptions, postgres_session_factory + ) + try_to_allocate_order2 = lambda: try_to_allocate( + order2, sku, exceptions, postgres_session_factory + ) thread1 = threading.Thread(target=try_to_allocate_order1) thread2 = threading.Thread(target=try_to_allocate_order2) thread1.start() @@ -121,5 +128,5 @@ def test_concurrent_updates_to_version_are_not_allowed(postgres_session_factory) dict(sku=sku), ) assert orders.rowcount == 1 - with unit_of_work.SqlAlchemyUnitOfWork() as uow: + with unit_of_work.SqlAlchemyUnitOfWork(postgres_session_factory) as uow: uow.session.execute("select 1") diff --git a/tests/integration/test_views.py b/tests/integration/test_views.py index 4dda3e54..ccd5d542 100644 --- a/tests/integration/test_views.py +++ b/tests/integration/test_views.py @@ -1,35 +1,49 @@ +# pylint: disable=redefined-outer-name from datetime import date -from allocation import views +from sqlalchemy.orm import clear_mappers +from unittest import mock +import pytest +from allocation import bootstrap, views from allocation.domain import commands -from allocation.service_layer import messagebus, unit_of_work +from allocation.service_layer import unit_of_work today = date.today() -def test_allocations_view(sqlite_session_factory): - uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory) - messagebus.handle(commands.CreateBatch("sku1batch", "sku1", 50, None), uow) - messagebus.handle(commands.CreateBatch("sku2batch", "sku2", 50, today), uow) - messagebus.handle(commands.Allocate("order1", "sku1", 20), uow) - messagebus.handle(commands.Allocate("order1", "sku2", 20), uow) +@pytest.fixture +def sqlite_bus(sqlite_session_factory): + bus = bootstrap.bootstrap( + start_orm=True, + uow=unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory), + notifications=mock.Mock(), + publish=lambda *args: None, + ) + yield bus + clear_mappers() + + +def test_allocations_view(sqlite_bus): + sqlite_bus.handle(commands.CreateBatch("sku1batch", "sku1", 50, None)) + sqlite_bus.handle(commands.CreateBatch("sku2batch", "sku2", 50, today)) + sqlite_bus.handle(commands.Allocate("order1", "sku1", 20)) + sqlite_bus.handle(commands.Allocate("order1", "sku2", 20)) # add a spurious batch and order to make sure we're getting the right ones - messagebus.handle(commands.CreateBatch("sku1batch-later", "sku1", 50, today), uow) - messagebus.handle(commands.Allocate("otherorder", "sku1", 30), uow) - messagebus.handle(commands.Allocate("otherorder", "sku2", 10), uow) + sqlite_bus.handle(commands.CreateBatch("sku1batch-later", "sku1", 50, today)) + sqlite_bus.handle(commands.Allocate("otherorder", "sku1", 30)) + sqlite_bus.handle(commands.Allocate("otherorder", "sku2", 10)) - assert views.allocations("order1", uow) == [ + assert views.allocations("order1", sqlite_bus.uow) == [ {"sku": "sku1", "batchref": "sku1batch"}, {"sku": "sku2", "batchref": "sku2batch"}, ] -def test_deallocation(sqlite_session_factory): - uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory) - messagebus.handle(commands.CreateBatch("b1", "sku1", 50, None), uow) - messagebus.handle(commands.CreateBatch("b2", "sku1", 50, today), uow) - messagebus.handle(commands.Allocate("o1", "sku1", 40), uow) - messagebus.handle(commands.ChangeBatchQuantity("b1", 10), uow) +def test_deallocation(sqlite_bus): + sqlite_bus.handle(commands.CreateBatch("b1", "sku1", 50, None)) + sqlite_bus.handle(commands.CreateBatch("b2", "sku1", 50, today)) + sqlite_bus.handle(commands.Allocate("o1", "sku1", 40)) + sqlite_bus.handle(commands.ChangeBatchQuantity("b1", 10)) - assert views.allocations("o1", uow) == [ + assert views.allocations("o1", sqlite_bus.uow) == [ {"sku": "sku1", "batchref": "b2"}, ] diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index 04fb9630..f1218540 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -1,10 +1,14 @@ # pylint: disable=no-self-use +from __future__ import annotations +from collections import defaultdict from datetime import date -from unittest import mock +from typing import Dict, List import pytest -from allocation.adapters import repository -from allocation.domain import commands, events -from allocation.service_layer import handlers, messagebus, unit_of_work +from allocation import bootstrap +from allocation.domain import commands +from allocation.service_layer import handlers +from allocation.adapters import notifications, repository +from allocation.service_layer import unit_of_work class FakeRepository(repository.AbstractRepository): @@ -37,81 +41,87 @@ def rollback(self): pass +class FakeNotifications(notifications.AbstractNotifications): + def __init__(self): + self.sent = defaultdict(list) # type: Dict[str, List[str]] + + def send(self, destination, message): + self.sent[destination].append(message) + + +def bootstrap_test_app(): + return bootstrap.bootstrap( + start_orm=False, + uow=FakeUnitOfWork(), + notifications=FakeNotifications(), + publish=lambda *args: None, + ) + + class TestAddBatch: def test_for_new_product(self): - uow = FakeUnitOfWork() - messagebus.handle( - commands.CreateBatch("b1", "CRUNCHY-ARMCHAIR", 100, None), uow - ) - assert uow.products.get("CRUNCHY-ARMCHAIR") is not None - assert uow.committed + bus = bootstrap_test_app() + bus.handle(commands.CreateBatch("b1", "CRUNCHY-ARMCHAIR", 100, None)) + assert bus.uow.products.get("CRUNCHY-ARMCHAIR") is not None + assert bus.uow.committed def test_for_existing_product(self): - uow = FakeUnitOfWork() - messagebus.handle(commands.CreateBatch("b1", "GARISH-RUG", 100, None), uow) - messagebus.handle(commands.CreateBatch("b2", "GARISH-RUG", 99, None), uow) - assert "b2" in [b.reference for b in uow.products.get("GARISH-RUG").batches] - - -@pytest.fixture(autouse=True) -def fake_redis_publish(): - with mock.patch("allocation.adapters.redis_eventpublisher.publish"): - yield + bus = bootstrap_test_app() + bus.handle(commands.CreateBatch("b1", "GARISH-RUG", 100, None)) + bus.handle(commands.CreateBatch("b2", "GARISH-RUG", 99, None)) + assert "b2" in [ + b.reference for b in bus.uow.products.get("GARISH-RUG").batches + ] class TestAllocate: def test_allocates(self): - uow = FakeUnitOfWork() - messagebus.handle( - commands.CreateBatch("batch1", "COMPLICATED-LAMP", 100, None), uow - ) - messagebus.handle(commands.Allocate("o1", "COMPLICATED-LAMP", 10), uow) - [batch] = uow.products.get("COMPLICATED-LAMP").batches + bus = bootstrap_test_app() + bus.handle(commands.CreateBatch("batch1", "COMPLICATED-LAMP", 100, None)) + bus.handle(commands.Allocate("o1", "COMPLICATED-LAMP", 10)) + [batch] = bus.uow.products.get("COMPLICATED-LAMP").batches assert batch.available_quantity == 90 def test_errors_for_invalid_sku(self): - uow = FakeUnitOfWork() - messagebus.handle(commands.CreateBatch("b1", "AREALSKU", 100, None), uow) + bus = bootstrap_test_app() + bus.handle(commands.CreateBatch("b1", "AREALSKU", 100, None)) with pytest.raises(handlers.InvalidSku, match="Invalid sku NONEXISTENTSKU"): - messagebus.handle(commands.Allocate("o1", "NONEXISTENTSKU", 10), uow) + bus.handle(commands.Allocate("o1", "NONEXISTENTSKU", 10)) def test_commits(self): - uow = FakeUnitOfWork() - messagebus.handle( - commands.CreateBatch("b1", "OMINOUS-MIRROR", 100, None), uow - ) - messagebus.handle(commands.Allocate("o1", "OMINOUS-MIRROR", 10), uow) - assert uow.committed + bus = bootstrap_test_app() + bus.handle(commands.CreateBatch("b1", "OMINOUS-MIRROR", 100, None)) + bus.handle(commands.Allocate("o1", "OMINOUS-MIRROR", 10)) + assert bus.uow.committed def test_sends_email_on_out_of_stock_error(self): - uow = FakeUnitOfWork() - messagebus.handle( - commands.CreateBatch("b1", "POPULAR-CURTAINS", 9, None), uow + fake_notifs = FakeNotifications() + bus = bootstrap.bootstrap( + start_orm=False, + uow=FakeUnitOfWork(), + notifications=fake_notifs, + publish=lambda *args: None, ) - - with mock.patch("allocation.adapters.email.send") as mock_send_mail: - messagebus.handle(commands.Allocate("o1", "POPULAR-CURTAINS", 10), uow) - assert mock_send_mail.call_args == mock.call( - "stock@made.com", f"Out of stock for POPULAR-CURTAINS" - ) + bus.handle(commands.CreateBatch("b1", "POPULAR-CURTAINS", 9, None)) + bus.handle(commands.Allocate("o1", "POPULAR-CURTAINS", 10)) + assert fake_notifs.sent["stock@made.com"] == [ + f"Out of stock for POPULAR-CURTAINS", + ] class TestChangeBatchQuantity: def test_changes_available_quantity(self): - uow = FakeUnitOfWork() - messagebus.handle( - commands.CreateBatch("batch1", "ADORABLE-SETTEE", 100, None), uow - ) - [batch] = uow.products.get(sku="ADORABLE-SETTEE").batches + bus = bootstrap_test_app() + bus.handle(commands.CreateBatch("batch1", "ADORABLE-SETTEE", 100, None)) + [batch] = bus.uow.products.get(sku="ADORABLE-SETTEE").batches assert batch.available_quantity == 100 - messagebus.handle(commands.ChangeBatchQuantity("batch1", 50), uow) - + bus.handle(commands.ChangeBatchQuantity("batch1", 50)) assert batch.available_quantity == 50 def test_reallocates_if_necessary(self): - uow = FakeUnitOfWork() + bus = bootstrap_test_app() history = [ commands.CreateBatch("batch1", "INDIFFERENT-TABLE", 50, None), commands.CreateBatch("batch2", "INDIFFERENT-TABLE", 50, date.today()), @@ -119,12 +129,12 @@ def test_reallocates_if_necessary(self): commands.Allocate("order2", "INDIFFERENT-TABLE", 20), ] for msg in history: - messagebus.handle(msg, uow) - [batch1, batch2] = uow.products.get(sku="INDIFFERENT-TABLE").batches + bus.handle(msg) + [batch1, batch2] = bus.uow.products.get(sku="INDIFFERENT-TABLE").batches assert batch1.available_quantity == 10 assert batch2.available_quantity == 50 - messagebus.handle(commands.ChangeBatchQuantity("batch1", 25), uow) + bus.handle(commands.ChangeBatchQuantity("batch1", 25)) # order1 or order2 will be deallocated, so we'll have 25 - 20 assert batch1.available_quantity == 5