ooad1-12 Exercise 1: Closing Gap G1 (a Refund That Fails) ========================================================== ex1/resilient.py (a subclass of BookingDesk, in its own file; the workshops package is not edited): """Exercise 1: close gap G1. A desk that records a refund the payment service could not make, tells the attendee, and retries each night. Written as a subclass in a new file, so the workshops package is not edited.""" from datetime import date from workshops.application import BookingDesk from workshops.application.events import BookingCancelled from dataclasses import dataclass @dataclass(frozen=True) class RefundFailed: booking_id: str attendee_id: str refund_pence: int @dataclass(frozen=True) class RefundPaid: booking_id: str attendee_id: str refund_pence: int class ResilientDesk(BookingDesk): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.refunds_due: dict[str, tuple[str, str | None, str, int]] = {} # booking id -> (attendee, receipt, session, pence owed) def cancel(self, booking_id: str, today: date) -> int: session, booking = self.store.find_booking(booking_id) receipt = booking.receipt refund = session.cancel_booking(booking, today, self.refund_policy) if refund: try: self.payments.refund(receipt, refund) except Exception: # the payment service is down: do not lose the debt self.refunds_due[booking.id] = (booking.attendee_id, receipt, session.id, refund) self._publish(RefundFailed(booking.id, booking.attendee_id, refund)) self._publish(BookingCancelled(booking.id, booking.attendee_id, refund)) self._offer_freed_seat(session, today) # the seat is offered whether or not the refund worked self.store.save(session) return refund def day_passed(self, today: date) -> None: super().day_passed(today) for booking_id, (attendee, receipt, session_id, pence) in list(self.refunds_due.items()): try: self.payments.refund(receipt, pence) except Exception: continue # still down: try again tomorrow del self.refunds_due[booking_id] self._publish(RefundPaid(booking_id, attendee, pence)) ex1/test_resilient.py: import sys import unittest from datetime import date, timedelta sys.path.insert(0, "ex1") from resilient import RefundFailed, RefundPaid, ResilientDesk from workshops.domain import Session from workshops.infrastructure import FakePayments, InMemoryStore, Recorder TODAY = date(2026, 11, 1) class Flaky(FakePayments): def __init__(self): super().__init__() self.down = True def refund(self, receipt, pence): if self.down: raise ConnectionError("payment service unreachable") super().refund(receipt, pence) def world(): session = Session("S1", "Pottery", date(2026, 12, 1), 1, 2500) pay, rec = Flaky(), Recorder() desk = ResilientDesk(InMemoryStore([session]), pay, (rec,)) ann = desk.book("ann", "S1", TODAY) bo = desk.book("bo", "S1", TODAY) rec.events.clear() return desk, session, pay, rec, ann, bo class Tests(unittest.TestCase): def test_a_failed_refund_is_recorded_announced_and_does_not_block_the_waiting_list(self): desk, session, pay, rec, ann, bo = world() desk.cancel(ann.id, TODAY) self.assertEqual(ann.state, "Cancelled") self.assertEqual(bo.state, "Offered") # the seat was still offered self.assertIn(ann.id, desk.refunds_due) self.assertEqual([type(e).__name__ for e in rec.events], ["RefundFailed", "BookingCancelled", "OfferMade"]) def test_the_nightly_run_retries_and_clears_the_debt_once_the_service_is_back(self): desk, session, pay, rec, ann, bo = world() desk.cancel(ann.id, TODAY) desk.day_passed(TODAY + timedelta(days=1)) # still down self.assertIn(ann.id, desk.refunds_due) pay.down = False desk.day_passed(TODAY + timedelta(days=2)) self.assertEqual(desk.refunds_due, {}) self.assertEqual(pay.refunds, [("R1", 2500)]) self.assertIsInstance(rec.events[-1], RefundPaid) def test_a_refund_that_works_first_time_leaves_nothing_due(self): desk, session, pay, rec, ann, bo = world() pay.down = False desk.cancel(ann.id, TODAY) self.assertEqual(desk.refunds_due, {}) self.assertEqual(pay.refunds, [("R1", 2500)]) if __name__ == "__main__": unittest.main() Run from the chapter folder with: py -m unittest ex1.test_resilient Output: ---------------------------------------------------------------------- Ran 3 tests in 0.000s OK WHAT IT DECIDES - The debt is recorded (refunds_due) and announced (RefundFailed) at the moment the refund fails, instead of being lost. - The cancellation still stands, and the freed seat is still offered: the waiting person is not made to wait for the payment service. - The nightly run retries every recorded debt, and publishes RefundPaid when one succeeds. - Still open, and for the manager: how long to keep retrying, and who is told if it never works. Also, the debt lives in memory in the desk, so a restart loses it: it belongs on the booking, in whatever persistence the service gets. WHY THIS WORKS AS AN ANSWER --------------------------- The first test shows the three things that were wrong in gap_refund.py being right: the booking is cancelled, the waiting person is Offered, and the debt is on record with the events in a sensible order. The second test uses a payment fake that is down for a night and then returns. The third makes sure the ordinary path is unchanged: a refund that works leaves nothing due. The subclass copies the cancel logic rather than reusing it, which is a cost: if the base class changes, this one must follow. A cleaner fix is a hook in the base class, which would mean editing the package, and that is the trade-off the exercise leaves for you to decide.