From 0749062ce10b2f84eb1f2b0829224e8c9e3a8917 Mon Sep 17 00:00:00 2001 From: Willem van den Ende Date: Thu, 1 Oct 2026 13:43:59 +0100 Subject: [PATCH] Port Round 2 to Python (OrderBook): 14 tests total green, START state preserved --- python/src/order_book_domain/__init__.py | 3 + python/src/order_book_domain/order_book.py | 76 +++++++++++++ .../test_order_book_lifecycle.py | 104 ++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 python/src/order_book_domain/order_book.py create mode 100644 python/tests/order_book_tests/test_order_book_lifecycle.py diff --git a/python/src/order_book_domain/__init__.py b/python/src/order_book_domain/__init__.py index e69de29..7f4170e 100644 --- a/python/src/order_book_domain/__init__.py +++ b/python/src/order_book_domain/__init__.py @@ -0,0 +1,3 @@ +from order_book_domain.order_book import Order, OrderBook, OrderStatus + +__all__ = ["Order", "OrderBook", "OrderStatus"] diff --git a/python/src/order_book_domain/order_book.py b/python/src/order_book_domain/order_book.py new file mode 100644 index 0000000..a44cce1 --- /dev/null +++ b/python/src/order_book_domain/order_book.py @@ -0,0 +1,76 @@ +from datetime import datetime, timedelta, timezone +from enum import Enum, auto + + +class OrderStatus(Enum): + PENDING = auto() + WAITING_FOR_PAYMENT = auto() + FULFILLING = auto() + DELIVERED = auto() + CANCELLED = auto() + + +class Order: + # Constructed by OrderBook only. + def __init__(self, id: int, customer: str) -> None: + self.id = id + self.customer = customer + self.status = OrderStatus.PENDING + self.finalized_at: datetime | None = None + + +class OrderBook: + def __init__(self, payment_window: timedelta | None = None) -> None: + self.payment_window = payment_window or timedelta(days=14) + self._orders_by_id: dict[int, Order] = {} + self._next_id = 1 + + @property + def orders(self) -> list[Order]: + return list(self._orders_by_id.values()) + + def open_order(self, customer: str) -> Order: + order = Order(self._next_id, customer) + self._next_id += 1 + self._orders_by_id[order.id] = order + return order + + def get_order(self, order_id: int) -> Order: + try: + return self._orders_by_id[order_id] + except KeyError: + raise ValueError(f"Unknown order id {order_id}.") from None + + def finalize_order(self, order_id: int, finalized_at: datetime) -> None: + order = self.get_order(order_id) + _require_status(order, OrderStatus.PENDING, "finalize") + order.status = OrderStatus.WAITING_FOR_PAYMENT + order.finalized_at = finalized_at + + def pay_order(self, order_id: int, paid_at: datetime) -> None: + order = self.get_order(order_id) + _require_status(order, OrderStatus.WAITING_FOR_PAYMENT, "pay") + if paid_at - order.finalized_at > self.payment_window: + order.status = OrderStatus.CANCELLED + raise ValueError( + f"Payment for order {order_id} arrived after the payment window; the order was cancelled." + ) + order.status = OrderStatus.FULFILLING + + def ship_order(self, order_id: int) -> None: + order = self.get_order(order_id) + _require_status(order, OrderStatus.FULFILLING, "ship") + order.status = OrderStatus.DELIVERED + + def cancel_order(self, order_id: int) -> None: + order = self.get_order(order_id) + if order.status not in (OrderStatus.PENDING, OrderStatus.WAITING_FOR_PAYMENT): + raise ValueError(f"Cannot cancel order {order_id} in status {order.status}.") + order.status = OrderStatus.CANCELLED + + +def _require_status(order: Order, expected: OrderStatus, action: str) -> None: + if order.status is not expected: + raise ValueError( + f"Cannot {action} order {order.id} in status {order.status} (expected {expected})." + ) diff --git a/python/tests/order_book_tests/test_order_book_lifecycle.py b/python/tests/order_book_tests/test_order_book_lifecycle.py new file mode 100644 index 0000000..051dcb0 --- /dev/null +++ b/python/tests/order_book_tests/test_order_book_lifecycle.py @@ -0,0 +1,104 @@ +from datetime import datetime, timedelta, timezone + +import pytest + +from order_book_domain import OrderBook, OrderStatus + +DAY0 = datetime(2025, 6, 1, tzinfo=timezone.utc) + + +def at(day: int) -> datetime: + return DAY0 + timedelta(days=day) + + +def test_finalize_then_pay_moves_order_through_two_states(): + book = OrderBook() + order = book.open_order("anna") + + book.finalize_order(order.id, at(1)) + assert book.get_order(order.id).status is OrderStatus.WAITING_FOR_PAYMENT + + book.pay_order(order.id, at(2)) + assert book.get_order(order.id).status is OrderStatus.FULFILLING + + +def test_pay_then_ship_delivers_the_order(): + book = OrderBook() + order = book.open_order("anna") + + book.finalize_order(order.id, at(1)) + assert book.get_order(order.id).status is OrderStatus.WAITING_FOR_PAYMENT + + book.pay_order(order.id, at(2)) + book.ship_order(order.id) + assert book.get_order(order.id).status is OrderStatus.DELIVERED + + +def test_pay_within_window_then_late_pay_on_second_order_cancels_only_that_order(): + book = OrderBook() + first = book.open_order("anna") + second = book.open_order("bram") + + book.finalize_order(first.id, at(1)) + book.finalize_order(second.id, at(1)) + assert len(book.orders) == 2 + + book.pay_order(first.id, at(5)) + assert book.get_order(first.id).status is OrderStatus.FULFILLING + + with pytest.raises(ValueError): + book.pay_order(second.id, at(30)) + assert book.get_order(second.id).status is OrderStatus.CANCELLED + + +def test_pay_before_finalize_throws_then_finalize_still_works(): + book = OrderBook() + order = book.open_order("anna") + + with pytest.raises(ValueError): + book.pay_order(order.id, at(1)) + assert book.get_order(order.id).status is OrderStatus.PENDING + + book.finalize_order(order.id, at(2)) + assert book.get_order(order.id).status is OrderStatus.WAITING_FOR_PAYMENT + + +def test_cancel_pending_order_then_pay_after_cancel_throws_again(): + book = OrderBook() + order = book.open_order("anna") + + book.cancel_order(order.id) + assert book.get_order(order.id).status is OrderStatus.CANCELLED + + with pytest.raises(ValueError): + book.pay_order(order.id, at(1)) + assert book.get_order(order.id).status is OrderStatus.CANCELLED + + +def test_shipped_order_cannot_be_cancelled(): + book = OrderBook() + order = book.open_order("anna") + + book.finalize_order(order.id, at(1)) + book.pay_order(order.id, at(2)) + book.ship_order(order.id) + assert book.get_order(order.id).status is OrderStatus.DELIVERED + + with pytest.raises(ValueError): + book.cancel_order(order.id) + assert book.get_order(order.id).status is OrderStatus.DELIVERED + + +def test_two_customers_interleave_transitions_independently(): + book = OrderBook() + annas = book.open_order("anna") + brams = book.open_order("bram") + + book.finalize_order(brams.id, at(1)) + assert book.get_order(annas.id).status is OrderStatus.PENDING + assert book.get_order(brams.id).status is OrderStatus.WAITING_FOR_PAYMENT + + book.pay_order(brams.id, at(2)) + book.cancel_order(annas.id) + assert book.get_order(annas.id).status is OrderStatus.CANCELLED + assert book.get_order(brams.id).status is OrderStatus.FULFILLING