├── .deepsource.toml ├── .github └── workflows │ └── python-app.yml ├── .gitignore ├── Dockerfile ├── Makefile ├── README.md ├── docker-compose.yml ├── mypy.ini ├── requirements.txt ├── src ├── allocation │ ├── __init__.py │ ├── adapters │ │ ├── __init__.py │ │ ├── email.py │ │ ├── event_publisher.py │ │ ├── orm.py │ │ └── repository.py │ ├── bootstrap.py │ ├── config.py │ ├── domain │ │ ├── __init__.py │ │ ├── commands.py │ │ ├── events.py │ │ └── model.py │ ├── entrypoints │ │ ├── __init__.py │ │ ├── event_consumer.py │ │ └── flask_app.py │ ├── service_layer │ │ ├── __init__.py │ │ ├── handlers.py │ │ ├── messagebus.py │ │ └── unit_of_work.py │ └── views.py └── setup.py └── tests ├── __init__.py ├── conftest.py ├── e2e ├── __init__.py └── test_api.py ├── integration ├── __init__.py ├── test_orm.py ├── test_repository.py ├── test_uow.py └── test_views.py ├── pytest.ini └── unit ├── __init__.py ├── test_batches.py ├── test_handlers.py └── test_product.py /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | [[analyzers]] 4 | name = "python" 5 | enabled = true 6 | 7 | [analyzers.meta] 8 | runtime_version = "3.x.x" 9 | type_checker = "mypy" 10 | max_line_length = 80 11 | 12 | [[analyzers]] 13 | name = "docker" 14 | enabled = true 15 | 16 | [[analyzers]] 17 | name = "shell" 18 | enabled = true 19 | 20 | [[analyzers]] 21 | name = "test-coverage" 22 | enabled = true 23 | 24 | test_patterns = [ 25 | "tests/**", 26 | "test_*.py" 27 | ] 28 | -------------------------------------------------------------------------------- /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python app workflow 5 | 6 | on: 7 | push: 8 | branches: [master] 9 | pull_request: 10 | branches: [master] 11 | 12 | jobs: 13 | linting: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v2 18 | - name: Set up Python 3.8 19 | uses: actions/setup-python@v2 20 | with: 21 | python-version: 3.8 22 | - name: Install dependencies 23 | run: | 24 | python -m pip install --upgrade pip 25 | pip install flake8 black 26 | - name: Lint with flake8 27 | run: | 28 | make check-flake8 29 | - name: Lint with black 30 | run: | 31 | make check-black 32 | 33 | testing: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - uses: actions/checkout@v2 37 | - name: Build 38 | run: | 39 | make build 40 | - name: Run tests 41 | run: | 42 | make test-coverage 43 | - name: Report test coverage to DeepSource 44 | uses: deepsourcelabs/test-coverage-action@master 45 | with: 46 | key: python 47 | coverage-file: src/coverage.xml 48 | dsn: ${{ secrets.DEEPSOURCE_DSN }} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | .vscode 3 | .mypy_cache 4 | .pytest_cache 5 | __pycache__ 6 | *.egg-info 7 | src/.coverage 8 | src/coverage.xml -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-alpine 2 | 3 | RUN apk add --no-cache --virtual .build-deps gcc postgresql-dev musl-dev python3-dev 4 | RUN apk add libpq 5 | 6 | COPY requirements.txt /tmp/ 7 | RUN pip install -r /tmp/requirements.txt 8 | 9 | RUN apk del --no-cache .build-deps 10 | 11 | RUN mkdir -p /src 12 | COPY src/ /src/ 13 | RUN pip install -e /src 14 | COPY tests/ /tests/ 15 | 16 | WORKDIR /src 17 | ENV FLASK_APP=allocation/entrypoints/flask_app.py FLASK_DEBUG=1 PYTHONUNBUFFERED=1 18 | CMD flask run --host=0.0.0.0 --port=80 -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # these will speed up builds, for docker-compose >= 1.25 2 | export COMPOSE_DOCKER_CLI_BUILD=1 3 | export COMPOSE_PROJECT_NAME=cosmicpython 4 | export DOCKER_BUILDKIT=1 5 | 6 | all: down build up test 7 | 8 | build: 9 | docker-compose build 10 | 11 | up: 12 | docker-compose up -d 13 | 14 | down: 15 | docker-compose down 16 | 17 | logs: 18 | docker-compose logs --tail=100 19 | 20 | test: up test-e2e test-integration test-unit 21 | 22 | test-coverage: up coverage 23 | 24 | test-e2e: 25 | docker-compose run --rm --no-deps --entrypoint=pytest app /tests/e2e -vv -rs 26 | 27 | test-integration: 28 | docker-compose run --rm --no-deps --entrypoint=pytest app /tests/integration -v -rs 29 | 30 | test-unit: 31 | docker-compose run --rm --no-deps --entrypoint=pytest app /tests/unit -v -rs 32 | 33 | test-smoke: 34 | docker-compose run --rm --no-deps --entrypoint=pytest app /tests -vv -rs -m smoke 35 | 36 | coverage: 37 | docker-compose run --rm --no-deps --entrypoint=pytest app /tests -q -rs --cov=allocation --cov-report xml:coverage.xml 38 | 39 | check-black: 40 | black --line-length 80 --diff --check . 41 | 42 | check-isort: 43 | isort . --check --diff 44 | 45 | check-flake8: 46 | flake8 $(find * -name '*.py') --count --select=E9,F63,F7,F82 --show-source --statistics 47 | flake8 $(find * -name '*.py') --count --exit-zero --max-complexity=10 --max-line-length=80 --statistics 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cosmicpython-code 2 | Example application code for the [Architecture Patterns with Python](https://www.oreilly.com/library/view/architecture-patterns-with/9781492052197/) book. 3 | 4 | [![Python app workflow](https://github.com/heykarimoff/cosmicpython-code/actions/workflows/python-app.yml/badge.svg?branch=master)](https://github.com/heykarimoff/cosmicpython-code/actions/workflows/python-app.yml) 5 | [![DeepSource](https://deepsource.io/gh/heykarimoff/cosmicpython-code.svg/?label=active+issues&show_trend=true&token=Q8CoowvMEAg9gbFgHrXaVOmX)](https://deepsource.io/gh/heykarimoff/cosmicpython-code/?ref=repository-badge) 6 | 7 | Visit [API documentation](https://documenter.getpostman.com/view/14594760/Tz5iA1Vc) to see available endpoints. 8 | 9 | ### Installation 10 | Install dependencies: 11 | ```sh 12 | pip install -r requirements.txt 13 | ``` 14 | 15 | ### Tests 16 | Run all tests: 17 | ```sh 18 | make test 19 | ``` 20 | Run only unit tests: 21 | ```sh 22 | make test-unit 23 | ``` 24 | Run only integration tests: 25 | ```sh 26 | make test-integration 27 | ``` 28 | Run only end-to-end tests: 29 | ```sh 30 | make test-e2e 31 | ``` 32 | Run only smoke tests: 33 | ```sh 34 | make test-smoke 35 | ``` 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | app: 4 | image: consmicpython/app 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | depends_on: 9 | - postgres 10 | - redis 11 | environment: 12 | - DB_HOST=postgres 13 | - DB_PASSWORD=abc123 14 | - API_HOST=app 15 | - REDIS_HOST=redis 16 | - PYTHONDONTWRITEBYTECODE=1 17 | volumes: 18 | - ./src:/src 19 | - ./tests:/tests 20 | ports: 21 | - "5005:80" 22 | 23 | pubsub: 24 | image: consmicpython/pubsub 25 | build: 26 | context: . 27 | dockerfile: Dockerfile 28 | depends_on: 29 | - postgres 30 | - redis 31 | environment: 32 | - DB_HOST=postgres 33 | - DB_PASSWORD=abc123 34 | - API_HOST=pubsub 35 | - REDIS_HOST=redis 36 | - PYTHONDONTWRITEBYTECODE=1 37 | volumes: 38 | - ./src:/src 39 | - ./tests:/tests 40 | entrypoint: 41 | - python 42 | - /src/allocation/entrypoints/event_consumer.py 43 | 44 | postgres: 45 | image: postgres:9.6 46 | environment: 47 | - POSTGRES_USER=allocation 48 | - POSTGRES_PASSWORD=abc123 49 | ports: 50 | - "54321:5432" 51 | 52 | redis: 53 | image: redis:alpine 54 | ports: 55 | - "6379:6379" 56 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = False 3 | mypy_path = ./src 4 | check_untyped_defs = True 5 | 6 | [mypy-pytest.*,sqlalchemy.*] 7 | ignore_missing_imports = True -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | black==20.8b1 2 | flake8==3.8.4 3 | flask==1.1.2 4 | isort==5.7.0 5 | psycopg2-binary==2.8.6 6 | pytest-cov==3.0.0 7 | pytest-emoji==0.2.0 8 | pytest-sugar==0.9.4 9 | pytest==6.2.2 10 | redis==3.5.3 11 | requests==2.25.1 12 | sqlalchemy==1.3.23 13 | tenacity==6.3.1 -------------------------------------------------------------------------------- /src/allocation/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heykarimoff/cosmicpython-code/5a1db5104b9f86c7355bc09da6f6f1b1e14e9a24/src/allocation/__init__.py -------------------------------------------------------------------------------- /src/allocation/adapters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heykarimoff/cosmicpython-code/5a1db5104b9f86c7355bc09da6f6f1b1e14e9a24/src/allocation/adapters/__init__.py -------------------------------------------------------------------------------- /src/allocation/adapters/email.py: -------------------------------------------------------------------------------- 1 | def send_mail(*args, **kwargs): 2 | print(f"Sending email: {args} {kwargs}") 3 | -------------------------------------------------------------------------------- /src/allocation/adapters/event_publisher.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from dataclasses import asdict 4 | 5 | from allocation import config 6 | from redis import Redis 7 | 8 | logger = logging.getLogger(__name__) 9 | redis_client = Redis(**config.get_redis_host_and_port()) 10 | 11 | 12 | def publish(channel, event): 13 | logger.debug(f"Publishing channel: {channel}, event: {event}") 14 | redis_client.publish(channel, json.dumps(asdict(event))) 15 | -------------------------------------------------------------------------------- /src/allocation/adapters/orm.py: -------------------------------------------------------------------------------- 1 | from allocation.domain import model 2 | from sqlalchemy import ( 3 | Column, 4 | Date, 5 | ForeignKey, 6 | Integer, 7 | MetaData, 8 | String, 9 | Table, 10 | ) 11 | from sqlalchemy.orm import mapper, relationship 12 | 13 | metadata = MetaData() 14 | order_lines = Table( 15 | "order_lines", 16 | metadata, 17 | Column("id", Integer, primary_key=True, autoincrement=True), 18 | Column("sku", String(255)), 19 | Column("qty", Integer, nullable=False), 20 | Column("orderid", String(255)), 21 | ) 22 | products = Table( 23 | "products", 24 | metadata, 25 | Column("sku", String(255), primary_key=True), 26 | ) 27 | batches = Table( 28 | "batches", 29 | metadata, 30 | Column("id", Integer, primary_key=True, autoincrement=True), 31 | Column("reference", String(255)), 32 | Column("sku", String(255), ForeignKey("products.sku")), 33 | Column("_purchased_quantity", Integer, nullable=False), 34 | Column("eta", Date, nullable=True), 35 | ) 36 | allocations = Table( 37 | "allocations", 38 | metadata, 39 | Column("id", Integer, primary_key=True, autoincrement=True), 40 | Column("orderline_id", ForeignKey("order_lines.id")), 41 | Column("batch_id", ForeignKey("batches.id")), 42 | ) 43 | allocations_view = Table( 44 | "allocations_view", 45 | metadata, 46 | Column("orderid", String(255)), 47 | Column("sku", String(255)), 48 | Column("qty", Integer), 49 | Column("batchref", String(255)), 50 | ) 51 | 52 | 53 | def start_mappers(): 54 | lines_mapper = mapper(model.OrderLine, order_lines) 55 | batch_mapper = mapper( 56 | model.Batch, 57 | batches, 58 | properties={ 59 | "_allocations": relationship( 60 | lines_mapper, 61 | secondary=allocations, 62 | collection_class=set, 63 | ) 64 | }, 65 | ) 66 | mapper( 67 | model.Product, 68 | products, 69 | properties={ 70 | "batches": relationship( 71 | batch_mapper, primaryjoin=batches.c.sku == products.c.sku 72 | ) 73 | }, 74 | ) 75 | -------------------------------------------------------------------------------- /src/allocation/adapters/repository.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import List, Protocol, Set 3 | 4 | from allocation.domain import model 5 | from sqlalchemy.orm.session import Session 6 | 7 | 8 | class AbstractRepository(Protocol): 9 | @abc.abstractmethod 10 | def add(self, product: model.Product): 11 | raise NotImplementedError 12 | 13 | @abc.abstractmethod 14 | def get(self, sku: model.Sku) -> model.Product: 15 | raise NotImplementedError 16 | 17 | @abc.abstractmethod 18 | def get_by_batch_reference( 19 | self, reference: model.Reference 20 | ) -> model.Product: 21 | raise NotImplementedError 22 | 23 | @abc.abstractmethod 24 | def list(self) -> List[model.Product]: 25 | raise NotImplementedError 26 | 27 | 28 | class TrackingRepository: 29 | seen = Set[model.Product] 30 | 31 | def __init__(self, repo: AbstractRepository): 32 | self._repo = repo 33 | self.seen = set() # type: Set[model.Product] 34 | 35 | def add(self, product: model.Product): 36 | self._repo.add(product) 37 | self.seen.add(product) 38 | 39 | def get(self, sku: model.Sku) -> model.Product: 40 | product = self._repo.get(sku) 41 | if product: 42 | self.seen.add(product) 43 | return product 44 | 45 | def get_by_batch_reference( 46 | self, reference: model.Reference 47 | ) -> model.Product: 48 | product = self._repo.get_by_batch_reference(reference) 49 | if product: 50 | self.seen.add(product) 51 | return product 52 | 53 | def list(self) -> List[model.Product]: 54 | return self._repo.list() 55 | 56 | 57 | class SqlAlchemyRepository: 58 | def __init__(self, session: Session): 59 | self.session = session 60 | 61 | def add(self, product: model.Product): 62 | self.session.add(product) 63 | 64 | def get(self, sku: model.Sku) -> model.Product: 65 | return self.session.query(model.Product).filter_by(sku=sku).first() 66 | 67 | def get_by_batch_reference( 68 | self, reference: model.Reference 69 | ) -> model.Product: 70 | return ( 71 | self.session.query(model.Product) 72 | .join(model.Batch) 73 | .filter(model.Batch.reference == reference) 74 | .first() 75 | ) 76 | 77 | def list(self): 78 | return self.session.query(model.Product).all() 79 | -------------------------------------------------------------------------------- /src/allocation/bootstrap.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from typing import Callable 3 | 4 | from allocation.adapters import email, event_publisher, orm 5 | from allocation.service_layer import messagebus, unit_of_work, handlers 6 | 7 | 8 | def bootstrap( 9 | start_orm: bool = True, 10 | uow: unit_of_work.AbstractUnitOfWork = unit_of_work.SqlAlchemyUnitOfWork(), 11 | send_mail: Callable = email.send_mail, 12 | publish: Callable = event_publisher.publish, 13 | ) -> messagebus.MessageBus: 14 | 15 | if start_orm: 16 | orm.start_mappers() 17 | 18 | dependencies = {"uow": uow, "send_mail": send_mail, "publish": publish} 19 | injected_event_handlers = { 20 | event_type: [ 21 | inject_dependencies(event_handler, dependencies) 22 | for event_handler in event_handlers 23 | ] 24 | for event_type, event_handlers in handlers.EVENT_HANDLERS.items() 25 | } 26 | injected_commans_handlers = { 27 | command_type: inject_dependencies(command_handler, dependencies) 28 | for command_type, command_handler in handlers.COMMAND_HANDLERS.items() 29 | } 30 | return messagebus.MessageBus( 31 | uow=uow, 32 | event_handlers=injected_event_handlers, 33 | command_handlers=injected_commans_handlers, 34 | ) 35 | 36 | 37 | def inject_dependencies(handler: Callable, dependencies: dict) -> Callable: 38 | params = inspect.signature(handler).parameters 39 | deps = { 40 | name: dependency 41 | for name, dependency in dependencies.items() 42 | if name in params 43 | } 44 | return lambda *args, **kwargs: handler(*args, **{**deps, **kwargs}) 45 | -------------------------------------------------------------------------------- /src/allocation/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def get_postgres_uri(): 5 | host = os.environ.get("DB_HOST", "localhost") 6 | port = 54321 if host == "localhost" else 5432 7 | password = os.environ.get("DB_PASSWORD", "abc123") 8 | user, db_name = "allocation", "allocation" 9 | return f"postgresql://{user}:{password}@{host}:{port}/{db_name}" 10 | 11 | 12 | def get_api_url(): 13 | host = os.environ.get("API_HOST", "localhost") 14 | port = 5005 if host == "localhost" else 80 15 | return f"http://{host}:{port}" 16 | 17 | 18 | def get_redis_host_and_port(): 19 | host = os.environ.get("REDIS_HOST", "localhost") 20 | port = 6379 if host == "localhost" else 6379 21 | return dict(host=host, port=port) 22 | -------------------------------------------------------------------------------- /src/allocation/domain/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heykarimoff/cosmicpython-code/5a1db5104b9f86c7355bc09da6f6f1b1e14e9a24/src/allocation/domain/__init__.py -------------------------------------------------------------------------------- /src/allocation/domain/commands.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import date 3 | from typing import Optional 4 | 5 | 6 | class Command: 7 | pass 8 | 9 | 10 | @dataclass 11 | class CreateBatch(Command): 12 | reference: str 13 | sku: str 14 | qty: int 15 | eta: Optional[date] = None 16 | 17 | 18 | @dataclass 19 | class ChangeBatchQuantity(Command): 20 | reference: str 21 | qty: int 22 | 23 | 24 | @dataclass 25 | class Allocate(Command): 26 | orderid: str 27 | sku: str 28 | qty: int 29 | 30 | 31 | @dataclass 32 | class Deallocate(Command): 33 | orderid: str 34 | sku: str 35 | qty: int 36 | -------------------------------------------------------------------------------- /src/allocation/domain/events.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | 4 | class Event: 5 | pass 6 | 7 | 8 | @dataclass 9 | class Allocated(Event): 10 | orderid: str 11 | sku: str 12 | qty: int 13 | batchref: str 14 | 15 | 16 | @dataclass 17 | class Deallocated(Event): 18 | orderid: str 19 | sku: str 20 | qty: int 21 | 22 | 23 | @dataclass 24 | class OutOfStock(Event): 25 | sku: str 26 | -------------------------------------------------------------------------------- /src/allocation/domain/model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from datetime import date 3 | from typing import List, NewType, Optional, Set 4 | 5 | from allocation.domain.events import Allocated, Deallocated, Event, OutOfStock 6 | 7 | Reference = NewType("Reference", str) 8 | Sku = NewType("Sku", str) 9 | Quantity = NewType("Quantity", int) 10 | 11 | 12 | @dataclass(unsafe_hash=True) 13 | class OrderLine: 14 | orderid: str 15 | sku: Sku 16 | qty: Quantity 17 | 18 | 19 | class Batch: 20 | def __init__( 21 | self, reference: Reference, sku: Sku, qty: Quantity, eta: Optional[date] 22 | ): 23 | self.reference = reference 24 | self.sku = sku 25 | self.eta = eta 26 | self._purchased_quantity = qty 27 | self._allocations: Set[OrderLine] = set() 28 | 29 | def __repr__(self): 30 | return f"" 31 | 32 | def __hash__(self): 33 | return hash(self.reference) 34 | 35 | def __eq__(self, other) -> bool: 36 | if not isinstance(other, Batch): 37 | return False 38 | return self.reference == other.reference 39 | 40 | def __gt__(self, other) -> bool: 41 | if self.eta is None: 42 | return False 43 | if other.eta is None: 44 | return True 45 | 46 | return self.eta > other.eta 47 | 48 | @property 49 | def allocated_quaitity(self) -> Quantity: 50 | return Quantity(sum(line.qty for line in self._allocations)) 51 | 52 | @property 53 | def available_quantity(self) -> Quantity: 54 | return Quantity(self._purchased_quantity - self.allocated_quaitity) 55 | 56 | def allocate(self, line: OrderLine) -> None: 57 | if self.can_allocate(line): 58 | self._allocations.add(line) 59 | 60 | def deallocate(self, line: OrderLine) -> None: 61 | if line in self._allocations: 62 | self._allocations.remove(line) 63 | 64 | def can_allocate(self, line: OrderLine) -> bool: 65 | return self.sku == line.sku and 0 < line.qty <= self.available_quantity 66 | 67 | def deallocate_one(self) -> Optional[OrderLine]: 68 | if self._allocations: 69 | return self._allocations.pop() 70 | 71 | 72 | class Product: 73 | events: List[Event] = [] 74 | 75 | def __init__(self, sku: Sku, batches: List[Batch]): 76 | self.sku = sku 77 | self.batches = batches 78 | self.events = [] # type: List[Event] 79 | 80 | def __repr__(self): 81 | return f"" 82 | 83 | def __hash__(self): 84 | return hash(self.sku) 85 | 86 | def __eq__(self, other) -> bool: 87 | if not isinstance(other, Product): 88 | return False 89 | return self.sku == other.sku and self.batches == other.batches 90 | 91 | def __gt__(self, other) -> bool: 92 | return len(self.batches) > len(other.batches) 93 | 94 | def allocate(self, line: OrderLine) -> Reference: 95 | try: 96 | batch = next( 97 | b for b in sorted(self.batches) if b.can_allocate(line) 98 | ) 99 | except StopIteration: 100 | self.events.append(OutOfStock(line.sku)) 101 | return None 102 | 103 | batch.allocate(line) 104 | self.events.append( 105 | Allocated(line.orderid, line.sku, line.qty, batch.reference) 106 | ) 107 | 108 | return batch.reference 109 | 110 | def deallocate(self, line: OrderLine) -> None: 111 | for batch in self.batches: 112 | batch.deallocate(line) 113 | 114 | def change_batch_quantity( 115 | self, reference: Reference, qty: Quantity 116 | ) -> None: 117 | batch = next(b for b in self.batches if b.reference == reference) 118 | batch._purchased_quantity = qty 119 | while batch.allocated_quaitity > qty: 120 | line = batch.deallocate_one() 121 | self.events.append(Deallocated(line.orderid, line.sku, line.qty)) 122 | -------------------------------------------------------------------------------- /src/allocation/entrypoints/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heykarimoff/cosmicpython-code/5a1db5104b9f86c7355bc09da6f6f1b1e14e9a24/src/allocation/entrypoints/__init__.py -------------------------------------------------------------------------------- /src/allocation/entrypoints/event_consumer.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | from allocation import bootstrap, config 5 | from allocation.domain import commands 6 | from redis import Redis 7 | 8 | logger = logging.getLogger(__name__) 9 | redis_client = Redis(**config.get_redis_host_and_port()) 10 | 11 | 12 | def main(): 13 | messagebus = bootstrap.bootstrap() 14 | pubsub = redis_client.pubsub(ignore_subscribe_messages=True) 15 | pubsub.subscribe("change_batch_quantity") 16 | 17 | for message in pubsub.listen(): 18 | logger.debug(f"Received message: {message}") 19 | handle_change_batch_quantity(message, messagebus) 20 | 21 | 22 | def handle_change_batch_quantity(message, messagebus): 23 | data = json.loads(message["data"]) 24 | command = commands.ChangeBatchQuantity(data["batchref"], data["qty"]) 25 | messagebus.handle(message=command) 26 | 27 | 28 | if __name__ == "__main__": 29 | main() 30 | -------------------------------------------------------------------------------- /src/allocation/entrypoints/flask_app.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from allocation import bootstrap, views 4 | from allocation.domain import commands 5 | from allocation.service_layer import handlers, unit_of_work 6 | from flask import Flask, jsonify, request 7 | 8 | app = Flask(__name__) 9 | messagebus = bootstrap.bootstrap() 10 | 11 | 12 | @app.route("/add_batch", methods=["POST"]) 13 | def add_batch_endpoint(): 14 | eta = request.json.get("eta") 15 | 16 | if eta is not None: 17 | eta = datetime.fromisoformat(eta).date() 18 | 19 | message = commands.CreateBatch( 20 | request.json["reference"], 21 | request.json["sku"], 22 | request.json["qty"], 23 | eta=eta, 24 | ) 25 | 26 | messagebus.handle(message) 27 | 28 | return {"message": "OK"}, 201 29 | 30 | 31 | @app.route("/allocate", methods=["POST"]) 32 | def allocate_endpoint(): 33 | try: 34 | message = commands.Allocate( 35 | request.json["orderid"], request.json["sku"], request.json["qty"] 36 | ) 37 | results = messagebus.handle(message) 38 | batchref = results.pop(0) 39 | except handlers.InvalidSku as e: 40 | return {"message": str(e)}, 400 41 | 42 | if not batchref: 43 | return {"message": "Out of stock"}, 400 44 | 45 | return {"message": "OK"}, 202 46 | 47 | 48 | @app.route("/deallocate", methods=["POST"]) 49 | def deallocate_endpoint(): 50 | try: 51 | message = commands.Deallocate( 52 | request.json["orderid"], request.json["sku"], request.json["qty"] 53 | ) 54 | messagebus.handle(message) 55 | except handlers.InvalidSku as e: 56 | return {"message": str(e)}, 400 57 | 58 | return {"message": "OK"}, 200 59 | 60 | 61 | @app.route("/allocations/", methods=["GET"]) 62 | def allocations_view_endpoint(orderid): 63 | uow = unit_of_work.SqlAlchemyUnitOfWork() 64 | result = views.allocations(orderid, uow) 65 | if not result: 66 | return {"message": "Not found"}, 404 67 | return jsonify(result), 200 68 | -------------------------------------------------------------------------------- /src/allocation/service_layer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heykarimoff/cosmicpython-code/5a1db5104b9f86c7355bc09da6f6f1b1e14e9a24/src/allocation/service_layer/__init__.py -------------------------------------------------------------------------------- /src/allocation/service_layer/handlers.py: -------------------------------------------------------------------------------- 1 | from dataclasses import asdict 2 | from typing import Callable 3 | 4 | from allocation.domain import commands, events, model 5 | from allocation.service_layer import unit_of_work 6 | 7 | 8 | class InvalidSku(Exception): 9 | pass 10 | 11 | 12 | def add_batch( 13 | message: commands.CreateBatch, uow: unit_of_work.AbstractUnitOfWork 14 | ): 15 | sku = message.sku 16 | batch = model.Batch( 17 | message.reference, message.sku, message.qty, message.eta 18 | ) 19 | with uow: 20 | product = uow.products.get(sku=sku) 21 | if product is None: 22 | product = model.Product(sku, []) 23 | uow.products.add(product) 24 | product.batches.append(batch) 25 | uow.commit() 26 | 27 | 28 | def change_batch_quantity( 29 | message: commands.ChangeBatchQuantity, uow: unit_of_work.AbstractUnitOfWork 30 | ): 31 | with uow: 32 | product = uow.products.get_by_batch_reference( 33 | reference=message.reference 34 | ) 35 | product.change_batch_quantity( 36 | reference=message.reference, qty=message.qty 37 | ) 38 | uow.commit() 39 | 40 | 41 | def allocate( 42 | message: commands.Allocate, uow: unit_of_work.AbstractUnitOfWork 43 | ) -> str: 44 | line = model.OrderLine(message.orderid, message.sku, message.qty) 45 | 46 | with uow: 47 | product = uow.products.get(sku=message.sku) 48 | 49 | if product is None: 50 | raise InvalidSku(f"Invalid sku {line.sku}") 51 | 52 | batchref = product.allocate(line) 53 | uow.commit() 54 | 55 | return batchref 56 | 57 | 58 | def reallocate( 59 | message: events.Deallocated, uow: unit_of_work.AbstractUnitOfWork 60 | ) -> None: 61 | allocate(commands.Allocate(**asdict(message)), uow) 62 | 63 | 64 | def deallocate( 65 | message: commands.Deallocate, uow: unit_of_work.AbstractUnitOfWork 66 | ) -> None: 67 | line = model.OrderLine(message.orderid, message.sku, message.qty) 68 | with uow: 69 | product = uow.products.get(sku=message.sku) 70 | 71 | if product is None: 72 | raise InvalidSku(f"Invalid sku {line.sku}") 73 | 74 | product.deallocate(line) 75 | uow.commit() 76 | 77 | 78 | def publish_allocated_event( 79 | message: events.Allocated, publish: Callable 80 | ) -> None: 81 | publish("line_allocated", message) 82 | 83 | 84 | def send_out_of_stock_notification( 85 | message: events.OutOfStock, send_mail: Callable 86 | ) -> None: 87 | send_mail("stock-admin@made.com", f"Out of stock: {message.sku}") 88 | 89 | 90 | def add_allocation_to_read_model( 91 | message: events.Allocated, uow: unit_of_work.AbstractUnitOfWork 92 | ): 93 | with uow: 94 | uow.session.execute( 95 | "INSERT INTO allocations_view (orderid, sku, qty, batchref)" 96 | " VALUES (:orderid, :sku, :qty, :batchref)", 97 | { 98 | "orderid": message.orderid, 99 | "sku": message.sku, 100 | "qty": message.qty, 101 | "batchref": message.batchref, 102 | }, 103 | ) 104 | uow.commit() 105 | 106 | 107 | def remove_allocation_from_read_model( 108 | message: events.Deallocated, uow: unit_of_work.AbstractUnitOfWork 109 | ): 110 | with uow: 111 | uow.session.execute( 112 | "DELETE FROM allocations_view" 113 | " WHERE orderid = :orderid AND sku = :sku", 114 | { 115 | "orderid": message.orderid, 116 | "sku": message.sku, 117 | }, 118 | ) 119 | uow.commit() 120 | 121 | 122 | EVENT_HANDLERS = { 123 | events.Allocated: [ 124 | publish_allocated_event, 125 | add_allocation_to_read_model, 126 | ], 127 | events.Deallocated: [ 128 | reallocate, 129 | remove_allocation_from_read_model, 130 | ], 131 | events.OutOfStock: [send_out_of_stock_notification], 132 | } 133 | 134 | 135 | COMMAND_HANDLERS = { 136 | commands.CreateBatch: add_batch, 137 | commands.ChangeBatchQuantity: change_batch_quantity, 138 | commands.Allocate: allocate, 139 | commands.Deallocate: deallocate, 140 | } 141 | -------------------------------------------------------------------------------- /src/allocation/service_layer/messagebus.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any, Callable, Dict, List, Type, Union 3 | 4 | from allocation.domain import commands, events 5 | from allocation.service_layer import unit_of_work 6 | from tenacity import RetryError, Retrying, stop_after_attempt, wait_exponential 7 | 8 | Message = Union[commands.Command, events.Event] 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class MessageBus: 13 | def __init__( 14 | self, 15 | uow: unit_of_work.AbstractUnitOfWork, 16 | event_handlers: Dict[Type[events.Event], List[Callable]], 17 | command_handlers: Dict[Type[commands.Command], Callable], 18 | ) -> None: 19 | self.uow = uow 20 | self.event_handlers = event_handlers 21 | self.command_handlers = command_handlers 22 | self.queue = [] # type: List[Message] 23 | 24 | def handle(self, message: Message) -> List: 25 | results = [] 26 | self.queue = [message] 27 | while self.queue: 28 | message = self.queue.pop(0) 29 | if isinstance(message, events.Event): 30 | self.handle_event(message) 31 | elif isinstance(message, commands.Command): 32 | result = self.handle_command(message) 33 | results.append(result) 34 | else: 35 | raise TypeError(f"Unknown message type {type(message)}") 36 | 37 | return results 38 | 39 | def handle_event(self, event: events.Event) -> None: 40 | for handler in self.event_handlers[type(event)]: 41 | try: 42 | for attempt in Retrying( 43 | stop=stop_after_attempt(3), 44 | wait=wait_exponential(multiplier=2), 45 | ): 46 | with attempt: 47 | logger.debug(f"Handling event: {event}") 48 | handler(event) 49 | self.queue.extend(self.uow.collect_new_events()) 50 | except RetryError as retry_failure: 51 | logger.error(f"Retry error: {retry_failure}") 52 | continue 53 | 54 | def handle_command(self, command: commands.Command) -> Any: 55 | try: 56 | logger.debug(f"Handling command: {command}") 57 | handler = self.command_handlers[type(command)] 58 | result = handler(command) 59 | self.queue.extend(self.uow.collect_new_events()) 60 | return result 61 | except Exception: 62 | logger.exception(f"Error handling command: {command}") 63 | raise 64 | -------------------------------------------------------------------------------- /src/allocation/service_layer/unit_of_work.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from typing import Generator, Optional 3 | 4 | from allocation import config 5 | from allocation.adapters import repository 6 | from allocation.domain import events 7 | from sqlalchemy import create_engine 8 | from sqlalchemy.orm import sessionmaker 9 | 10 | DEFAULT_SESSION_FACTORY = sessionmaker( 11 | bind=create_engine(config.get_postgres_uri()) 12 | ) 13 | 14 | 15 | class AbstractUnitOfWork(abc.ABC): 16 | products: repository.AbstractRepository 17 | 18 | def __enter__(self): 19 | pass 20 | 21 | def __exit__(self, *args): 22 | self.rollback() 23 | 24 | @abc.abstractmethod 25 | def _commit(self): 26 | raise NotImplementedError 27 | 28 | @abc.abstractmethod 29 | def _rollback(self): 30 | raise NotImplementedError 31 | 32 | def commit(self): 33 | self._commit() 34 | 35 | def rollback(self): 36 | self._rollback() 37 | 38 | def collect_new_events( 39 | self, 40 | ) -> Optional[Generator[events.Event, None, None]]: 41 | for product in self.products.seen: 42 | while product.events: 43 | yield product.events.pop(0) 44 | 45 | 46 | class SqlAlchemyUnitOfWork(AbstractUnitOfWork): 47 | def __init__(self, session_factory=DEFAULT_SESSION_FACTORY): 48 | self.session_factory = session_factory 49 | 50 | def __enter__(self): 51 | self.session = self.session_factory() 52 | self.products = repository.TrackingRepository( 53 | repository.SqlAlchemyRepository(self.session) 54 | ) 55 | return super().__enter__() 56 | 57 | def __exit__(self, *args): 58 | super().__exit__(*args) 59 | self.session.close() 60 | 61 | def _commit(self): 62 | self.session.commit() 63 | 64 | def _rollback(self): 65 | self.session.rollback() 66 | -------------------------------------------------------------------------------- /src/allocation/views.py: -------------------------------------------------------------------------------- 1 | from allocation.service_layer import unit_of_work 2 | from typing import List, Dict 3 | 4 | 5 | def allocations( 6 | orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork 7 | ) -> List[Dict]: 8 | with uow: 9 | results = list( 10 | uow.session.execute( 11 | "SELECT sku, qty, batchref" 12 | " FROM allocations_view" 13 | " WHERE orderid = :orderid", 14 | {"orderid": orderid}, 15 | ) 16 | ) 17 | 18 | return [ 19 | {"sku": sku, "batchref": batchref, "qty": qty} 20 | for sku, qty, batchref in results 21 | ] 22 | -------------------------------------------------------------------------------- /src/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name="allocation", 5 | version="0.1", 6 | packages=["allocation"], 7 | ) 8 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heykarimoff/cosmicpython-code/5a1db5104b9f86c7355bc09da6f6f1b1e14e9a24/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import uuid 4 | from pathlib import Path 5 | 6 | import pytest 7 | import requests 8 | from allocation import config 9 | from allocation.adapters.orm import metadata, start_mappers 10 | from redis import Redis 11 | from requests.exceptions import ConnectionError 12 | from sqlalchemy import create_engine 13 | from sqlalchemy.exc import OperationalError 14 | from sqlalchemy.orm import clear_mappers, sessionmaker 15 | 16 | 17 | @pytest.fixture 18 | def url(): 19 | return config.get_api_url() 20 | 21 | 22 | @pytest.fixture 23 | def in_memory_db(): 24 | engine = create_engine("sqlite:///:memory:") 25 | metadata.create_all(engine) 26 | return engine 27 | 28 | 29 | @pytest.fixture 30 | def session_factory(in_memory_db): 31 | start_mappers() 32 | yield sessionmaker(bind=in_memory_db) 33 | clear_mappers() 34 | 35 | 36 | @pytest.fixture 37 | def session(session_factory): 38 | return session_factory() 39 | 40 | 41 | def wait_for_postgres_to_come_up(engine): 42 | deadline = time.time() + 10 43 | while time.time() < deadline: 44 | try: 45 | return engine.connect() 46 | except OperationalError: 47 | time.sleep(0.5) 48 | pytest.fail("Postgres never came up") 49 | 50 | 51 | def wait_for_webapp_to_come_up(): 52 | deadline = time.time() + 10 53 | url = config.get_api_url() 54 | while time.time() < deadline: 55 | try: 56 | return requests.get(url) 57 | except ConnectionError: 58 | time.sleep(0.5) 59 | pytest.fail("API never came up") 60 | 61 | 62 | @pytest.fixture(scope="session") 63 | def postgres_db(): 64 | engine = create_engine(config.get_postgres_uri()) 65 | wait_for_postgres_to_come_up(engine) 66 | metadata.create_all(engine) 67 | return engine 68 | 69 | 70 | @pytest.fixture 71 | def postgres_session_factory(postgres_db): 72 | start_mappers() 73 | yield sessionmaker(bind=postgres_db) 74 | clear_mappers() 75 | 76 | 77 | @pytest.fixture 78 | def postgres_session(postgres_session_factory): 79 | return postgres_session_factory() 80 | 81 | 82 | @pytest.fixture 83 | def restart_api(): 84 | ( 85 | Path(__file__).parent / "../src/allocation/entrypoints/flask_app.py" 86 | ).touch() 87 | time.sleep(0.5) 88 | wait_for_webapp_to_come_up() 89 | 90 | 91 | @pytest.fixture 92 | def post_to_add_batch(url): 93 | def _add_batch(reference, sku, qty, eta): 94 | response = requests.post( 95 | f"{url}/add_batch", 96 | json={"reference": reference, "sku": sku, "qty": qty, "eta": eta}, 97 | ) 98 | return response 99 | 100 | return _add_batch 101 | 102 | 103 | @pytest.fixture 104 | def post_to_allocate(url): 105 | def _allocate(orderid, sku, qty): 106 | response = requests.post( 107 | f"{url}/allocate", 108 | json={"orderid": orderid, "sku": sku, "qty": qty}, 109 | ) 110 | return response 111 | 112 | return _allocate 113 | 114 | 115 | @pytest.fixture 116 | def get_allocation(url): 117 | def _get_allocation(orderid): 118 | response = requests.get(f"{url}/allocations/{orderid}") 119 | return response 120 | 121 | return _get_allocation 122 | 123 | 124 | @pytest.fixture(scope="session") 125 | def redis_client(): 126 | return Redis(**config.get_redis_host_and_port()) 127 | 128 | 129 | @pytest.fixture 130 | def subscribe(redis_client): 131 | def _subscribe(channel): 132 | pubsub = redis_client.pubsub() 133 | pubsub.subscribe(channel) 134 | confirmation = pubsub.get_message(timeout=3) 135 | assert confirmation.get("type") == "subscribe" 136 | return pubsub 137 | 138 | return _subscribe 139 | 140 | 141 | @pytest.fixture 142 | def publish(redis_client): 143 | def _publish(channel, message): 144 | redis_client.publish(channel, json.dumps(message)) 145 | 146 | return _publish 147 | 148 | 149 | @pytest.fixture 150 | def random_suffix(): 151 | def _random_suffix(): 152 | return uuid.uuid4().hex[:6] 153 | 154 | return _random_suffix 155 | 156 | 157 | @pytest.fixture 158 | def random_sku(random_suffix): 159 | def _random_sku(name=""): 160 | return f"sku-{name}-{random_suffix()}" 161 | 162 | return _random_sku 163 | 164 | 165 | @pytest.fixture 166 | def random_batchref(random_suffix): 167 | def _random_batchref(name=""): 168 | return f"batch-{name}-{random_suffix()}" 169 | 170 | return _random_batchref 171 | 172 | 173 | @pytest.fixture 174 | def random_orderid(random_suffix): 175 | def _random_orderid(name=""): 176 | return f"order-{name}-{random_suffix()}" 177 | 178 | return _random_orderid 179 | -------------------------------------------------------------------------------- /tests/e2e/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heykarimoff/cosmicpython-code/5a1db5104b9f86c7355bc09da6f6f1b1e14e9a24/tests/e2e/__init__.py -------------------------------------------------------------------------------- /tests/e2e/test_api.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import pytest 4 | import requests 5 | from tenacity import Retrying, stop_after_delay 6 | 7 | pytestmark = pytest.mark.e2e 8 | 9 | 10 | @pytest.mark.smoke 11 | @pytest.mark.usefixtures("postgres_db") 12 | @pytest.mark.usefixtures("restart_api") 13 | def test_add_batch(url, random_sku, random_batchref): 14 | sku, othersku = random_sku(), random_sku("other") 15 | earlybatch = random_batchref(1) 16 | laterbatch = random_batchref(2) 17 | 18 | response = requests.post( 19 | f"{url}/add_batch", 20 | json={ 21 | "reference": earlybatch, 22 | "sku": sku, 23 | "qty": 100, 24 | }, 25 | ) 26 | 27 | assert response.status_code == 201, response.text 28 | 29 | response = requests.post( 30 | f"{url}/add_batch", 31 | json={ 32 | "reference": laterbatch, 33 | "sku": othersku, 34 | "qty": 50, 35 | "eta": "2021-01-01", 36 | }, 37 | ) 38 | assert response.status_code == 201, response.text 39 | assert response.json()["message"] == "OK" 40 | 41 | 42 | @pytest.mark.smoke 43 | @pytest.mark.usefixtures("restart_api") 44 | def test_allocate_returns_200_and_allocated_batchref( 45 | url, 46 | post_to_add_batch, 47 | get_allocation, 48 | random_sku, 49 | random_batchref, 50 | random_orderid, 51 | ): 52 | orderid = random_orderid() 53 | sku, othersku = random_sku(), random_sku("other") 54 | earlybatch = random_batchref(1) 55 | laterbatch = random_batchref(2) 56 | otherbatch = random_batchref(3) 57 | 58 | post_to_add_batch(laterbatch, sku, 100, "2011-01-02") 59 | post_to_add_batch(earlybatch, sku, 100, "2011-01-01") 60 | post_to_add_batch(otherbatch, othersku, 100, None) 61 | 62 | data = {"orderid": orderid, "sku": sku, "qty": 3} 63 | response = requests.post(f"{url}/allocate", json=data) 64 | 65 | assert response.status_code == 202, response.text 66 | assert response.json()["message"] == "OK" 67 | 68 | response = get_allocation(orderid) 69 | assert response.status_code == 200, response.text 70 | assert response.json() == [ 71 | {"batchref": earlybatch, "sku": sku, "qty": 3}, 72 | ] 73 | 74 | 75 | @pytest.mark.usefixtures("restart_api") 76 | def test_allocate_retuns_400_and_out_of_stock_message( 77 | url, 78 | post_to_add_batch, 79 | get_allocation, 80 | random_sku, 81 | random_batchref, 82 | random_orderid, 83 | ): 84 | sku, small_batch, large_order = ( 85 | random_sku(), 86 | random_batchref(), 87 | random_orderid(), 88 | ) 89 | response = post_to_add_batch(small_batch, sku, 10, "2011-01-01") 90 | assert response.status_code == 201, response.text 91 | 92 | data = {"orderid": large_order, "sku": sku, "qty": 20} 93 | response = requests.post(f"{url}/allocate", json=data) 94 | 95 | assert response.status_code == 400, response.text 96 | assert response.json()["message"] == "Out of stock" 97 | 98 | response = get_allocation(large_order) 99 | assert response.status_code == 404, response.text 100 | 101 | 102 | @pytest.mark.usefixtures("restart_api") 103 | def test_allocate_returns_400_invalid_sku_message( 104 | url, get_allocation, random_sku, random_orderid 105 | ): 106 | unknown_sku, orderid = random_sku(), random_orderid() 107 | 108 | data = {"orderid": orderid, "sku": unknown_sku, "qty": 20} 109 | response = requests.post(f"{url}/allocate", json=data) 110 | 111 | assert response.status_code == 400, response.text 112 | assert response.json()["message"] == f"Invalid sku {unknown_sku}" 113 | 114 | response = get_allocation(orderid) 115 | assert response.status_code == 404, response.text 116 | 117 | 118 | @pytest.mark.smoke 119 | @pytest.mark.usefixtures("restart_api") 120 | def test_deallocate( 121 | url, 122 | post_to_add_batch, 123 | post_to_allocate, 124 | get_allocation, 125 | random_sku, 126 | random_batchref, 127 | random_orderid, 128 | ): 129 | sku, order1, order2 = random_sku(), random_orderid(), random_orderid() 130 | batch = random_batchref() 131 | post_to_add_batch(batch, sku, 100, "2011-01-01") 132 | 133 | # fully allocate 134 | response = post_to_allocate(order1, sku, 100) 135 | assert response.status_code == 202, response.text 136 | assert response.json()["message"] == "OK" 137 | response = get_allocation(order1) 138 | assert response.status_code == 200, response.text 139 | 140 | # cannot allocate second order 141 | response = post_to_allocate(order2, sku, 100) 142 | assert response.status_code == 400, response.text 143 | response = get_allocation(order2) 144 | assert response.status_code == 404, response.text 145 | 146 | # deallocate 147 | response = requests.post( 148 | f"{url}/deallocate", json={"orderid": order1, "sku": sku, "qty": 100} 149 | ) 150 | assert response.status_code == 200, response.text 151 | assert response.json()["message"] == "OK" 152 | 153 | # now we can allocate second order 154 | response = post_to_allocate(order2, sku, 100) 155 | assert response.status_code == 202, response.text 156 | assert response.json()["message"] == "OK" 157 | response = get_allocation(order2) 158 | assert response.status_code == 200, response.text 159 | 160 | 161 | @pytest.mark.smoke 162 | @pytest.mark.usefixtures("restart_api") 163 | def test_change_batch_quantity_leading_to_reallocation( 164 | post_to_add_batch, 165 | post_to_allocate, 166 | random_sku, 167 | random_batchref, 168 | random_orderid, 169 | subscribe, 170 | publish, 171 | ): 172 | orderid, sku = random_orderid(), random_sku() 173 | earlier_batch, later_batch = random_batchref("old"), random_batchref("new") 174 | post_to_add_batch(earlier_batch, sku, 100, "2011-01-01") 175 | post_to_add_batch(later_batch, sku, 100, "2011-01-02") 176 | 177 | response = post_to_allocate(orderid, sku, 100) 178 | assert response.status_code == 202, response.text 179 | 180 | subscription = subscribe("line_allocated") 181 | 182 | publish("change_batch_quantity", {"batchref": earlier_batch, "qty": 50}) 183 | 184 | for attempt in Retrying(stop=stop_after_delay(3), reraise=True): 185 | with attempt: 186 | message = subscription.get_message(timeout=1) 187 | if message: 188 | data = json.loads(message["data"]) 189 | assert data["orderid"] == orderid 190 | assert data["batchref"] == later_batch 191 | -------------------------------------------------------------------------------- /tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heykarimoff/cosmicpython-code/5a1db5104b9f86c7355bc09da6f6f1b1e14e9a24/tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/integration/test_orm.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | import pytest 4 | from allocation.domain import model 5 | 6 | 7 | def test_orderline_mapper_can_load_lines(session): 8 | session.execute( 9 | "INSERT INTO order_lines (orderid, sku, qty) VALUES" 10 | '("order1", "RED-CHAIR", 12), ("order1", "RED-TABLE", 13),' 11 | '("order2", "BLUE-LIPSTICK", 14)' 12 | ) 13 | expected = [ 14 | model.OrderLine("order1", "RED-CHAIR", 12), 15 | model.OrderLine("order1", "RED-TABLE", 13), 16 | model.OrderLine("order2", "BLUE-LIPSTICK", 14), 17 | ] 18 | 19 | assert session.query(model.OrderLine).all() == expected 20 | 21 | 22 | def test_orderline_mapper_can_save_lines(session): 23 | new_line = model.OrderLine("order1", "DECORATIVE-WIDGET", 12) 24 | session.add(new_line) 25 | session.commit() 26 | 27 | rows = list(session.execute('SELECT orderid, sku, qty FROM "order_lines"')) 28 | assert rows == [("order1", "DECORATIVE-WIDGET", 12)] 29 | 30 | 31 | @pytest.mark.smoke 32 | def test_retrieving_products(session): 33 | session.execute( 34 | "INSERT INTO products (sku) VALUES ('RED-CHAIR'), ('RED-TABLE')" 35 | ) 36 | expected = [ 37 | model.Product("RED-CHAIR", []), 38 | model.Product("RED-TABLE", []), 39 | ] 40 | 41 | assert session.query(model.Product).all() == expected 42 | 43 | 44 | @pytest.mark.smoke 45 | def test_saving_products(session): 46 | product = model.Product("sku1", []) 47 | session.add(product) 48 | session.commit() 49 | rows = list(session.execute('SELECT sku FROM "products"')) 50 | assert rows == [("sku1",)] 51 | 52 | 53 | def test_retrieving_batches(session): 54 | session.execute( 55 | "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" 56 | ' VALUES ("batch1", "sku1", 100, null)' 57 | ) 58 | session.execute( 59 | "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" 60 | ' VALUES ("batch2", "sku2", 200, "2011-04-11")' 61 | ) 62 | expected = [ 63 | model.Batch("batch1", "sku1", 100, eta=None), 64 | model.Batch("batch2", "sku2", 200, eta=date(2011, 4, 11)), 65 | ] 66 | 67 | assert session.query(model.Batch).all() == expected 68 | 69 | 70 | def test_saving_batches(session): 71 | batch = model.Batch("batch1", "sku1", 100, eta=None) 72 | session.add(batch) 73 | session.commit() 74 | rows = list( 75 | session.execute( 76 | 'SELECT reference, sku, _purchased_quantity, eta FROM "batches"' 77 | ) 78 | ) 79 | assert rows == [("batch1", "sku1", 100, None)] 80 | 81 | 82 | def test_saving_allocations(session): 83 | batch = model.Batch("batch1", "sku1", 100, eta=None) 84 | line = model.OrderLine("order1", "sku1", 10) 85 | batch.allocate(line) 86 | session.add(batch) 87 | session.commit() 88 | rows = list( 89 | session.execute('SELECT orderline_id, batch_id FROM "allocations"') 90 | ) 91 | assert rows == [(batch.id, line.id)] 92 | 93 | 94 | def test_retrieving_allocations(session): 95 | session.execute( 96 | "INSERT INTO order_lines" 97 | '(orderid, sku, qty) VALUES ("order1", "sku1", 12)' 98 | ) 99 | [[olid]] = session.execute( 100 | "SELECT id FROM order_lines WHERE orderid=:orderid AND sku=:sku", 101 | dict(orderid="order1", sku="sku1"), 102 | ) 103 | session.execute( 104 | "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" 105 | ' VALUES ("batch1", "sku1", 100, null)' 106 | ) 107 | [[bid]] = session.execute( 108 | "SELECT id FROM batches WHERE reference=:ref AND sku=:sku", 109 | dict(ref="batch1", sku="sku1"), 110 | ) 111 | session.execute( 112 | "INSERT INTO allocations (orderline_id, batch_id) VALUES (:olid, :bid)", 113 | dict(olid=olid, bid=bid), 114 | ) 115 | 116 | batch = session.query(model.Batch).one() 117 | 118 | assert batch._allocations == {model.OrderLine("order1", "sku1", 12)} 119 | -------------------------------------------------------------------------------- /tests/integration/test_repository.py: -------------------------------------------------------------------------------- 1 | from allocation.adapters import repository 2 | from allocation.domain import model 3 | 4 | 5 | def test_repository_can_save_a_batch(session): 6 | batch = model.Batch("batch1", "RUSTY-SOAPDISH", 100, eta=None) 7 | product = model.Product("RUSTY-SOAPDISH", [batch]) 8 | 9 | repo = repository.SqlAlchemyRepository(session) 10 | repo.add(product) 11 | session.commit() 12 | 13 | rows = list(session.execute('SELECT sku FROM "products"')) 14 | assert rows == [("RUSTY-SOAPDISH",)] 15 | rows = list( 16 | session.execute( 17 | 'SELECT reference, sku, _purchased_quantity, eta FROM "batches"' 18 | ) 19 | ) 20 | assert rows == [("batch1", "RUSTY-SOAPDISH", 100, None)] 21 | 22 | 23 | def insert_order_line(session): 24 | session.execute( 25 | "INSERT INTO order_lines (orderid, sku, qty)" 26 | ' VALUES ("order1", "GENERIC-SOFA", 12)' 27 | ) 28 | [[orderline_id]] = session.execute( 29 | "SELECT id FROM order_lines WHERE orderid=:orderid AND sku=:sku", 30 | dict(orderid="order1", sku="GENERIC-SOFA"), 31 | ) 32 | return orderline_id 33 | 34 | 35 | def insert_product_batch(session, batch_id): 36 | session.execute( 37 | 'INSERT INTO products (sku) VALUES ("GENERIC-SOFA")', 38 | ) 39 | session.execute( 40 | "INSERT INTO batches (reference, sku, _purchased_quantity, eta) " 41 | 'VALUES (:batch_id, "GENERIC-SOFA", 100, null)', 42 | dict(batch_id=batch_id), 43 | ) 44 | [[batch_id]] = session.execute( 45 | "SELECT id FROM batches " 46 | 'WHERE reference=:batch_id AND sku="GENERIC-SOFA"', 47 | dict(batch_id=batch_id), 48 | ) 49 | return batch_id 50 | 51 | 52 | def insert_batch(session, batch_id): 53 | session.execute( 54 | "INSERT INTO batches (reference, sku, _purchased_quantity, eta) " 55 | 'VALUES (:batch_id, "GENERIC-TABLE", 100, null)', 56 | dict(batch_id=batch_id), 57 | ) 58 | [[batch_id]] = session.execute( 59 | "SELECT id FROM batches " 60 | 'WHERE reference=:batch_id AND sku="GENERIC-TABLE"', 61 | dict(batch_id=batch_id), 62 | ) 63 | return batch_id 64 | 65 | 66 | def insert_allocation(session, orderline_id, batch_id): 67 | session.execute( 68 | "INSERT INTO allocations (orderline_id, batch_id) " 69 | "VALUES (:orderline_id, :batch_id)", 70 | dict(orderline_id=orderline_id, batch_id=batch_id), 71 | ) 72 | 73 | 74 | def test_repository_can_retrieve_a_batch_with_allocations(session): 75 | orderline_id = insert_order_line(session) 76 | batch1_id = insert_product_batch(session, "batch1") 77 | insert_batch(session, "batch2") 78 | insert_allocation(session, orderline_id, batch1_id) 79 | 80 | repo = repository.SqlAlchemyRepository(session) 81 | retrieved = repo.get("GENERIC-SOFA") 82 | 83 | batch = model.Batch("batch1", "GENERIC-SOFA", 100, eta=None) 84 | expected = model.Product("GENERIC-SOFA", [batch]) 85 | assert retrieved == expected 86 | assert retrieved.batches[0]._purchased_quantity == batch._purchased_quantity 87 | assert retrieved.batches[0]._allocations == { 88 | model.OrderLine("order1", "GENERIC-SOFA", 12), 89 | } 90 | -------------------------------------------------------------------------------- /tests/integration/test_uow.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from allocation.domain import model 3 | from allocation.service_layer import unit_of_work 4 | 5 | 6 | def insert_batch(session, reference, sku, qty, eta): 7 | session.execute("INSERT INTO products (sku) VALUES (:sku)", dict(sku=sku)) 8 | session.execute( 9 | "INSERT INTO batches (reference, sku, _purchased_quantity, eta)" 10 | " VALUES (:ref, :sku, :qty, :eta)", 11 | dict(ref=reference, sku=sku, qty=qty, eta=eta), 12 | ) 13 | 14 | 15 | def get_allocated_batch_ref(session, orderid, sku): 16 | [[orderlineid]] = session.execute( 17 | "SELECT id FROM order_lines WHERE orderid=:orderid AND sku=:sku", 18 | dict(orderid=orderid, sku=sku), 19 | ) 20 | [[batchref]] = session.execute( 21 | "SELECT b.reference FROM allocations " 22 | "JOIN batches AS b ON batch_id = b.id " 23 | "WHERE orderline_id=:orderlineid", 24 | dict(orderlineid=orderlineid), 25 | ) 26 | return batchref 27 | 28 | 29 | def test_retrieve_a_product_and_allocate_order_line_to_it(session_factory): 30 | session = session_factory() 31 | insert_batch(session, "batch1", "HIPSTER-WORKBENCH", 100, None) 32 | session.commit() 33 | 34 | uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory) 35 | with uow: 36 | product = uow.products.get(sku="HIPSTER-WORKBENCH") 37 | line = model.OrderLine("order1", "HIPSTER-WORKBENCH", 10) 38 | assert product.events == [] 39 | product.allocate(line) 40 | uow.commit() 41 | 42 | batchref = get_allocated_batch_ref(session, "order1", "HIPSTER-WORKBENCH") 43 | 44 | assert batchref == "batch1" 45 | 46 | 47 | def test_rolls_back_uncommitted_work_by_default(session_factory): 48 | uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory) 49 | with uow: 50 | insert_batch(uow.session, "batch1", "MEDIUM-PLINTH", 100, None) 51 | 52 | new_session = session_factory() 53 | rows = list(new_session.execute('SELECT * FROM "batches"')) 54 | assert rows == [] 55 | 56 | 57 | def test_rolls_back_on_error(session_factory): 58 | class MyException(Exception): 59 | pass 60 | 61 | uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory) 62 | with pytest.raises(MyException): 63 | with uow: 64 | insert_batch(uow.session, "batch1", "LARGE-FORK", 100, None) 65 | raise MyException() 66 | 67 | new_session = session_factory() 68 | rows = list(new_session.execute('SELECT * FROM "batches"')) 69 | assert rows == [] 70 | -------------------------------------------------------------------------------- /tests/integration/test_views.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | import pytest 4 | from allocation import bootstrap, views 5 | from allocation.domain import commands 6 | from allocation.service_layer import unit_of_work 7 | 8 | today = date.today() 9 | 10 | 11 | @pytest.fixture 12 | def messagebus(session_factory): 13 | bus = bootstrap.bootstrap( 14 | start_orm=False, 15 | uow=unit_of_work.SqlAlchemyUnitOfWork(session_factory), 16 | send_mail=lambda *args, **kwargs: None, 17 | publish=lambda *args, **kwargs: None, 18 | ) 19 | return bus 20 | 21 | 22 | @pytest.mark.smoke 23 | def test_allocations_view(messagebus, random_orderid): 24 | orderid = random_orderid() 25 | messagebus.handle(commands.CreateBatch("sku1batch", "sku1", 50, None)) 26 | messagebus.handle(commands.CreateBatch("sku2batch", "sku2", 50, today)) 27 | messagebus.handle(commands.Allocate(orderid, "sku1", 20)) 28 | messagebus.handle(commands.Allocate(orderid, "sku2", 20)) 29 | # add a spurious batch and order to make sure we're getting the right ones 30 | messagebus.handle( 31 | commands.CreateBatch("sku1batch-later", "sku1", 50, today) 32 | ) 33 | messagebus.handle(commands.Allocate(random_orderid(), "sku1", 30)) 34 | messagebus.handle(commands.Allocate(random_orderid(), "sku2", 10)) 35 | 36 | assert views.allocations(orderid, messagebus.uow) == [ 37 | {"sku": "sku1", "batchref": "sku1batch", "qty": 20}, 38 | {"sku": "sku2", "batchref": "sku2batch", "qty": 20}, 39 | ] 40 | 41 | 42 | def test_deallocation(messagebus, random_orderid): 43 | orderid = random_orderid() 44 | messagebus.handle(commands.CreateBatch("sku1batch", "sku1", 50, None)) 45 | messagebus.handle(commands.CreateBatch("sku2batch", "sku1", 50, today)) 46 | messagebus.handle(commands.Allocate(orderid, "sku1", 20)) 47 | assert views.allocations(orderid, messagebus.uow) == [ 48 | {"sku": "sku1", "qty": 20, "batchref": "sku1batch"}, 49 | ] 50 | messagebus.handle(commands.ChangeBatchQuantity("sku1batch", 10)) 51 | 52 | assert views.allocations(orderid, messagebus.uow) == [ 53 | {"sku": "sku1", "qty": 20, "batchref": "sku2batch"}, 54 | ] 55 | -------------------------------------------------------------------------------- /tests/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --tb=short 3 | markers = 4 | e2e: end-to-end tests. 5 | unit: fast-running tests. 6 | smoke: thorough tests. -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/heykarimoff/cosmicpython-code/5a1db5104b9f86c7355bc09da6f6f1b1e14e9a24/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_batches.py: -------------------------------------------------------------------------------- 1 | from datetime import date, timedelta 2 | 3 | import pytest 4 | from allocation.domain.model import Batch, OrderLine 5 | 6 | today = date.today() 7 | tomorrow = today + timedelta(days=1) 8 | later = tomorrow + timedelta(days=10) 9 | 10 | 11 | def make_batch_and_line(sku, batch_qty, line_qty): 12 | return ( 13 | Batch("batch-001", sku, batch_qty, eta=today), 14 | OrderLine("order-123", sku, line_qty), 15 | ) 16 | 17 | 18 | @pytest.mark.smoke 19 | def test_allocating_to_a_batch_reduces_the_available_quantity(): 20 | batch, line = make_batch_and_line("SMALL-TABLE", 20, 10) 21 | 22 | batch.allocate(line) 23 | 24 | assert batch.available_quantity == 10 25 | 26 | 27 | batche_lines = [ 28 | pytest.param(*make_batch_and_line("TABLE", 20, -1), False, id="negative"), 29 | pytest.param(*make_batch_and_line("TABLE", 20, 0), False, id="zero"), 30 | pytest.param(*make_batch_and_line("TABLE", 20, 2), True, id="way_too_many"), 31 | pytest.param(*make_batch_and_line("TABLE", 20, 19), True, id="just enough"), 32 | pytest.param(*make_batch_and_line("TABLE", 20, 20), True, id="exact"), 33 | pytest.param(*make_batch_and_line("TABLE", 20, 21), False, id="exceed"), 34 | pytest.param(*make_batch_and_line("TABLE", 10, 20), False, id="too much"), 35 | pytest.param( 36 | *make_batch_and_line("TABLE", 10, -20), False, id="negative line" 37 | ), 38 | pytest.param( 39 | *make_batch_and_line("TABLE", -10, -20), False, id="both negative" 40 | ), 41 | pytest.param( 42 | *make_batch_and_line("TABLE", -10, 20), False, id="negative batch" 43 | ), 44 | pytest.param(*make_batch_and_line("TABLE", 0, 20), False, id="zero batch"), 45 | pytest.param(*make_batch_and_line("TABLE", 0, 0), False, id="both zero"), 46 | ] 47 | 48 | 49 | @pytest.mark.parametrize( 50 | "batch, line, allocatable", 51 | batche_lines, 52 | ) 53 | def test_can_allocate(batch, line, allocatable): 54 | assert batch.can_allocate(line) is allocatable 55 | 56 | 57 | @pytest.mark.smoke 58 | def test_can_allocate_if_available_greater_than_required(): 59 | large_batch, small_line = make_batch_and_line("SMALL-TABLE", 20, 2) 60 | 61 | assert large_batch.can_allocate(small_line) 62 | 63 | 64 | @pytest.mark.smoke 65 | def test_cannot_allocate_if_available_smaller_than_required(): 66 | small_batch, large_line = make_batch_and_line("SMALL-TABLE", 10, 20) 67 | 68 | assert not small_batch.can_allocate(large_line) 69 | 70 | 71 | @pytest.mark.smoke 72 | def test_can_allocate_if_available_equal_to_required(): 73 | batch, line = make_batch_and_line("SMALL-TABLE", 20, 20) 74 | 75 | assert batch.can_allocate(line) 76 | 77 | 78 | def test_cannot_allocate_if_skus_do_not_match(): 79 | batch = Batch("batch-001", "SMALL-TABLE", qty=20, eta=today) 80 | line = OrderLine("order-123", "BIG-SOFA", 2) 81 | 82 | assert not batch.can_allocate(line) 83 | 84 | 85 | def test_can_only_deallocate_allocated_lines(): 86 | batch, unallocated_line = make_batch_and_line("ARM-CHAIR", 20, 5) 87 | 88 | batch.deallocate(unallocated_line) 89 | 90 | assert batch.available_quantity == 20 91 | 92 | 93 | def test_allocation_is_idempotent(): 94 | batch, line = make_batch_and_line("ARM-CHAIR", 20, 5) 95 | 96 | batch.allocate(line) 97 | batch.allocate(line) 98 | batch.allocate(line) 99 | 100 | assert batch.available_quantity == 15 101 | -------------------------------------------------------------------------------- /tests/unit/test_handlers.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from typing import List 3 | 4 | import pytest 5 | from allocation import bootstrap 6 | from allocation.adapters import repository 7 | from allocation.domain import commands, model 8 | from allocation.service_layer import handlers, unit_of_work 9 | 10 | 11 | class FakeRepository: 12 | def __init__(self, products: List[model.Product] = None): 13 | self._products = products or set() 14 | 15 | def add(self, product: model.Product): 16 | self._products.add(product) 17 | 18 | def get(self, sku: model.Sku) -> model.Product: 19 | return next((item for item in self._products if item.sku == sku), None) 20 | 21 | def get_by_batch_reference( 22 | self, reference: model.Reference 23 | ) -> model.Product: 24 | return next( 25 | ( 26 | item 27 | for item in self._products 28 | for batch in item.batches 29 | if batch.reference == reference 30 | ), 31 | None, 32 | ) 33 | 34 | def list(self): 35 | return list(self._products) 36 | 37 | @staticmethod 38 | def for_batch(reference, sku, qty, eta=None): 39 | batch = model.Batch(reference, sku, qty, eta) 40 | return FakeRepository([model.Product(sku, [batch])]) 41 | 42 | 43 | class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork): 44 | def __init__(self): 45 | self.products = repository.TrackingRepository(FakeRepository()) 46 | self.committed = False 47 | 48 | def _commit(self): 49 | self.committed = True 50 | 51 | def _rollback(self): 52 | pass 53 | 54 | 55 | @pytest.fixture 56 | def messagebus(): 57 | bus = bootstrap.bootstrap( 58 | start_orm=False, 59 | uow=FakeUnitOfWork(), 60 | send_mail=lambda *args, **kwargs: None, 61 | publish=lambda *args, **kwargs: None, 62 | ) 63 | return bus 64 | 65 | 66 | @pytest.mark.smoke 67 | def test_add_batch_for_new_product(messagebus): 68 | message = commands.CreateBatch( 69 | reference="batch1", sku="COMPLICATED-LAMP", qty=100 70 | ) 71 | 72 | messagebus.handle(message) 73 | 74 | assert messagebus.uow.products.get("COMPLICATED-LAMP") is not None 75 | product = messagebus.uow.products.get("COMPLICATED-LAMP") 76 | assert "batch1" in [b.reference for b in product.batches] 77 | assert messagebus.uow.committed 78 | 79 | 80 | @pytest.mark.smoke 81 | def test_add_batch_for_existing_product(messagebus): 82 | history = [ 83 | commands.CreateBatch( 84 | reference="batch1", sku="CRUNCHY-ARMCHAIR", qty=10 85 | ), 86 | commands.CreateBatch( 87 | reference="batch2", sku="CRUNCHY-ARMCHAIR", qty=15 88 | ), 89 | ] 90 | 91 | for message in history: 92 | messagebus.handle(message) 93 | 94 | assert messagebus.uow.products.get("CRUNCHY-ARMCHAIR") is not None 95 | product = messagebus.uow.products.get("CRUNCHY-ARMCHAIR") 96 | assert "batch1" in [b.reference for b in product.batches] 97 | assert "batch2" in [b.reference for b in product.batches] 98 | assert messagebus.uow.committed 99 | 100 | 101 | @pytest.mark.smoke 102 | def test_allocate_returns_allocation(messagebus): 103 | message = commands.CreateBatch( 104 | reference="batch1", sku="COMPLICATED-LAMP", qty=100 105 | ) 106 | messagebus.handle(message) 107 | 108 | message = commands.Allocate( 109 | orderid="order1", sku="COMPLICATED-LAMP", qty=10 110 | ) 111 | [batchref] = messagebus.handle(message) 112 | 113 | assert batchref == "batch1" 114 | assert messagebus.uow.committed 115 | 116 | 117 | def test_allocate_errors_for_invalid_sku(messagebus): 118 | message = commands.CreateBatch(reference="batch1", sku="AREALSKU", qty=100) 119 | messagebus.handle(message) 120 | 121 | with pytest.raises( 122 | handlers.InvalidSku, match="Invalid sku NON-EXISTENTSKU" 123 | ): 124 | message = commands.Allocate( 125 | orderid="order1", sku="NON-EXISTENTSKU", qty=10 126 | ) 127 | messagebus.handle(message) 128 | 129 | 130 | @pytest.mark.smoke 131 | def test_deallocate(messagebus): 132 | message = commands.CreateBatch( 133 | reference="batch1", sku="COMPLICATED-LAMP", qty=100 134 | ) 135 | messagebus.handle(message) 136 | 137 | message = commands.Allocate( 138 | orderid="order1", sku="COMPLICATED-LAMP", qty=10 139 | ) 140 | [batchref] = messagebus.handle(message) 141 | assert batchref == "batch1" 142 | product = messagebus.uow.products.get("COMPLICATED-LAMP") 143 | batch = product.batches[0] 144 | assert batch.reference == "batch1" 145 | assert batch.allocated_quaitity == 10 146 | 147 | message = commands.Deallocate( 148 | orderid="order1", sku="COMPLICATED-LAMP", qty=10 149 | ) 150 | messagebus.handle(message) 151 | 152 | assert batch.allocated_quaitity == 0 153 | assert messagebus.uow.committed 154 | 155 | 156 | @pytest.mark.smoke 157 | def test_changes_available_quantity(messagebus): 158 | message = commands.CreateBatch( 159 | reference="batch1", sku="ADORABLE-SETTEE", qty=100 160 | ) 161 | messagebus.handle(message) 162 | [batch] = messagebus.uow.products.get("ADORABLE-SETTEE").batches 163 | 164 | assert batch.available_quantity == 100 165 | 166 | message = commands.ChangeBatchQuantity(reference="batch1", qty=50) 167 | messagebus.handle(message) 168 | 169 | assert batch.available_quantity == 50 170 | 171 | 172 | def test_realocates_batch_if_nessesary_when_available_quantity_reduces( 173 | messagebus, 174 | ): 175 | history = [ 176 | commands.CreateBatch( 177 | reference="batch1", sku="INDIFFERENT-TABLE", qty=100 178 | ), 179 | commands.CreateBatch( 180 | reference="batch2", 181 | sku="INDIFFERENT-TABLE", 182 | qty=100, 183 | eta=date.today(), 184 | ), 185 | commands.Allocate(orderid="order1", sku="INDIFFERENT-TABLE", qty=50), 186 | commands.Allocate(orderid="order2", sku="INDIFFERENT-TABLE", qty=50), 187 | ] 188 | for message in history: 189 | messagebus.handle(message) 190 | [batch1, batch2] = messagebus.uow.products.get("INDIFFERENT-TABLE").batches 191 | 192 | assert batch1.available_quantity == 0 193 | assert batch2.available_quantity == 100 194 | 195 | message = commands.ChangeBatchQuantity(reference="batch1", qty=55) 196 | messagebus.handle(message) 197 | 198 | assert batch1.available_quantity == 5 199 | assert batch2.available_quantity == 50 200 | -------------------------------------------------------------------------------- /tests/unit/test_product.py: -------------------------------------------------------------------------------- 1 | from datetime import date, timedelta 2 | 3 | import pytest 4 | from allocation.domain.events import OutOfStock 5 | from allocation.domain.model import Batch, OrderLine, Product 6 | 7 | today = date.today() 8 | tomorrow = today + timedelta(days=1) 9 | later = tomorrow + timedelta(days=10) 10 | 11 | 12 | @pytest.mark.smoke 13 | def test_prefers_warehouse_batches_to_shipments(): 14 | in_stock_batch = Batch("batch-001", "BIG-SOFA", qty=20, eta=None) 15 | shipment_batch = Batch("batch-002", "BIG-SOFA", qty=20, eta=today) 16 | product = Product(sku="BIG-SOFA", batches=[in_stock_batch, shipment_batch]) 17 | line = OrderLine("order-123", "BIG-SOFA", 5) 18 | 19 | product.allocate(line) 20 | 21 | assert in_stock_batch.available_quantity == 15 22 | assert shipment_batch.available_quantity == 20 23 | 24 | 25 | def test_returns_allocated_batch_reference(): 26 | in_stock_batch = Batch("batch-001", "BIG-SOFA", qty=20, eta=None) 27 | shipment_batch = Batch("batch-002", "BIG-SOFA", qty=20, eta=today) 28 | product = Product(sku="BIG-SOFA", batches=[in_stock_batch, shipment_batch]) 29 | line = OrderLine("order-123", "BIG-SOFA", 5) 30 | 31 | allocation = product.allocate(line) 32 | 33 | assert allocation == in_stock_batch.reference 34 | 35 | 36 | @pytest.mark.smoke 37 | def test_prefers_earlier_batches(): 38 | tomorrows_batch = Batch("batch-001", "MINI-SPOON", qty=20, eta=tomorrow) 39 | upcoming_batch = Batch("batch-001", "MINI-SPOON", qty=20, eta=later) 40 | product = Product( 41 | sku="MINI-SPOON", batches=[tomorrows_batch, upcoming_batch] 42 | ) 43 | line = OrderLine("order-123", "MINI-SPOON", 5) 44 | 45 | allocation = product.allocate(line) 46 | 47 | assert allocation == tomorrows_batch.reference 48 | assert tomorrows_batch.available_quantity == 15 49 | assert upcoming_batch.available_quantity == 20 50 | 51 | 52 | def test_raises_out_of_stock_exception_if_cannot_allocate(): 53 | batch = Batch("batch-001", "SMALL-TABLE", qty=5, eta=today) 54 | line1 = OrderLine("order-1", "SMALL-TABLE", 5) 55 | line2 = OrderLine("order-2", "SMALL-TABLE", 5) 56 | 57 | product = Product(sku="SMALL-TABLE", batches=[batch]) 58 | product.allocate(line1) 59 | 60 | allocation = product.allocate(line2) 61 | assert product.events[-1] == OutOfStock(sku="SMALL-TABLE") 62 | assert allocation is None 63 | --------------------------------------------------------------------------------