diff --git a/app/allocation/adapters/dao.py b/app/allocation/adapters/dao.py new file mode 100644 index 0000000..911a248 --- /dev/null +++ b/app/allocation/adapters/dao.py @@ -0,0 +1,16 @@ +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import AsyncSession + +from app.allocation.adapters.dto import Allocation + + +async def allocations(sku: str, session: AsyncSession) -> list[Allocation]: + result = await session.execute( + sa.text( + "SELECT allocations_view.order_id, allocations_view.batch_id, " + 'allocations_view.sku, "order".qty FROM allocations_view JOIN "order" ' + 'ON allocations_view.order_id = "order".id WHERE allocations_view.sku = :sku' + ), + dict(sku=sku), + ) + return [Allocation(**dict(r)) for r in result.fetchall()] diff --git a/app/allocation/adapters/dto.py b/app/allocation/adapters/dto.py new file mode 100644 index 0000000..e1ef14c --- /dev/null +++ b/app/allocation/adapters/dto.py @@ -0,0 +1,10 @@ +from uuid import UUID + +from pydantic import BaseModel + + +class Allocation(BaseModel): + order_id: UUID + sku: str + qty: int + batch_id: UUID diff --git a/app/allocation/adapters/orm.py b/app/allocation/adapters/orm.py index 784505b..b517950 100644 --- a/app/allocation/adapters/orm.py +++ b/app/allocation/adapters/orm.py @@ -7,8 +7,8 @@ mapper_registry = registry() metadata = mapper_registry.metadata -order_line_table = sa.Table( - "order_line", +order_table = sa.Table( + "order", metadata, sa.Column("id", UUID(as_uuid=True), primary_key=True), sa.Column("sku", sa.String), @@ -19,7 +19,7 @@ "batch", metadata, sa.Column("id", UUID(as_uuid=True), primary_key=True), - sa.Column("sku", sa.ForeignKey("products.sku")), + sa.Column("sku", sa.ForeignKey("product.sku")), sa.Column("qty", sa.Integer), sa.Column("eta", sa.Date, nullable=True), ) @@ -28,32 +28,39 @@ "allocation", metadata, sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), - sa.Column("order_line_id", sa.ForeignKey("order_line.id")), + sa.Column("order_id", sa.ForeignKey("order.id")), sa.Column("batch_id", sa.ForeignKey("batch.id")), + sa.UniqueConstraint("order_id", "batch_id"), ) -products = sa.Table( - "products", +product_table = sa.Table( + "product", metadata, sa.Column("sku", sa.String, primary_key=True), sa.Column("version_number", sa.Integer, nullable=False, server_default="0"), ) +allocations_view = sa.Table( + "allocations_view", + metadata, + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("order_id", UUID), + sa.Column("sku", sa.String(255)), + sa.Column("batch_id", UUID), + sa.UniqueConstraint("order_id", "batch_id"), +) + def start_mappers() -> None: - line_mapper = mapper_registry.map_imperatively(models.OrderLine, order_line_table) + order_mapper = mapper_registry.map_imperatively(models.Order, order_table) batches_mapper = mapper_registry.map_imperatively( models.Batch, batch_table, - properties={ - "allocations": relationship( - line_mapper, secondary=allocation_table, collection_class=set - ) - }, + properties={"allocations": relationship(order_mapper, secondary=allocation_table, collection_class=set)}, ) mapper_registry.map_imperatively( models.Product, - products, + product_table, properties={"batches": relationship(batches_mapper)}, - version_id_col=products.c.version_number, + version_id_col=product_table.c.version_number, ) diff --git a/app/allocation/constants.py b/app/allocation/constants.py index 92af11f..45b69fd 100644 --- a/app/allocation/constants.py +++ b/app/allocation/constants.py @@ -1,2 +1,3 @@ -LINE_ALLOCATED_CHANNEL = "allocation:line_allocated:v1" +ORDER_DEALLOCATED_CHANNEL = "allocation:order_deallocated:v1" +ORDER_ALLOCATED_CHANNEL = "allocation:order_allocated:v1" BATCH_QUANTITY_CHANGED_CHANNEL = "allocation:batch_quantity_changed:v1" diff --git a/app/allocation/domain/commands.py b/app/allocation/domain/commands.py index 9962c33..8d10d00 100644 --- a/app/allocation/domain/commands.py +++ b/app/allocation/domain/commands.py @@ -23,5 +23,6 @@ class ChangeBatchQuantity(Command): @dataclass class Allocate(Command): + order_id: UUID sku: str qty: int diff --git a/app/allocation/domain/events.py b/app/allocation/domain/events.py index 72b33d1..4d4cf14 100644 --- a/app/allocation/domain/events.py +++ b/app/allocation/domain/events.py @@ -17,3 +17,10 @@ class Allocated(Event): @dataclass class OutOfStock(Event): sku: str + + +@dataclass +class Deallocated(Event): + order_id: UUID + sku: str + qty: int diff --git a/app/allocation/domain/models.py b/app/allocation/domain/models.py index fc5dec2..902e0e9 100644 --- a/app/allocation/domain/models.py +++ b/app/allocation/domain/models.py @@ -6,7 +6,7 @@ @dataclass(unsafe_hash=True, kw_only=True) -class OrderLine: +class Order: id: UUID = field(default_factory=uuid4) sku: str qty: int @@ -18,7 +18,7 @@ class Batch: sku: str eta: date = None qty: int - allocations: set[OrderLine] = field(default_factory=lambda: set()) + allocations: set[Order] = field(default_factory=lambda: set()) def __repr__(self) -> str: return f"" @@ -38,27 +38,23 @@ def __gt__(self, other: Batch) -> bool: return True return self.eta > other.eta - def allocate(self, line: OrderLine) -> None: - if self.can_allocate(line): - self.allocations.add(line) + def allocate(self, order: Order) -> None: + if self.can_allocate(order): + self.allocations.add(order) - def deallocate_one(self) -> OrderLine: + def deallocate_one(self) -> Order: return self.allocations.pop() @property def allocated_quantity(self) -> int: - return sum(line.qty for line in self.allocations) + return sum(order.qty for order in self.allocations) @property def available_quantity(self) -> int: return self.qty - self.allocated_quantity - def can_allocate(self, line: OrderLine) -> bool: - return ( - self.sku == line.sku - and self.available_quantity >= line.qty - and line not in self.allocations - ) + def can_allocate(self, order: Order) -> bool: + return self.sku == order.sku and self.available_quantity >= order.qty and order not in self.allocations @dataclass(kw_only=True) @@ -67,23 +63,23 @@ class Product: batches: list[Batch] version_number: int = 0 - def allocate(self, line: OrderLine) -> UUID: + def allocate(self, order: Order) -> UUID: try: - batch = next(b for b in sorted(self.batches) if b.can_allocate(line)) - batch.allocate(line) + batch = next(b for b in sorted(self.batches) if b.can_allocate(order)) + batch.allocate(order) self.version_number += 1 return batch.id except StopIteration: return None - def change_batch_quantity(self, id: UUID, qty: int) -> list[OrderLine]: + def change_batch_quantity(self, id: UUID, qty: int) -> list[Order]: batch = next(b for b in self.batches if b.id == id) batch.qty = qty - deallocated_lines = [] + deallocated_orders = [] while batch.available_quantity < 0: - line = batch.deallocate_one() - deallocated_lines.append(line) - return deallocated_lines + order = batch.deallocate_one() + deallocated_orders.append(order) + return deallocated_orders def __hash__(self) -> int: return hash(self.sku) diff --git a/app/allocation/routers/__init__.py b/app/allocation/entrypoints/__init__.py similarity index 100% rename from app/allocation/routers/__init__.py rename to app/allocation/entrypoints/__init__.py diff --git a/app/allocation/routers/dependencies.py b/app/allocation/entrypoints/dependencies.py similarity index 85% rename from app/allocation/routers/dependencies.py rename to app/allocation/entrypoints/dependencies.py index fe8bf68..3c71eef 100644 --- a/app/allocation/routers/dependencies.py +++ b/app/allocation/entrypoints/dependencies.py @@ -6,7 +6,7 @@ from app.allocation.adapters.db import DB from app.allocation.adapters.repository import AbstractProductRepository, PGProductRepository -from app.allocation.service_layer.unit_of_work import AbstractUnitOfWork, ProductUnitOfWork +from app.allocation.service_layer.unit_of_work import AbstractUnitOfWork, PGUnitOfWork from app.config import config @@ -26,5 +26,5 @@ def repository( return PGProductRepository(session) -def batch_uow() -> AbstractUnitOfWork[AbstractProductRepository]: - return ProductUnitOfWork() +def batch_uow() -> AbstractUnitOfWork: + return PGUnitOfWork() diff --git a/app/allocation/entrypoints/restapi.py b/app/allocation/entrypoints/restapi.py new file mode 100644 index 0000000..64108ff --- /dev/null +++ b/app/allocation/entrypoints/restapi.py @@ -0,0 +1,56 @@ +from datetime import date +from uuid import uuid4 + +from fastapi import Body, Depends, FastAPI, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.allocation.adapters import dao +from app.allocation.adapters.dto import Allocation +from app.allocation.adapters.orm import start_mappers +from app.allocation.domain import commands +from app.allocation.entrypoints.dependencies import batch_uow, session +from app.allocation.service_layer import handlers +from app.allocation.service_layer.handlers import InvalidSku +from app.allocation.service_layer.unit_of_work import AbstractUnitOfWork + +app = FastAPI() +start_mappers() + + +@app.get("/") +async def root() -> dict[str, str]: + return {"message": "Hello World"} + + +@app.post("/batches", status_code=201) +async def add_batch( + sku: str = Body(), + quantity: int = Body(), + eta: date = Body(default=None), + uow: AbstractUnitOfWork = Depends(batch_uow), +) -> dict[str, str]: + cmd = commands.CreateBatch(uuid4(), sku, quantity, eta) + await handlers.CreateBatchCmdHandler(uow).handle(cmd) + return {"batch_id": str(cmd.id)} + + +@app.post("/allocate", response_model=dict[str, str], status_code=201) +async def allocate( + sku: str = Body(), + quantity: int = Body(), + uow: AbstractUnitOfWork = Depends(batch_uow), +) -> dict[str, str]: + try: + cmd = commands.Allocate(uuid4(), sku, quantity) + batch_id = await handlers.AllocateCmdHandler(uow).handle(cmd) + except InvalidSku as e: + raise HTTPException(status_code=400, detail=str(e)) + return {"batch_id": str(batch_id)} + + +@app.get("/allocations/{sku}", status_code=200) +async def allocations_view_endpoint(sku: str, session: AsyncSession = Depends(session)) -> list[Allocation]: + result = await dao.allocations(sku, session) + if not result: + raise HTTPException(status_code=404, detail="not found") + return result diff --git a/app/allocation/entrypoints/run_worker.py b/app/allocation/entrypoints/run_worker.py new file mode 100644 index 0000000..42a9900 --- /dev/null +++ b/app/allocation/entrypoints/run_worker.py @@ -0,0 +1,71 @@ +import asyncio +from uuid import UUID + +import orjson +import sqlalchemy as sa + +from app.allocation.adapters.db import DB +from app.allocation.adapters.orm import start_mappers +from app.allocation.adapters.redis import redis +from app.allocation.constants import BATCH_QUANTITY_CHANGED_CHANNEL, ORDER_ALLOCATED_CHANNEL, ORDER_DEALLOCATED_CHANNEL +from app.allocation.domain import commands, events +from app.allocation.service_layer import handlers, unit_of_work +from app.config import config + +start_mappers() +db = DB(config.PG_DSN) + +# TODO: prevent event loss +# TODO: use same transaction, or make idempotent +async def main() -> None: + pubsub = redis.pubsub(ignore_subscribe_messages=True) + await pubsub.subscribe(BATCH_QUANTITY_CHANGED_CHANNEL, ORDER_ALLOCATED_CHANNEL, ORDER_DEALLOCATED_CHANNEL) + + async for m in pubsub.listen(): + channel = m["channel"].decode("utf-8") + data = orjson.loads(m["data"]) + if channel == BATCH_QUANTITY_CHANGED_CHANNEL: + await handlers.ChangeBatchQuantityCmdHandler(unit_of_work.PGUnitOfWork()).handle( + commands.ChangeBatchQuantity(id=UUID(data["id"]), qty=data["qty"]), + ) + elif channel == ORDER_ALLOCATED_CHANNEL: + await _add_allocation_to_read_model( + events.Allocated( + order_id=UUID(data["order_id"]), sku=data["sku"], qty=data["qty"], batch_id=UUID(data["batch_id"]) + ), + ) + elif channel == ORDER_DEALLOCATED_CHANNEL: + event = events.Deallocated(order_id=UUID(data["order_id"]), sku=data["sku"], qty=data["qty"]) + await _remove_allocation_from_read_model(event) + await _remove_order(event) + await handlers.AllocateCmdHandler(unit_of_work.PGUnitOfWork()).handle( + commands.Allocate(order_id=event.order_id, sku=event.sku, qty=event.qty) + ) + + +async def _add_allocation_to_read_model(event: events.Allocated) -> None: + async with db.session() as session: + await session.execute( + sa.text("INSERT INTO allocations_view (order_id, sku, batch_id) VALUES (:order_id, :sku, :batch_id)"), + dict(order_id=event.order_id, sku=event.sku, batch_id=event.batch_id), + ) + + +async def _remove_order(event: events.Deallocated) -> None: + async with db.session() as session: + await session.execute( + sa.text('DELETE FROM "order" WHERE id = :order_id'), + dict(order_id=event.order_id), + ) + + +async def _remove_allocation_from_read_model(event: events.Deallocated) -> None: + async with db.session() as session: + await session.execute( + sa.text("DELETE FROM allocations_view WHERE order_id = :order_id AND sku = :sku"), + dict(order_id=event.order_id, sku=event.sku), + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/app/allocation/routers/api.py b/app/allocation/routers/api.py deleted file mode 100644 index 04268df..0000000 --- a/app/allocation/routers/api.py +++ /dev/null @@ -1,48 +0,0 @@ -from datetime import date -from uuid import UUID - -from fastapi import Body, Depends, FastAPI, HTTPException - -from app.allocation.adapters.orm import start_mappers -from app.allocation.adapters.repository import AbstractProductRepository -from app.allocation.domain import commands -from app.allocation.routers.dependencies import batch_uow -from app.allocation.service_layer import messagebus -from app.allocation.service_layer.handlers import InvalidSku -from app.allocation.service_layer.unit_of_work import AbstractUnitOfWork - -app = FastAPI() -start_mappers() - - -@app.get("/") -async def root() -> dict[str, str]: - return {"message": "Hello World"} - - -@app.post("/batches", status_code=201) -async def add_batch( - batch_id: UUID = Body(), - sku: str = Body(), - quantity: int = Body(), - eta: date = Body(default=None), - uow: AbstractUnitOfWork[AbstractProductRepository] = Depends(batch_uow), -) -> dict[str, str]: - cmd = commands.CreateBatch(batch_id, sku, quantity, eta) - await messagebus.handle(cmd, uow) - return {"message": "success"} - - -@app.post("/allocate", response_model=dict[str, str], status_code=201) -async def allocate( - sku: str = Body(), - quantity: int = Body(), - uow: AbstractUnitOfWork[AbstractProductRepository] = Depends(batch_uow), -) -> dict[str, str]: - try: - cmd = commands.Allocate(sku, quantity) - results = await messagebus.handle(cmd, uow) - batch_id = results[0] - except InvalidSku as e: - raise HTTPException(status_code=400, detail=str(e)) - return {"batch_id": str(batch_id)} diff --git a/app/allocation/routers/worker.py b/app/allocation/routers/worker.py deleted file mode 100644 index 6007270..0000000 --- a/app/allocation/routers/worker.py +++ /dev/null @@ -1,30 +0,0 @@ -import asyncio -from uuid import UUID - -import orjson - -from app.allocation.adapters.orm import start_mappers -from app.allocation.adapters.redis import redis -from app.allocation.constants import BATCH_QUANTITY_CHANGED_CHANNEL -from app.allocation.domain import commands -from app.allocation.service_layer import messagebus, unit_of_work - -start_mappers() - - -async def main() -> None: - pubsub = redis.pubsub(ignore_subscribe_messages=True) - await pubsub.subscribe(BATCH_QUANTITY_CHANGED_CHANNEL) - - async for m in pubsub.listen(): - channel = m["channel"].decode("utf-8") - data = orjson.loads(m["data"]) - if channel == BATCH_QUANTITY_CHANGED_CHANNEL: - await messagebus.handle( - commands.ChangeBatchQuantity(id=UUID(data["id"]), qty=data["qty"]), - uow=unit_of_work.ProductUnitOfWork(), - ) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/app/allocation/service_layer/handlers.py b/app/allocation/service_layer/handlers.py index b1fa795..0e63927 100644 --- a/app/allocation/service_layer/handlers.py +++ b/app/allocation/service_layer/handlers.py @@ -1,12 +1,11 @@ from typing import Protocol, TypeVar -from uuid import UUID, uuid4 +from uuid import UUID import orjson from app.allocation.adapters import email from app.allocation.adapters.redis import redis -from app.allocation.adapters.repository import AbstractProductRepository -from app.allocation.constants import LINE_ALLOCATED_CHANNEL +from app.allocation.constants import ORDER_ALLOCATED_CHANNEL, ORDER_DEALLOCATED_CHANNEL from app.allocation.domain import commands, events, models from app.allocation.service_layer import unit_of_work @@ -25,15 +24,15 @@ async def handle(self, cmd: P) -> R: class CreateBatchCmdHandler(Handler[commands.CreateBatch, models.Batch]): - def __init__(self, uow: unit_of_work.AbstractUnitOfWork[AbstractProductRepository]) -> None: + def __init__(self, uow: unit_of_work.AbstractUnitOfWork) -> None: self._uow = uow async def handle(self, cmd: commands.CreateBatch) -> models.Batch: async with self._uow: - product = await self._uow.repo.get(cmd.sku) + product = await self._uow.products.get(cmd.sku) if product is None: product = models.Product(sku=cmd.sku, batches=[]) - await self._uow.repo.add(product) + await self._uow.products.add(product) batch = models.Batch(id=cmd.id, sku=cmd.sku, qty=cmd.qty, eta=cmd.eta) product.batches.append(batch) await self._uow.commit() @@ -41,20 +40,20 @@ async def handle(self, cmd: commands.CreateBatch) -> models.Batch: class AllocateCmdHandler(Handler[commands.Allocate, UUID]): - def __init__(self, uow: unit_of_work.AbstractUnitOfWork[AbstractProductRepository]) -> None: + def __init__(self, uow: unit_of_work.AbstractUnitOfWork) -> None: self._uow = uow async def handle(self, cmd: commands.Allocate) -> UUID: - line = models.OrderLine(id=uuid4(), sku=cmd.sku, qty=cmd.qty) + order = models.Order(id=cmd.order_id, sku=cmd.sku, qty=cmd.qty) async with self._uow: - product = await self._uow.repo.get(line.sku) + product = await self._uow.products.get(order.sku) if product is None: - raise InvalidSku(f"Invalid sku {line.sku}") - batch_id = product.allocate(line) + raise InvalidSku(f"Invalid sku {order.sku}") + batch_id = product.allocate(order) if batch_id is None: - self._send_email(events.OutOfStock(line.sku)) + self._send_email(events.OutOfStock(order.sku)) else: - await self._publish(events.Allocated(line.id, line.sku, line.qty, batch_id)) + await self._publish(events.Allocated(order.id, order.sku, order.qty, batch_id)) await self._uow.commit() return batch_id @@ -63,7 +62,7 @@ def _send_email(self, event: events.OutOfStock) -> None: async def _publish(self, event: events.Allocated) -> None: await redis.publish( - LINE_ALLOCATED_CHANNEL, + ORDER_ALLOCATED_CHANNEL, orjson.dumps( dict( order_id=str(event.order_id), @@ -75,14 +74,24 @@ async def _publish(self, event: events.Allocated) -> None: ) -class ChangeBatchQuantityCmdHandler(Handler[commands.ChangeBatchQuantity, list[commands.Allocate]]): - def __init__(self, uow: unit_of_work.AbstractUnitOfWork[AbstractProductRepository]) -> None: +class ChangeBatchQuantityCmdHandler(Handler[commands.ChangeBatchQuantity, None]): + def __init__(self, uow: unit_of_work.AbstractUnitOfWork) -> None: self._uow = uow - async def handle(self, cmd: commands.ChangeBatchQuantity) -> list[commands.Allocate]: + async def handle(self, cmd: commands.ChangeBatchQuantity) -> None: async with self._uow: - product = await self._uow.repo.get_by_batch_id(cmd.id) - lines = product.change_batch_quantity(cmd.id, cmd.qty) - results = [commands.Allocate(line.sku, line.qty) for line in lines] + product = await self._uow.products.get_by_batch_id(cmd.id) + orders = product.change_batch_quantity(cmd.id, cmd.qty) + deallocated_events = [events.Deallocated(order.id, order.sku, order.qty) for order in orders] + if deallocated_events: + await self._publish(deallocated_events) await self._uow.commit() - return results + + async def _publish(self, event: list[events.Deallocated]) -> None: + pipe = redis.pipeline() + for e in event: + pipe.publish( + ORDER_DEALLOCATED_CHANNEL, + orjson.dumps(dict(order_id=str(e.order_id), sku=e.sku, qty=e.qty)), + ) + await pipe.execute() diff --git a/app/allocation/service_layer/messagebus.py b/app/allocation/service_layer/messagebus.py deleted file mode 100644 index 4998228..0000000 --- a/app/allocation/service_layer/messagebus.py +++ /dev/null @@ -1,23 +0,0 @@ -from typing import Any - -from app.allocation.domain import commands -from app.allocation.service_layer import handlers, unit_of_work - - -async def handle( - message: commands.Command, - uow: unit_of_work.AbstractUnitOfWork[unit_of_work.AbstractProductRepository], -) -> list[Any]: - results: list[Any] = [] - messages = [message] - while messages: - message = messages.pop(0) - if isinstance(message, commands.CreateBatch): - results.append(await handlers.CreateBatchCmdHandler(uow).handle(message)) - elif isinstance(message, commands.Allocate): - results.append(await handlers.AllocateCmdHandler(uow).handle(message)) - elif isinstance(message, commands.ChangeBatchQuantity): - messages.extend(await handlers.ChangeBatchQuantityCmdHandler(uow).handle(message)) - else: - raise Exception(f"Unknown message {message}") - return results diff --git a/app/allocation/service_layer/unit_of_work.py b/app/allocation/service_layer/unit_of_work.py index 690d605..d2dff4b 100644 --- a/app/allocation/service_layer/unit_of_work.py +++ b/app/allocation/service_layer/unit_of_work.py @@ -1,26 +1,23 @@ from __future__ import annotations import abc -from typing import Any, Generic, TypeVar +from typing import Any -from app.allocation.adapters.db import SESSION_FACTORY -from app.allocation.adapters.repository import ( - AbstractProductRepository, - PGProductRepository, -) +from sqlalchemy.ext.asyncio import AsyncSession -Repo = TypeVar("Repo", bound=AbstractProductRepository) +from app.allocation.adapters.db import SESSION_FACTORY, async_scoped_session +from app.allocation.adapters.repository import AbstractProductRepository, PGProductRepository -class AbstractUnitOfWork(abc.ABC, Generic[Repo]): - async def __aenter__(self) -> AbstractUnitOfWork[Repo]: +class AbstractUnitOfWork(abc.ABC): + async def __aenter__(self) -> AbstractUnitOfWork: return self async def __aexit__(self, *args: Any) -> None: await self.rollback() @abc.abstractproperty - def repo(self) -> Repo: + def products(self) -> AbstractProductRepository: raise NotImplementedError @abc.abstractmethod @@ -32,19 +29,27 @@ async def rollback(self) -> None: raise NotImplementedError -class ProductUnitOfWork(AbstractUnitOfWork[AbstractProductRepository]): - @property - def repo(self) -> AbstractProductRepository: - return self._repo +class PGUnitOfWork(AbstractUnitOfWork): + def __init__(self) -> None: + self._session_factory: async_scoped_session = None + self._session: AsyncSession = None + self._products: PGProductRepository = None - async def __aenter__(self) -> AbstractUnitOfWork[AbstractProductRepository]: - self._session = SESSION_FACTORY() - self._repo = PGProductRepository(self._session) + @property + def products(self) -> AbstractProductRepository: + return self._products + + async def __aenter__(self) -> AbstractUnitOfWork: + # put below code to __init__, and then run test it will raise error "no event loop" + # because async client do not create event loop at depends level + self._session_factory = SESSION_FACTORY + self._session = self._session_factory() + self._products = PGProductRepository(self._session) return await super().__aenter__() async def __aexit__(self, *args: Any) -> None: await super().__aexit__(*args) - await SESSION_FACTORY.remove() + await self._session_factory.remove() async def commit(self) -> None: await self._session.commit() diff --git a/pyproject.toml b/pyproject.toml index bf18d2e..f0a3a92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.ruff] -line-length = 100 +line-length = 120 fix = true [tool.ruff.isort] @@ -45,7 +45,7 @@ known-first-party = ["app"] "__init__.py" = ["E402"] [tool.black] -line-length = 100 +line-length = 120 [tool.mypy] python_version = "3.10" diff --git a/scripts/start-dev.sh b/scripts/start-dev.sh index e79ba56..a59520f 100755 --- a/scripts/start-dev.sh +++ b/scripts/start-dev.sh @@ -4,4 +4,4 @@ set -e poetry install --sync -uvicorn app.allocation.routers.api:app --reload --host 0.0.0.0 --port 8000 \ No newline at end of file +uvicorn app.allocation.entrypoints.api:app --reload --host 0.0.0.0 --port 8000 \ No newline at end of file diff --git a/scripts/start-worker.sh b/scripts/start-worker.sh index 815a01b..6e59be8 100644 --- a/scripts/start-worker.sh +++ b/scripts/start-worker.sh @@ -4,4 +4,4 @@ set -e poetry install --sync -python app/allocation/routers/worker.py \ No newline at end of file +python app/allocation/entrypoints/run_worker.py \ No newline at end of file diff --git a/scripts/sync-env.py b/scripts/sync-env.py deleted file mode 100755 index 578ab87..0000000 --- a/scripts/sync-env.py +++ /dev/null @@ -1,38 +0,0 @@ -# Use this code snippet in your app. -# If you need more information about configurations -# or implementing the sample code, visit the AWS docs: -# https://aws.amazon.com/developer/language/python/ - -import json - -import boto3 -from botocore.exceptions import ClientError - - -def get_secret() -> None: - - secret_name = "secret-name" - region_name = "ap-northeast-2" - - # Create a Secrets Manager client - session = boto3.session.Session() - client = session.client(service_name="secretsmanager", region_name=region_name) - - try: - get_secret_value_response = client.get_secret_value(SecretId=secret_name) - except ClientError as e: - # For a list of exceptions thrown, see - # https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html - raise e - - # Decrypts secret using the associated KMS key. - secret = get_secret_value_response["SecretString"] - - # Your code goes here. - - with open("./secrets/.env", "w") as f: - for k, v in json.loads(secret).items(): - f.write(f"{k}={v}\n") - - -get_secret() diff --git a/tests/conftest.py b/tests/conftest.py index fccc0d9..d417b91 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,12 +5,9 @@ import pytest from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine -from app.allocation.adapters.db import DB from app.allocation.adapters.orm import metadata from app.config import config -db = DB(config.PG_DSN) - @pytest.fixture(scope="session") def event_loop() -> Generator[Any, Any, Any]: @@ -20,7 +17,7 @@ def event_loop() -> Generator[Any, Any, Any]: loop.close() -@pytest.fixture +@pytest.fixture(scope="session") async def engine() -> AsyncGenerator[AsyncEngine, None]: engine = create_async_engine(config.PG_DSN) async with engine.begin() as conn: diff --git a/tests/e2e/test_api.py b/tests/e2e/test_api.py index 1f2e361..10a3e4d 100644 --- a/tests/e2e/test_api.py +++ b/tests/e2e/test_api.py @@ -1,3 +1,4 @@ +import asyncio from collections.abc import AsyncGenerator from datetime import date from typing import Any @@ -10,7 +11,7 @@ from sqlalchemy.orm import sessionmaker from app.allocation.adapters.orm import metadata -from app.allocation.routers.api import app +from app.allocation.entrypoints.restapi import app @pytest.fixture @@ -31,39 +32,29 @@ async def session(engine: AsyncEngine) -> AsyncGenerator[AsyncSession, Any]: @pytest.fixture(autouse=True) -async def clear_db(session: AsyncSession) -> AsyncGenerator[Any, Any]: - yield session - for table in reversed(metadata.sorted_tables): - await session.execute(table.delete()) +async def clear_db(engine: AsyncEngine) -> AsyncGenerator[AsyncEngine, Any]: + yield engine + async with engine.begin() as conn: + for table in reversed(metadata.sorted_tables): + await conn.execute(table.delete()) async def test_add_batch_returns_201(client: AsyncClient, session: AsyncSession) -> None: # Given - await session.execute( - sa.text("INSERT INTO products (sku, version_number) VALUES " "('SKU', 1)") - ) + await session.execute(sa.text("INSERT INTO product (sku, version_number) VALUES " "('SKU', 1)")) await session.commit() # When - res = await client.post( - "/batches", json={"batch_id": str(uuid4()), "sku": "SKU", "quantity": 3} - ) + res = await client.post("/batches", json={"sku": "SKU", "quantity": 3}) # Then assert res.status_code == 201 - assert res.json() == {"message": "success"} -async def test_allocate_api_returns_201_and_allocated_batch( - session: AsyncSession, client: AsyncClient -) -> None: +async def test_allocate_api_returns_201_and_order_is_allocated(session: AsyncSession, client: AsyncClient) -> None: # Given: create 3 batches with different eta. 2 batches have same sku - await session.execute( - sa.text("INSERT INTO products (sku, version_number) VALUES " "('SKU', 1)") - ) - await session.execute( - sa.text("INSERT INTO products (sku, version_number) VALUES " "('OTHER-SKU', 1)") - ) + await session.execute(sa.text("INSERT INTO product (sku, version_number) VALUES " "('SKU', 1)")) + await session.execute(sa.text("INSERT INTO product (sku, version_number) VALUES " "('OTHER-SKU', 1)")) await session.commit() batches = [ (UUID("d236f2aa-8f61-4aeb-9cbd-eade21736457"), "SKU", 100, date(2011, 1, 2)), @@ -78,11 +69,10 @@ async def test_allocate_api_returns_201_and_allocated_batch( await session.commit() # When - res = await client.post( - "/allocate", json={"line_id": str(uuid4()), "sku": "SKU", "quantity": 3} - ) + res = await client.post("/allocate", json={"sku": "SKU", "quantity": 3}) + await asyncio.sleep(0.1) # wait for worker get event - # Then: order line is allocated to the batch with earliest eta, and status code 201 + # Then: order is allocated to the batch with earliest eta, and status code 201 assert res.status_code == 201 assert res.json() == {"batch_id": "f6e16413-441e-40c0-b2eb-e826b080b448"} @@ -90,11 +80,12 @@ async def test_allocate_api_returns_201_and_allocated_batch( async def test_allocate_api_returns_400_and_error_message_if_invalid_sku( client: AsyncClient, ) -> None: + # Given + order_id = uuid4() + # When: request with invalid sku - res = await client.post( - "/allocate", json={"line_id": str(uuid4()), "sku": "NOT-EXIST-SKU", "quantity": 3} - ) + res1 = await client.post("/allocate", json={"order_id": str(order_id), "sku": "NOT-EXIST-SKU", "quantity": 3}) # Then: status code 400 and error message - assert res.status_code == 400 - assert res.json() == {"detail": "Invalid sku NOT-EXIST-SKU"} + assert res1.status_code == 400 + assert res1.json() == {"detail": "Invalid sku NOT-EXIST-SKU"} diff --git a/tests/e2e/test_external_events.py b/tests/e2e/test_external_events.py deleted file mode 100644 index d35fd4b..0000000 --- a/tests/e2e/test_external_events.py +++ /dev/null @@ -1,74 +0,0 @@ -from collections.abc import AsyncGenerator -from typing import Any -from uuid import uuid4 - -import orjson -import pytest -from httpx import AsyncClient -from sqlalchemy.ext.asyncio import AsyncEngine - -from app.allocation.adapters.redis import Redis -from app.allocation.constants import BATCH_QUANTITY_CHANGED_CHANNEL, LINE_ALLOCATED_CHANNEL -from app.allocation.routers.api import app -from app.config import config - - -@pytest.fixture -async def client() -> AsyncGenerator[AsyncClient, Any]: - async with AsyncClient( - app=app, - base_url="http://testserver", - headers={"Content-Type": "application/json"}, - ) as client: - yield client - - -@pytest.fixture -async def rc() -> AsyncGenerator[Redis, None]: - rc = Redis.from_url(config.REDIS_DSN) - yield rc - await rc.flushall() - - -async def test_change_batch_quantity_leading_to_reallocation( - client: AsyncClient, rc: Redis, engine: AsyncEngine -) -> None: - # Given - await client.post( - "/batches", - json={ - "batch_id": "96a3b8cd-f2db-481f-894b-60d6c6bc3c42", - "sku": "SKU", - "quantity": 10, - "eta": "2021-01-01", - }, - ) - await client.post( - "/batches", - json={ - "batch_id": "5a63ca86-94e0-4146-8e72-6d79cf3ae0c2", - "sku": "SKU", - "quantity": 10, - "eta": "2021-01-02", - }, - ) - res = await client.post( - "/allocate", json={"line_id": str(uuid4()), "sku": "SKU", "quantity": 10} - ) - assert res.json() == {"batch_id": "96a3b8cd-f2db-481f-894b-60d6c6bc3c42"} - - pubsub = rc.pubsub() - await pubsub.subscribe(LINE_ALLOCATED_CHANNEL) - confirmation = await pubsub.get_message(timeout=1) - assert confirmation["type"] == "subscribe" - - # When - await rc.publish( - BATCH_QUANTITY_CHANGED_CHANNEL, - orjson.dumps({"id": "96a3b8cd-f2db-481f-894b-60d6c6bc3c42", "qty": 5}), - ) - - # Then - message = await pubsub.get_message(timeout=1) - data = orjson.loads(message["data"]) - assert data["batch_id"] == "5a63ca86-94e0-4146-8e72-6d79cf3ae0c2" diff --git a/tests/e2e/test_worker.py b/tests/e2e/test_worker.py new file mode 100644 index 0000000..636e97d --- /dev/null +++ b/tests/e2e/test_worker.py @@ -0,0 +1,120 @@ +from collections.abc import AsyncGenerator +from typing import Any + +import orjson +import pytest +from httpx import AsyncClient +from redis.asyncio.client import PubSub +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession +from sqlalchemy.orm import sessionmaker + +from app.allocation.adapters.orm import metadata +from app.allocation.adapters.redis import Redis +from app.allocation.constants import BATCH_QUANTITY_CHANGED_CHANNEL, ORDER_ALLOCATED_CHANNEL, ORDER_DEALLOCATED_CHANNEL +from app.allocation.entrypoints.restapi import app +from app.config import config + + +@pytest.fixture +async def client() -> AsyncGenerator[AsyncClient, Any]: + async with AsyncClient( + app=app, + base_url="http://testserver", + headers={"Content-Type": "application/json"}, + ) as client: + yield client + + +@pytest.fixture +async def rc() -> AsyncGenerator[Redis, None]: + rc = Redis.from_url(config.REDIS_DSN) + yield rc + await rc.flushall() + + +@pytest.fixture +async def session(engine: AsyncEngine) -> AsyncGenerator[AsyncSession, Any]: + session = sessionmaker(bind=engine, class_=AsyncSession)() + yield session + await session.close() + + +@pytest.fixture(autouse=True) +async def clear_db(engine: AsyncEngine) -> AsyncGenerator[AsyncEngine, Any]: + yield engine + async with engine.begin() as conn: + for table in reversed(metadata.sorted_tables): + await conn.execute(table.delete()) + + +async def test_allocate_leading_to_add_row_to_allocations_view(client: AsyncClient) -> None: + # Given: create batch + earlist_batch_id, _ = await _create_two_batches(client) + + # When: allocate order + allocate_res = await client.post("/allocate", json={"sku": "SKU", "quantity": 10}) + view_res = await client.get("/allocations/SKU") + + # Then: order is allocated. and allocations_view returns the allocation + assert allocate_res.status_code == 201 + assert allocate_res.json()["batch_id"] == earlist_batch_id + assert view_res.status_code == 200 + assert view_res.json()[0]["sku"] == "SKU" + assert view_res.json()[0]["batch_id"] == earlist_batch_id + assert view_res.json()[0]["qty"] == 10 + + +async def test_change_batch_quantity_leading_to_reallocation(client: AsyncClient, rc: Redis) -> None: + # Given: create batch and subscribe redis channels + earlist_batch_id, latest_batch_id = await _create_two_batches(client) + await client.post("/allocate", json={"sku": "SKU", "quantity": 10}) + pubsub = await _subscribe(rc) + + # When + await rc.publish( + BATCH_QUANTITY_CHANGED_CHANNEL, + orjson.dumps({"id": earlist_batch_id, "qty": 5}), + ) + + # Then + message1 = await pubsub.get_message(timeout=1) + message2 = await pubsub.get_message(timeout=1) + data1 = orjson.loads(message1["data"]) + assert message1["channel"].decode() == ORDER_DEALLOCATED_CHANNEL + assert data1["sku"] == "SKU" + assert data1["qty"] == 10 + data2 = orjson.loads(message2["data"]) + assert message2["channel"].decode() == ORDER_ALLOCATED_CHANNEL + assert data2["sku"] == "SKU" + assert data2["qty"] == 10 + assert data2["batch_id"] == latest_batch_id + + +async def _subscribe(rc: Redis) -> PubSub: + pubsub = rc.pubsub() + await pubsub.subscribe(ORDER_ALLOCATED_CHANNEL, ORDER_DEALLOCATED_CHANNEL) + confirmation1 = await pubsub.get_message(timeout=1) + confirmation2 = await pubsub.get_message(timeout=1) + assert confirmation1["type"] == "subscribe" + assert confirmation2["type"] == "subscribe" + return pubsub + + +async def _create_two_batches(client: AsyncClient) -> tuple[str, str]: + res1 = await client.post( + "/batches", + json={ + "sku": "SKU", + "quantity": 10, + "eta": "2021-01-01", + }, + ) + res2 = await client.post( + "/batches", + json={ + "sku": "SKU", + "quantity": 10, + "eta": "2021-01-02", + }, + ) + return res1.json()["batch_id"], res2.json()["batch_id"] diff --git a/tests/integration/test_dao.py b/tests/integration/test_dao.py new file mode 100644 index 0000000..25a1ec9 --- /dev/null +++ b/tests/integration/test_dao.py @@ -0,0 +1,48 @@ +from uuid import UUID + +import sqlalchemy as sa +from sqlalchemy.ext.asyncio import AsyncSession + +from app.allocation.adapters import dao +from app.allocation.adapters.dto import Allocation + + +async def test_allocations_view(session: AsyncSession) -> None: + # Given + await session.execute( + sa.text("INSERT INTO allocations_view (order_id, sku, batch_id) VALUES (:order_id, :sku, :batch_id)"), + dict( + order_id=UUID("b0764de0-5348-47b0-9895-6dabce8e093f"), + sku="sku1", + batch_id=UUID("5ed8a924-d4d7-41c4-af06-0bb85248ed6b"), + ), + ) + await session.execute( + sa.text('INSERT INTO "order" (id, sku, qty) VALUES (:order_id, :sku, :qty)'), + dict(order_id=UUID("b0764de0-5348-47b0-9895-6dabce8e093f"), sku="sku1", qty=22), + ) + await session.execute( + sa.text("INSERT INTO allocations_view (order_id, sku, batch_id) VALUES (:order_id, :sku, :batch_id)"), + dict( + order_id=UUID("4f7e2d27-f2f7-4741-927b-02d1384a1d2c"), + sku="sku2", + batch_id=UUID("450fac40-d02c-43a4-b9cb-f63d75c0a2e6"), + ), + ) + await session.execute( + sa.text('INSERT INTO "order" (id, sku, qty) VALUES (:order_id, :sku, :qty)'), + dict(order_id=UUID("4f7e2d27-f2f7-4741-927b-02d1384a1d2c"), sku="sku2", qty=33), + ) + + # When + result = await dao.allocations("sku1", session) + + # Then + assert result == [ + Allocation( + order_id=UUID("b0764de0-5348-47b0-9895-6dabce8e093f"), + sku="sku1", + qty=22, + batch_id=UUID("5ed8a924-d4d7-41c4-af06-0bb85248ed6b"), + ) + ] diff --git a/tests/integration/test_handler.py b/tests/integration/test_handler.py new file mode 100644 index 0000000..5b6b7df --- /dev/null +++ b/tests/integration/test_handler.py @@ -0,0 +1,160 @@ +# pylint: disable=no-self-use +from datetime import date +from typing import Any +from collections.abc import AsyncGenerator +from unittest import mock +from uuid import UUID, uuid4 + +import pytest +from pytest_mock import MockerFixture +from sqlalchemy.ext.asyncio import AsyncEngine + +from app.allocation.adapters.orm import metadata +from app.allocation.domain import commands +from app.allocation.service_layer import handlers, unit_of_work + + +@pytest.fixture(autouse=True) +async def clear_db(engine: AsyncEngine) -> AsyncGenerator[AsyncEngine, Any]: + # TODO: session and function tests pass, but module tests fail due to start_mappers issue. + yield engine + async with engine.begin() as conn: + for table in reversed(metadata.sorted_tables): + await conn.execute(table.delete()) + + +class TestAddBatch: + async def test_for_new_product(self) -> None: + uow = unit_of_work.PGUnitOfWork() + await handlers.CreateBatchCmdHandler(uow).handle(commands.CreateBatch(uuid4(), "CRUNCHY-ARMCHAIR", 100)) + async with uow: + assert await uow.products.get("CRUNCHY-ARMCHAIR") is not None + + async def test_for_existing_product(self) -> None: + uow = unit_of_work.PGUnitOfWork() + + await handlers.CreateBatchCmdHandler(uow).handle( + commands.CreateBatch(UUID("5103f447-4568-4615-bc28-70447d1a7436"), "GARISH-RUG", 100) + ) + await handlers.CreateBatchCmdHandler(uow).handle( + commands.CreateBatch(UUID("6c381ae2-c9fc-4b52-aacb-3e496f0aacef"), "GARISH-RUG", 99) + ) + + async with uow: + assert UUID("5103f447-4568-4615-bc28-70447d1a7436") in [ + b.id for b in (await uow.products.get("GARISH-RUG")).batches + ] + assert UUID("6c381ae2-c9fc-4b52-aacb-3e496f0aacef") in [ + b.id for b in (await uow.products.get("GARISH-RUG")).batches + ] + + +class TestAllocate: + async def test_returns_allocation(self, mocker: MockerFixture) -> None: + uow = unit_of_work.PGUnitOfWork() + mock_redis = mocker.patch("app.allocation.service_layer.handlers.redis", return_value=mock.AsyncMock) + mock_redis.attach_mock(mock_redis, "publish") + + await handlers.CreateBatchCmdHandler(uow).handle( + commands.CreateBatch(UUID("b4cf5213-6e1f-46cc-8302-aac1f12ac617"), "COMPLICATED-LAMP", 100) + ) + batch_id = await handlers.AllocateCmdHandler(uow).handle( + commands.Allocate(UUID("c3370153-5d1c-4059-9a2a-4a39267afc27"), "COMPLICATED-LAMP", 10) + ) + + async with uow: + assert mock_redis.call_args == mock.call( + "allocation:order_allocated:v1", + b'{"order_id":"c3370153-5d1c-4059-9a2a-4a39267afc27","sku":"COMPLICATED-LAMP","qty":10,"batch_id":"b4cf5213-6e1f-46cc-8302-aac1f12ac617"}', + ) + assert batch_id == UUID("b4cf5213-6e1f-46cc-8302-aac1f12ac617") + + async def test_errors_for_invalid_sku(self) -> None: + uow = unit_of_work.PGUnitOfWork() + with pytest.raises(handlers.InvalidSku, match="Invalid sku NONEXISTENTSKU"): + await handlers.AllocateCmdHandler(uow).handle(commands.Allocate(uuid4(), "NONEXISTENTSKU", 10)) + + async def test_allocate_handler_commit(self, mocker: MockerFixture) -> None: + uow = unit_of_work.PGUnitOfWork() + mock_redis = mocker.patch("app.allocation.service_layer.handlers.redis", return_value=mock.AsyncMock) + mock_redis.attach_mock(mock_redis, "publish") + + await handlers.CreateBatchCmdHandler(uow).handle( + commands.CreateBatch(UUID("0cf8c64c-efd3-4b18-994b-9254ee7c3c93"), "OMINOUS-MIRROR", 100, None) + ) + await handlers.AllocateCmdHandler(uow).handle( + commands.Allocate(UUID("1156164c-1ed1-4726-b315-5db7ac65ebb5"), "OMINOUS-MIRROR", 10) + ) + + assert mock_redis.call_args == mock.call( + "allocation:order_allocated:v1", + b'{"order_id":"1156164c-1ed1-4726-b315-5db7ac65ebb5","sku":"OMINOUS-MIRROR","qty":10,"batch_id":"0cf8c64c-efd3-4b18-994b-9254ee7c3c93"}', + ) + + async def test_sends_email_on_out_of_stock_error(self, mocker: MockerFixture) -> None: + uow = unit_of_work.PGUnitOfWork() + mock_send_mail = mocker.patch("app.allocation.adapters.email.send") + + await handlers.CreateBatchCmdHandler(uow).handle(commands.CreateBatch(uuid4(), "POPULAR-CURTAINS", 9, None)) + await handlers.AllocateCmdHandler(uow).handle(commands.Allocate(uuid4(), "POPULAR-CURTAINS", 10)) + + assert mock_send_mail.call_args == mock.call("stock@made.com", "Out of stock for POPULAR-CURTAINS") + + +class TestChangeBatchQuantity: + async def test_changes_available_quantity(self) -> None: + uow = unit_of_work.PGUnitOfWork() + await handlers.CreateBatchCmdHandler(uow).handle( + commands.CreateBatch(UUID("05e2c957-154b-4dcf-9d24-d172f26e4b12"), "ADORABLE-SETTEE", 100, None) + ) + + await handlers.ChangeBatchQuantityCmdHandler(uow).handle( + commands.ChangeBatchQuantity(UUID("05e2c957-154b-4dcf-9d24-d172f26e4b12"), 50) + ) + async with uow: + [batch] = (await uow.products.get(sku="ADORABLE-SETTEE")).batches + assert batch.available_quantity == 50 + + async def test_reallocates_if_necessary(self, mocker: MockerFixture) -> None: + # Given + uow = unit_of_work.PGUnitOfWork() + + await handlers.CreateBatchCmdHandler(uow).handle( + commands.CreateBatch(UUID("874c6d0d-84e6-4307-b9d5-e23ec78bb727"), "INDIFFERENT-TABLE", 50, None) + ) + await handlers.CreateBatchCmdHandler(uow).handle( + commands.CreateBatch(UUID("e9c6851c-e74a-40ac-8e86-3956f4762853"), "INDIFFERENT-TABLE", 50, date.today()) + ) + with mock.patch("app.allocation.service_layer.handlers.redis", return_value=mock.AsyncMock) as mock_redis: + mock_redis.attach_mock(mock_redis, "publish") + await handlers.AllocateCmdHandler(uow).handle( + commands.Allocate(UUID("57e0e250-93f2-4378-b44e-307838b4c367"), "INDIFFERENT-TABLE", 40) + ) + await handlers.AllocateCmdHandler(uow).handle( + commands.Allocate(UUID("1406c359-13b6-422d-8507-b24a0c763abd"), "INDIFFERENT-TABLE", 20) + ) + + async with uow: + [batch1, batch2] = (await uow.products.get(sku="INDIFFERENT-TABLE")).batches + assert batch1.available_quantity == 10 + assert batch2.available_quantity == 30 + + # When + def mock_publish(channel: str, message: bytes) -> None: + assert channel == "allocation:order_deallocated:v1" + assert message == b'{"order_id":"57e0e250-93f2-4378-b44e-307838b4c367","sku":"INDIFFERENT-TABLE","qty":40}' + + mock_pipeline = mock.AsyncMock() + mock_pipeline.publish = mock_publish + mocker.patch( + "app.allocation.service_layer.handlers.redis.pipeline", + return_value=mock_pipeline, + ) + await handlers.ChangeBatchQuantityCmdHandler(uow).handle( + commands.ChangeBatchQuantity(UUID("874c6d0d-84e6-4307-b9d5-e23ec78bb727"), 25) + ) + + # Then + async with uow: + [batch1, batch2] = (await uow.products.get(sku="INDIFFERENT-TABLE")).batches + assert batch2.available_quantity == 25 diff --git a/tests/integration/test_uow.py b/tests/integration/test_uow.py index a89ead1..64dddeb 100644 --- a/tests/integration/test_uow.py +++ b/tests/integration/test_uow.py @@ -10,7 +10,7 @@ from app.allocation.adapters.orm import metadata from app.allocation.domain import models -from app.allocation.service_layer.unit_of_work import ProductUnitOfWork +from app.allocation.service_layer.unit_of_work import PGUnitOfWork @pytest.fixture @@ -21,32 +21,31 @@ async def session(engine: AsyncEngine) -> AsyncGenerator[AsyncSession, Any]: @pytest.fixture(autouse=True) -async def clear_db(session: AsyncSession) -> AsyncGenerator[Any, Any]: - yield session - for table in reversed(metadata.sorted_tables): - await session.execute(table.delete()) +async def clear_db(engine: AsyncEngine) -> AsyncGenerator[AsyncEngine, Any]: + yield engine + async with engine.begin() as conn: + for table in reversed(metadata.sorted_tables): + await conn.execute(table.delete()) async def test_uow_can_save_product(session: AsyncSession) -> None: # Given - uow = ProductUnitOfWork() + uow = PGUnitOfWork() # When async with uow: product = models.Product(sku="RETRO-CLOCK", version_number=1, batches=[]) - await uow.repo.add(product) + await uow.products.add(product) await uow.commit() # Then - [[sku]] = await session.execute(sa.text("SELECT sku FROM products")) + [[sku]] = await session.execute(sa.text("SELECT sku FROM product")) assert sku == "RETRO-CLOCK" async def test_uow_can_retrieve_product_with_batch_and_allocations(session: AsyncSession) -> None: # Given - await session.execute( - sa.text("INSERT INTO products (sku, version_number) VALUES " "('RETRO-CLOCK', 1)") - ) + await session.execute(sa.text("INSERT INTO product (sku, version_number) VALUES ('RETRO-CLOCK', 1)")) await session.execute( sa.text( "INSERT INTO batch (id, sku, qty, eta) VALUES " @@ -54,13 +53,13 @@ async def test_uow_can_retrieve_product_with_batch_and_allocations(session: Asyn ) ) await session.commit() - uow = ProductUnitOfWork() + uow = PGUnitOfWork() # When async with uow: - product = await uow.repo.get("RETRO-CLOCK") - line = models.OrderLine(sku="RETRO-CLOCK", qty=10) - product.allocate(line) + product = await uow.products.get("RETRO-CLOCK") + order = models.Order(sku="RETRO-CLOCK", qty=10) + product.allocate(order) await uow.commit() # Then @@ -70,15 +69,13 @@ async def test_uow_can_retrieve_product_with_batch_and_allocations(session: Asyn async def test_rollback_uncommitted_work_by_default(session: AsyncSession) -> None: # Given - await session.execute( - sa.text("INSERT INTO products (sku, version_number) VALUES " "('RETRO-CLOCK', 1)") - ) + await session.execute(sa.text("INSERT INTO product (sku, version_number) VALUES " "('RETRO-CLOCK', 1)")) await session.commit() - uow = ProductUnitOfWork() + uow = PGUnitOfWork() # When async with uow: - product = await uow.repo.get("RETRO-CLOCK") + product = await uow.products.get("RETRO-CLOCK") batch = models.Batch(sku="RETRO-CLOCK", qty=100, eta=None) product.batches.append(batch) @@ -89,18 +86,16 @@ async def test_rollback_uncommitted_work_by_default(session: AsyncSession) -> No async def test_rollsback_on_error(session: AsyncSession) -> None: # Given - await session.execute( - sa.text("INSERT INTO products (sku, version_number) VALUES " "('RETRO-CLOCK', 1)") - ) + await session.execute(sa.text("INSERT INTO product (sku, version_number) VALUES " "('RETRO-CLOCK', 1)")) await session.commit() class MyException(Exception): pass - uow = ProductUnitOfWork() + uow = PGUnitOfWork() with pytest.raises(MyException): async with uow: - product = await uow.repo.get("RETRO-CLOCK") + product = await uow.products.get("RETRO-CLOCK") batch = models.Batch(sku="RETRO-CLOCK", qty=100, eta=None) product.batches.append(batch) raise MyException() @@ -110,9 +105,7 @@ class MyException(Exception): async def test_concurrent_updates_to_version_are_not_allowed(session: AsyncSession) -> None: - await session.execute( - sa.text("INSERT INTO products (sku, version_number) VALUES " "('RETRO-CLOCK', 1)") - ) + await session.execute(sa.text("INSERT INTO product (sku, version_number) VALUES " "('RETRO-CLOCK', 1)")) await session.execute( sa.text( "INSERT INTO batch (id, sku, qty, eta) VALUES " @@ -121,14 +114,14 @@ async def test_concurrent_updates_to_version_are_not_allowed(session: AsyncSessi ) await session.commit() - order1 = models.OrderLine(sku="RETRO-CLOCK", qty=3) - order2 = models.OrderLine(sku="RETRO-CLOCK", qty=7) + order1 = models.Order(sku="RETRO-CLOCK", qty=3) + order2 = models.Order(sku="RETRO-CLOCK", qty=7) exceptions = [] - async def allocate(order: models.OrderLine) -> None: + async def allocate(order: models.Order) -> None: try: - async with ProductUnitOfWork() as uow: - product = await uow.repo.get("RETRO-CLOCK") + async with PGUnitOfWork() as uow: + product = await uow.products.get("RETRO-CLOCK") product.allocate(order) await asyncio.sleep(0.1) await uow.commit() @@ -137,23 +130,20 @@ async def allocate(order: models.OrderLine) -> None: await asyncio.gather(*[allocate(order1), allocate(order2)]) [exception] = exceptions - assert ( - "UPDATE statement on table 'products' expected to update 1 row(s); 0 were matched." - in str(exception) - ) + assert "UPDATE statement on table 'product' expected to update 1 row(s); 0 were matched." in str(exception) [[version]] = await session.execute( - sa.text("SELECT version_number FROM products WHERE sku=:sku"), + sa.text("SELECT version_number FROM product WHERE sku=:sku"), dict(sku="RETRO-CLOCK"), ) assert version == 2 orders = await session.execute( sa.text( - "SELECT order_line_id FROM allocation" + "SELECT order_id FROM allocation" " JOIN batch ON allocation.batch_id = batch.id" - " JOIN order_line ON allocation.order_line_id = order_line.id" - " WHERE order_line.sku=:sku" + ' JOIN "order" ON allocation.order_id = "order".id' + ' WHERE "order".sku=:sku' ), dict(sku="RETRO-CLOCK"), ) @@ -161,25 +151,19 @@ async def allocate(order: models.OrderLine) -> None: async def test_get_by_batch_id(session: AsyncSession) -> None: - b1 = models.Batch( - id=UUID("c1dac0a1-b8e8-4052-a7e4-67061204d4d9"), sku="sku1", qty=100, eta=None - ) - b2 = models.Batch( - id=UUID("c3f04384-8fdf-4d89-8616-9efec913092f"), sku="sku1", qty=100, eta=None - ) - b3 = models.Batch( - id=UUID("cf15ab56-082a-4495-8422-b42cbb5ac1e5"), sku="sku2", qty=100, eta=None - ) + b1 = models.Batch(id=UUID("c1dac0a1-b8e8-4052-a7e4-67061204d4d9"), sku="sku1", qty=100, eta=None) + b2 = models.Batch(id=UUID("c3f04384-8fdf-4d89-8616-9efec913092f"), sku="sku1", qty=100, eta=None) + b3 = models.Batch(id=UUID("cf15ab56-082a-4495-8422-b42cbb5ac1e5"), sku="sku2", qty=100, eta=None) p1 = models.Product(sku="sku1", batches=[b1, b2]) p2 = models.Product(sku="sku2", batches=[b3]) session.add(p1) session.add(p2) await session.commit() - async with ProductUnitOfWork() as uow: - actual1 = await uow.repo.get_by_batch_id(UUID("c1dac0a1-b8e8-4052-a7e4-67061204d4d9")) - actual2 = await uow.repo.get_by_batch_id(UUID("c3f04384-8fdf-4d89-8616-9efec913092f")) - actual3 = await uow.repo.get_by_batch_id(UUID("cf15ab56-082a-4495-8422-b42cbb5ac1e5")) + async with PGUnitOfWork() as uow: + actual1 = await uow.products.get_by_batch_id(UUID("c1dac0a1-b8e8-4052-a7e4-67061204d4d9")) + actual2 = await uow.products.get_by_batch_id(UUID("c3f04384-8fdf-4d89-8616-9efec913092f")) + actual3 = await uow.products.get_by_batch_id(UUID("cf15ab56-082a-4495-8422-b42cbb5ac1e5")) assert actual1.sku == "sku1" assert actual2.sku == "sku1" assert actual3.sku == "sku2" diff --git a/tests/test_main.py b/tests/test_main.py index 67acdbc..8b5723b 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -6,7 +6,7 @@ @pytest.fixture() async def client() -> AsyncGenerator[TestClient, None]: - from app.allocation.routers.api import app + from app.allocation.entrypoints.restapi import app yield TestClient(app) diff --git a/tests/unit/test_batches.py b/tests/unit/test_batches.py index a7f277a..f636294 100644 --- a/tests/unit/test_batches.py +++ b/tests/unit/test_batches.py @@ -1,13 +1,13 @@ -from app.allocation.domain.models import Batch, OrderLine +from app.allocation.domain.models import Batch, Order def test_allocating_to_a_batch_reduces_available_quantity() -> None: # Given batch = Batch(sku="SMALL-FORK", qty=10) - line = OrderLine(sku="SMALL-FORK", qty=10) + order = Order(sku="SMALL-FORK", qty=10) # When - batch.allocate(line) + batch.allocate(order) # Then assert batch.available_quantity == 0 @@ -16,10 +16,10 @@ def test_allocating_to_a_batch_reduces_available_quantity() -> None: def test_can_allocate_if_available_greater_than_required() -> None: # Given batch = Batch(sku="SMALL-FORK", qty=10) - line = OrderLine(sku="SMALL-FORK", qty=1) + order = Order(sku="SMALL-FORK", qty=1) # When - can_allocate = batch.can_allocate(line) + can_allocate = batch.can_allocate(order) # Then assert can_allocate @@ -28,10 +28,10 @@ def test_can_allocate_if_available_greater_than_required() -> None: def test_cannot_allocate_if_available_smaller_than_required() -> None: # Given batch = Batch(sku="SMALL-FORK", qty=1) - line = OrderLine(sku="SMALL-FORK", qty=10) + order = Order(sku="SMALL-FORK", qty=10) # When - can_allocate = batch.can_allocate(line) + can_allocate = batch.can_allocate(order) # Then assert can_allocate is False @@ -40,10 +40,10 @@ def test_cannot_allocate_if_available_smaller_than_required() -> None: def test_can_allocate_if_available_equal_to_required() -> None: # Given batch = Batch(sku="SMALL-FORK", qty=1) - line = OrderLine(sku="SMALL-FORK", qty=1) + order = Order(sku="SMALL-FORK", qty=1) # When - can_allocate = batch.can_allocate(line) + can_allocate = batch.can_allocate(order) # Then assert can_allocate @@ -52,10 +52,10 @@ def test_can_allocate_if_available_equal_to_required() -> None: def test_cannot_allocate_if_skus_do_not_match() -> None: # Given batch = Batch(sku="SMALL-FORK", qty=10) - line = OrderLine(sku="LARGE-FORK", qty=10) + order = Order(sku="LARGE-FORK", qty=10) # When - can_allocate = batch.can_allocate(line) + can_allocate = batch.can_allocate(order) # Then assert can_allocate is False @@ -64,14 +64,14 @@ def test_cannot_allocate_if_skus_do_not_match() -> None: def test_allocation_is_idempotent() -> None: # Given batch = Batch(sku="SMALL-FORK", qty=10) - line = OrderLine(sku="SMALL-FORK", qty=10) + order = Order(sku="SMALL-FORK", qty=10) # When - batch.allocate(line) + batch.allocate(order) # Then assert batch.available_quantity == 0 # When - batch.allocate(line) + batch.allocate(order) # Then assert batch.available_quantity == 0 diff --git a/tests/unit/test_handlers.py b/tests/unit/test_handlers.py index 13169ca..0329996 100644 --- a/tests/unit/test_handlers.py +++ b/tests/unit/test_handlers.py @@ -5,10 +5,11 @@ from uuid import UUID, uuid4 import pytest +from pytest_mock import MockerFixture from app.allocation.adapters import repository from app.allocation.domain import commands, models -from app.allocation.service_layer import handlers, messagebus, unit_of_work +from app.allocation.service_layer import handlers, unit_of_work class FakeRepository(repository.AbstractProductRepository): @@ -26,17 +27,17 @@ async def get_by_batch_id(self, batch_id: UUID) -> models.Product: return next((p for p in self._products for b in p.batches if b.id == batch_id), None) -class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork[repository.AbstractProductRepository]): +class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork): def __init__(self) -> None: - self._repo = FakeRepository([]) + self._products = FakeRepository([]) self.committed = False async def __aexit__(self, *args: Any) -> None: await self.rollback() @property - def repo(self) -> repository.AbstractProductRepository: - return self._repo + def products(self) -> repository.AbstractProductRepository: + return self._products async def commit(self) -> None: self.committed = True @@ -48,100 +49,130 @@ async def rollback(self) -> None: class TestAddBatch: async def test_for_new_product(self) -> None: uow = FakeUnitOfWork() - await messagebus.handle(commands.CreateBatch(uuid4(), "CRUNCHY-ARMCHAIR", 100), uow) - assert await uow.repo.get("CRUNCHY-ARMCHAIR") is not None + await handlers.CreateBatchCmdHandler(uow).handle(commands.CreateBatch(uuid4(), "CRUNCHY-ARMCHAIR", 100)) + assert await uow.products.get("CRUNCHY-ARMCHAIR") is not None assert uow.committed async def test_for_existing_product(self) -> None: uow = FakeUnitOfWork() - await messagebus.handle( - commands.CreateBatch(UUID("5103f447-4568-4615-bc28-70447d1a7436"), "GARISH-RUG", 100), - uow, + + await handlers.CreateBatchCmdHandler(uow).handle( + commands.CreateBatch(UUID("5103f447-4568-4615-bc28-70447d1a7436"), "GARISH-RUG", 100) ) - await messagebus.handle( - commands.CreateBatch(UUID("6c381ae2-c9fc-4b52-aacb-3e496f0aacef"), "GARISH-RUG", 99), - uow, + await handlers.CreateBatchCmdHandler(uow).handle( + commands.CreateBatch(UUID("6c381ae2-c9fc-4b52-aacb-3e496f0aacef"), "GARISH-RUG", 99) ) + assert UUID("5103f447-4568-4615-bc28-70447d1a7436") in [ - b.id for b in (await uow.repo.get("GARISH-RUG")).batches + b.id for b in (await uow.products.get("GARISH-RUG")).batches ] assert UUID("6c381ae2-c9fc-4b52-aacb-3e496f0aacef") in [ - b.id for b in (await uow.repo.get("GARISH-RUG")).batches + b.id for b in (await uow.products.get("GARISH-RUG")).batches ] class TestAllocate: - async def test_returns_allocation(self) -> None: + async def test_returns_allocation(self, mocker: MockerFixture) -> None: uow = FakeUnitOfWork() - await messagebus.handle( - commands.CreateBatch( - UUID("b4cf5213-6e1f-46cc-8302-aac1f12ac617"), "COMPLICATED-LAMP", 100 - ), - uow, + mock_redis = mocker.patch("app.allocation.service_layer.handlers.redis", return_value=mock.AsyncMock) + mock_redis.attach_mock(mock_redis, "publish") + + await handlers.CreateBatchCmdHandler(uow).handle( + commands.CreateBatch(UUID("b4cf5213-6e1f-46cc-8302-aac1f12ac617"), "COMPLICATED-LAMP", 100) + ) + batch_id = await handlers.AllocateCmdHandler(uow).handle( + commands.Allocate(UUID("c3370153-5d1c-4059-9a2a-4a39267afc27"), "COMPLICATED-LAMP", 10) + ) + + assert mock_redis.call_args == mock.call( + "allocation:order_allocated:v1", + b'{"order_id":"c3370153-5d1c-4059-9a2a-4a39267afc27","sku":"COMPLICATED-LAMP","qty":10,"batch_id":"b4cf5213-6e1f-46cc-8302-aac1f12ac617"}', ) - results = await messagebus.handle(commands.Allocate("COMPLICATED-LAMP", 10), uow) - assert results.pop(0) == UUID("b4cf5213-6e1f-46cc-8302-aac1f12ac617") + assert batch_id == UUID("b4cf5213-6e1f-46cc-8302-aac1f12ac617") async def test_errors_for_invalid_sku(self) -> None: uow = FakeUnitOfWork() with pytest.raises(handlers.InvalidSku, match="Invalid sku NONEXISTENTSKU"): - await messagebus.handle(commands.Allocate("NONEXISTENTSKU", 10), uow) + await handlers.AllocateCmdHandler(uow).handle(commands.Allocate(uuid4(), "NONEXISTENTSKU", 10)) - async def test_commits(self) -> None: + async def test_allocate_handler_commit(self, mocker: MockerFixture) -> None: uow = FakeUnitOfWork() - await messagebus.handle(commands.CreateBatch(uuid4(), "OMINOUS-MIRROR", 100, None), uow) - await messagebus.handle(commands.Allocate("OMINOUS-MIRROR", 10), uow) + mock_redis = mocker.patch("app.allocation.service_layer.handlers.redis", return_value=mock.AsyncMock) + mock_redis.attach_mock(mock_redis, "publish") + + await handlers.CreateBatchCmdHandler(uow).handle( + commands.CreateBatch(UUID("0cf8c64c-efd3-4b18-994b-9254ee7c3c93"), "OMINOUS-MIRROR", 100, None) + ) + await handlers.AllocateCmdHandler(uow).handle( + commands.Allocate(UUID("1156164c-1ed1-4726-b315-5db7ac65ebb5"), "OMINOUS-MIRROR", 10) + ) + + assert mock_redis.call_args == mock.call( + "allocation:order_allocated:v1", + b'{"order_id":"1156164c-1ed1-4726-b315-5db7ac65ebb5","sku":"OMINOUS-MIRROR","qty":10,"batch_id":"0cf8c64c-efd3-4b18-994b-9254ee7c3c93"}', + ) assert uow.committed - async def test_sends_email_on_out_of_stock_error(self) -> None: + async def test_sends_email_on_out_of_stock_error(self, mocker: MockerFixture) -> None: uow = FakeUnitOfWork() - await messagebus.handle(commands.CreateBatch(uuid4(), "POPULAR-CURTAINS", 9, None), uow) - with mock.patch("app.allocation.adapters.email.send") as mock_send_mail: - await messagebus.handle(commands.Allocate("POPULAR-CURTAINS", 10), uow) - assert mock_send_mail.call_args == mock.call( - "stock@made.com", "Out of stock for POPULAR-CURTAINS" - ) + mock_send_mail = mocker.patch("app.allocation.adapters.email.send") + + await handlers.CreateBatchCmdHandler(uow).handle(commands.CreateBatch(uuid4(), "POPULAR-CURTAINS", 9, None)) + await handlers.AllocateCmdHandler(uow).handle(commands.Allocate(uuid4(), "POPULAR-CURTAINS", 10)) + + assert mock_send_mail.call_args == mock.call("stock@made.com", "Out of stock for POPULAR-CURTAINS") class TestChangeBatchQuantity: async def test_changes_available_quantity(self) -> None: uow = FakeUnitOfWork() - await messagebus.handle( - commands.CreateBatch( - UUID("05e2c957-154b-4dcf-9d24-d172f26e4b12"), "ADORABLE-SETTEE", 100, None - ), - uow, + await handlers.CreateBatchCmdHandler(uow).handle( + commands.CreateBatch(UUID("05e2c957-154b-4dcf-9d24-d172f26e4b12"), "ADORABLE-SETTEE", 100, None) ) - [batch] = (await uow.repo.get(sku="ADORABLE-SETTEE")).batches - assert batch.available_quantity == 100 + [batch] = (await uow.products.get(sku="ADORABLE-SETTEE")).batches - await messagebus.handle( - commands.ChangeBatchQuantity(UUID("05e2c957-154b-4dcf-9d24-d172f26e4b12"), 50), uow + await handlers.ChangeBatchQuantityCmdHandler(uow).handle( + commands.ChangeBatchQuantity(UUID("05e2c957-154b-4dcf-9d24-d172f26e4b12"), 50) ) assert batch.available_quantity == 50 - async def test_reallocates_if_necessary(self) -> None: + async def test_reallocates_if_necessary(self, mocker: MockerFixture) -> None: + # Given uow = FakeUnitOfWork() - event_history = [ - commands.CreateBatch( - UUID("874c6d0d-84e6-4307-b9d5-e23ec78bb727"), "INDIFFERENT-TABLE", 50, None - ), - commands.CreateBatch(uuid4(), "INDIFFERENT-TABLE", 50, date.today()), - commands.Allocate("INDIFFERENT-TABLE", 20), - commands.Allocate("INDIFFERENT-TABLE", 20), - ] - for e in event_history: - await messagebus.handle(e, uow) - [batch1, batch2] = (await uow.repo.get(sku="INDIFFERENT-TABLE")).batches + + await handlers.CreateBatchCmdHandler(uow).handle( + commands.CreateBatch(UUID("874c6d0d-84e6-4307-b9d5-e23ec78bb727"), "INDIFFERENT-TABLE", 50, None) + ) + await handlers.CreateBatchCmdHandler(uow).handle( + commands.CreateBatch(UUID("e9c6851c-e74a-40ac-8e86-3956f4762853"), "INDIFFERENT-TABLE", 50, date.today()) + ) + with mock.patch("app.allocation.service_layer.handlers.redis", return_value=mock.AsyncMock) as mock_redis: + mock_redis.attach_mock(mock_redis, "publish") + await handlers.AllocateCmdHandler(uow).handle( + commands.Allocate(UUID("57e0e250-93f2-4378-b44e-307838b4c367"), "INDIFFERENT-TABLE", 40) + ) + await handlers.AllocateCmdHandler(uow).handle( + commands.Allocate(UUID("1406c359-13b6-422d-8507-b24a0c763abd"), "INDIFFERENT-TABLE", 20) + ) + [batch1, batch2] = (await uow.products.get(sku="INDIFFERENT-TABLE")).batches assert batch1.available_quantity == 10 - assert batch2.available_quantity == 50 + assert batch2.available_quantity == 30 - await messagebus.handle( - commands.ChangeBatchQuantity(UUID("874c6d0d-84e6-4307-b9d5-e23ec78bb727"), 25), uow + # When + def mock_publish(channel: str, message: bytes) -> None: + assert channel == "allocation:order_deallocated:v1" + assert message == b'{"order_id":"57e0e250-93f2-4378-b44e-307838b4c367","sku":"INDIFFERENT-TABLE","qty":40}' + + mock_pipeline = mock.AsyncMock() + mock_pipeline.publish = mock_publish + mocker.patch( + "app.allocation.service_layer.handlers.redis.pipeline", + return_value=mock_pipeline, + ) + await handlers.ChangeBatchQuantityCmdHandler(uow).handle( + commands.ChangeBatchQuantity(UUID("874c6d0d-84e6-4307-b9d5-e23ec78bb727"), 25) ) - # order1 or order2 will be deallocated, so we'll have 25 - 20 - assert batch1.available_quantity == 5 - # and 20 will be reallocated to the next batch - assert batch2.available_quantity == 30 + # Then + assert batch1.available_quantity == 25 diff --git a/tests/unit/test_product.py b/tests/unit/test_product.py index fd463b2..3e957ec 100644 --- a/tests/unit/test_product.py +++ b/tests/unit/test_product.py @@ -1,6 +1,6 @@ from datetime import date, timedelta -from app.allocation.domain.models import Batch, OrderLine, Product +from app.allocation.domain.models import Batch, Order, Product def test_perfers_none_eta_batches_to_allocate() -> None: @@ -8,10 +8,10 @@ def test_perfers_none_eta_batches_to_allocate() -> None: in_stock_batch = Batch(sku="RETRO-CLOCK", qty=100) shipment_batch = Batch(sku="RETRO-CLOCK", qty=100, eta=date.today() + timedelta(days=1)) product = Product(sku="RETRO-CLOCK", batches=[in_stock_batch, shipment_batch]) - line = OrderLine(sku="RETRO-CLOCK", qty=10) + order = Order(sku="RETRO-CLOCK", qty=10) # When - product.allocate(line) + product.allocate(order) # Then assert in_stock_batch.available_quantity == 90 @@ -24,10 +24,10 @@ def test_prefers_earlier_batches() -> None: medium = Batch(sku="MINIMALIST-SPOON", qty=100, eta=date.today() + timedelta(days=1)) latest = Batch(sku="MINIMALIST-SPOON", qty=100, eta=date.today() + timedelta(days=2)) product = Product(sku="MINIMALIST-SPOON", batches=[earliest, medium, latest]) - line = OrderLine(sku="MINIMALIST-SPOON", qty=10) + order = Order(sku="MINIMALIST-SPOON", qty=10) # When - product.allocate(line) + product.allocate(order) # Then assert earliest.available_quantity == 90 @@ -40,10 +40,10 @@ def test_allocate_returns_allocated_batch_id() -> None: in_stock_batch = Batch(sku="HIGHBROW-POSTER", qty=100) shipment_batch = Batch(sku="HIGHBROW-POSTER", qty=100, eta=date.today() + timedelta(days=1)) product = Product(sku="HIGHBROW-POSTER", batches=[in_stock_batch, shipment_batch]) - line = OrderLine(sku="HIGHBROW-POSTER", qty=10) + order = Order(sku="HIGHBROW-POSTER", qty=10) # When - batch_id = product.allocate(line) + batch_id = product.allocate(order) # Then assert batch_id == in_stock_batch.id @@ -53,22 +53,22 @@ def test_raises_out_of_stock_exception_if_cannot_allocate() -> None: # Given batch = Batch(sku="SMALL-FORK", qty=10) product = Product(sku="SMALL-FORK", batches=[batch]) - product.allocate(OrderLine(sku="SMALL-FORK", qty=10)) + product.allocate(Order(sku="SMALL-FORK", qty=10)) # When & Then - allocation = product.allocate(OrderLine(sku="SMALL-FORK", qty=1)) + allocation = product.allocate(Order(sku="SMALL-FORK", qty=1)) assert allocation is None def test_increment_version_number() -> None: # Given - line = OrderLine(sku="HIGHBROW-POSTER", qty=10) + order = Order(sku="HIGHBROW-POSTER", qty=10) batch = Batch(sku="HIGHBROW-POSTER", qty=100) product = Product(sku="HIGHBROW-POSTER", batches=[batch]) product.version_number = 7 # When - product.allocate(line) + product.allocate(order) # Then assert product.version_number == 8