From 7d704a8549b4a0a12eafd08a647979e40553a068 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 19 Jul 2026 17:48:11 +0200 Subject: [PATCH] test: booking-flow, availability, and #4 concurrency regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 16 tests, run under the LNbits pytest env (spirekeeper pattern: monkeypatch crud/invoice, drive async via asyncio.run — no live DB/wallet): - test_availability: half-open overlap semantics (back-to-back stays OK), and is_available blocking on held/confirmed bookings + manual blocks. - test_booking_flow: canonical amount_sat, awaiting_payment transition, min-nights guard, and hold-release (declined) on InvoiceError. - test_atomic_hold: the #4 regression — two concurrent same-date requests yield exactly one hold + one conflict; non-overlapping both succeed. The fakes yield mid-check to open the race window, so the test fails without the per-room lock (verified by neutering it) and passes with it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- tests/__init__.py | 0 tests/conftest.py | 88 ++++++++++++++++++++++++++++++++++ tests/test_atomic_hold.py | 96 ++++++++++++++++++++++++++++++++++++++ tests/test_availability.py | 87 ++++++++++++++++++++++++++++++++++ tests/test_booking_flow.py | 82 ++++++++++++++++++++++++++++++++ 5 files changed, 353 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_atomic_hold.py create mode 100644 tests/test_availability.py create mode 100644 tests/test_booking_flow.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..3fe5037 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,88 @@ +"""Pytest configuration + factories for the chatelet test suite. + +Following the spirekeeper pattern: unit tests isolate the function under +test by monkeypatching the crud / lnbits calls it makes and driving async +functions with `asyncio.run(...)`, so no live LNbits DB or wallet is needed. +The `loguru_capture` fixture is here for the same reason spirekeeper needs +it — loguru binds sys.stderr at import, before capsys wraps it. +""" + +from typing import Generator, List + +import pytest +from loguru import logger + +from ..models import ( + BookingRequestData, + CreateRoomData, + Room, + RoomStatus, +) + + +@pytest.fixture(autouse=True) +def _reset_room_locks(): + """Each test drives its own `asyncio.run` loop, but `services._room_locks` + is module-level — a lock created in one test's loop would raise + 'bound to a different event loop' when reused in the next. Clear the + registry around every test so locks are recreated in the active loop. + (Production is unaffected: LNbits runs one long-lived loop.)""" + from .. import services + + services._room_locks.clear() + yield + services._room_locks.clear() + + +@pytest.fixture +def loguru_capture() -> Generator[List[str], None, None]: + captured: List[str] = [] + handler_id = logger.add( + captured.append, level="WARNING", format="{level} {message}" + ) + yield captured + logger.remove(handler_id) + + +# --- factories ------------------------------------------------------------- + + +def make_room( + room_id: str = "room1", + *, + wallet: str = "wallet1", + currency: str = "sat", # "sat" so pricing skips the FX call in tests + price: float = 100.0, + min_nights: int = 1, + max_guests: int = 2, + status: RoomStatus = RoomStatus.active, +) -> Room: + return Room( + **CreateRoomData( + wallet=wallet, + title="Tower Room", + price_amount=price, + price_currency=currency, + min_nights=min_nights, + max_guests=max_guests, + ).dict(), + id=room_id, + status=status, + ) + + +def make_request( + room_id: str = "room1", + *, + guest: str = "npub_guest", + check_in: str = "2026-08-01", + check_out: str = "2026-08-04", + num_guests: int = 1, +) -> BookingRequestData: + return BookingRequestData( + room_id=room_id, + guest_pubkey=guest, + check_in=check_in, + check_out=check_out, + num_guests=num_guests, + ) diff --git a/tests/test_atomic_hold.py b/tests/test_atomic_hold.py new file mode 100644 index 0000000..cf1ec0f --- /dev/null +++ b/tests/test_atomic_hold.py @@ -0,0 +1,96 @@ +"""Regression for #4 — atomic check-then-hold. + +The fakes here yield control (`await asyncio.sleep`) between reading +availability and committing the hold, opening the exact race window the +per-room lock closes. Without the lock in `services.request_booking`, the +first test double-books (two quotes, zero conflicts) and fails; with it, one +request wins and the other gets `Unavailable`. +""" + +import asyncio +from types import SimpleNamespace + +from .. import crud, services +from ..models import BookingQuote +from .conftest import make_request, make_room + + +def _setup(monkeypatch, room): + """In-memory occupancy store + async crud/invoice stubs. Returns the + (held_ranges, invoices) lists so tests can assert on them.""" + held: list[tuple[str, str]] = [] + invoices: list[dict] = [] + + async def fake_get_room(_): + return room + + async def fake_settings(): + return SimpleNamespace(deposit_percent=100, default_hold_minutes=30) + + async def fake_is_available(room_id, ci, co): + await asyncio.sleep(0.02) # race window: let a concurrent caller interleave + return not any(crud._overlaps(ci, co, s, e) for s, e in held) + + async def fake_create_booking(booking): + await asyncio.sleep(0.01) + held.append((booking.check_in, booking.check_out)) + return booking + + async def fake_update_booking(booking): + return booking + + async def fake_create_invoice(**kwargs): + invoices.append(kwargs) + return SimpleNamespace( + payment_hash="ph_" + kwargs["extra"]["booking_id"], bolt11="lnbc_fake" + ) + + monkeypatch.setattr(crud, "get_room", fake_get_room) + monkeypatch.setattr(crud, "get_or_create_settings", fake_settings) + monkeypatch.setattr(crud, "is_available", fake_is_available) + monkeypatch.setattr(crud, "create_booking", fake_create_booking) + monkeypatch.setattr(crud, "update_booking", fake_update_booking) + monkeypatch.setattr(services, "create_invoice", fake_create_invoice) + return held, invoices + + +def test_concurrent_same_dates_yield_one_hold_one_conflict(monkeypatch): + held, invoices = _setup(monkeypatch, make_room()) + + async def run_two(): + return await asyncio.gather( + services.request_booking(make_request(guest="g1")), + services.request_booking(make_request(guest="g2")), + return_exceptions=True, + ) + + results = asyncio.run(run_two()) + quotes = [r for r in results if isinstance(r, BookingQuote)] + conflicts = [r for r in results if isinstance(r, services.Unavailable)] + + assert len(quotes) == 1, f"expected exactly one hold, got {results}" + assert len(conflicts) == 1, f"expected exactly one conflict, got {results}" + assert len(held) == 1, "only one booking should occupy the calendar" + assert len(invoices) == 1, "only the winner should be invoiced" + + +def test_concurrent_non_overlapping_both_succeed(monkeypatch): + """The lock must not over-serialize: two distinct date ranges on the same + room (same lock) should both go through.""" + held, invoices = _setup(monkeypatch, make_room()) + + async def run_two(): + return await asyncio.gather( + services.request_booking( + make_request(guest="g1", check_in="2026-08-01", check_out="2026-08-04") + ), + services.request_booking( + make_request(guest="g2", check_in="2026-08-10", check_out="2026-08-12") + ), + return_exceptions=True, + ) + + results = asyncio.run(run_two()) + assert all(isinstance(r, BookingQuote) for r in results), results + assert len(held) == 2 + assert len(invoices) == 2 diff --git a/tests/test_availability.py b/tests/test_availability.py new file mode 100644 index 0000000..9d9ea02 --- /dev/null +++ b/tests/test_availability.py @@ -0,0 +1,87 @@ +"""Availability arbiter: half-open overlap semantics + is_available().""" + +import asyncio + +from .. import crud +from ..models import Block, Booking, BookingStatus, RoomStatus +from .conftest import make_room + + +# --- pure interval logic --------------------------------------------------- + + +class TestOverlap: + def test_disjoint_ranges_do_not_overlap(self): + assert not crud._overlaps("2026-08-01", "2026-08-04", "2026-08-04", "2026-08-06") + + def test_back_to_back_same_day_does_not_overlap(self): + # check_out is exclusive: one guest leaves 08-04, next arrives 08-04. + assert not crud._overlaps("2026-08-01", "2026-08-04", "2026-08-04", "2026-08-07") + + def test_partial_overlap(self): + assert crud._overlaps("2026-08-01", "2026-08-05", "2026-08-04", "2026-08-08") + + def test_full_containment(self): + assert crud._overlaps("2026-08-01", "2026-08-10", "2026-08-03", "2026-08-05") + + def test_nights_between(self): + assert crud.nights_between("2026-08-01", "2026-08-04") == 3 + + +# --- is_available (crud getters monkeypatched) ----------------------------- + + +def _patch(monkeypatch, *, room, bookings=None, blocks=None): + async def _get_room(_): + return room + + async def _get_bookings(_): + return bookings or [] + + async def _get_blocks(_): + return blocks or [] + + monkeypatch.setattr(crud, "get_room", _get_room) + monkeypatch.setattr(crud, "get_bookings_for_room", _get_bookings) + monkeypatch.setattr(crud, "get_blocks_for_room", _get_blocks) + + +def _booking(status: BookingStatus, ci="2026-08-02", co="2026-08-05") -> Booking: + return Booking( + id="b1", room_id="room1", guest_pubkey="g", check_in=ci, check_out=co, + nights=3, num_guests=1, currency="sat", price_fiat=300.0, + amount_sat=300, deposit_sat=300, status=status, + ) + + +class TestIsAvailable: + def test_open_range_is_available(self, monkeypatch): + _patch(monkeypatch, room=make_room()) + assert asyncio.run(crud.is_available("room1", "2026-08-01", "2026-08-04")) + + def test_inactive_room_is_never_available(self, monkeypatch): + _patch(monkeypatch, room=make_room(status=RoomStatus.inactive)) + assert not asyncio.run(crud.is_available("room1", "2026-08-01", "2026-08-04")) + + def test_overlapping_confirmed_booking_blocks(self, monkeypatch): + _patch(monkeypatch, room=make_room(), + bookings=[_booking(BookingStatus.confirmed)]) + assert not asyncio.run(crud.is_available("room1", "2026-08-01", "2026-08-04")) + + def test_overlapping_held_booking_blocks(self, monkeypatch): + # A mere hold occupies the calendar — this is what makes the atomic + # check-then-hold work. + _patch(monkeypatch, room=make_room(), + bookings=[_booking(BookingStatus.held)]) + assert not asyncio.run(crud.is_available("room1", "2026-08-01", "2026-08-04")) + + def test_cancelled_booking_does_not_block(self, monkeypatch): + _patch(monkeypatch, room=make_room(), + bookings=[_booking(BookingStatus.cancelled)]) + assert asyncio.run(crud.is_available("room1", "2026-08-01", "2026-08-04")) + + def test_manual_block_blocks(self, monkeypatch): + blk = Block(id="k1", room_id="room1", start_date="2026-08-03", + end_date="2026-08-06", reason="maintenance") + _patch(monkeypatch, room=make_room(), blocks=[blk]) + assert not asyncio.run(crud.is_available("room1", "2026-08-01", "2026-08-04")) diff --git a/tests/test_booking_flow.py b/tests/test_booking_flow.py new file mode 100644 index 0000000..a8e0e59 --- /dev/null +++ b/tests/test_booking_flow.py @@ -0,0 +1,82 @@ +"""Booking flow through services.request_booking: canonical amount, invoice +tagging, and hold-release on invoice failure.""" + +import asyncio +from types import SimpleNamespace + +import pytest +from lnbits.exceptions import InvoiceError + +from .. import crud, services +from ..models import BookingQuote, BookingStatus +from .conftest import make_request, make_room + + +def _setup(monkeypatch, room, *, invoice_raises=False): + created: list = [] # bookings passed to create_booking + updated: list = [] # bookings passed to update_booking (captures final state) + + async def fake_get_room(_): + return room + + async def fake_settings(): + return SimpleNamespace(deposit_percent=100, default_hold_minutes=30) + + async def fake_is_available(*_): + return True + + async def fake_create_booking(booking): + # Snapshot the status at creation time — `booking` is mutated in place + # later (held -> declined), so the reference alone can't prove it was + # ever held. + created.append(SimpleNamespace(id=booking.id, status=booking.status)) + return booking + + async def fake_update_booking(booking): + updated.append(booking) + return booking + + async def fake_create_invoice(**kwargs): + if invoice_raises: + raise InvoiceError("no funding source") + return SimpleNamespace(payment_hash="ph_1", bolt11="lnbc_fake") + + monkeypatch.setattr(crud, "get_room", fake_get_room) + monkeypatch.setattr(crud, "get_or_create_settings", fake_settings) + monkeypatch.setattr(crud, "is_available", fake_is_available) + monkeypatch.setattr(crud, "create_booking", fake_create_booking) + monkeypatch.setattr(crud, "update_booking", fake_update_booking) + monkeypatch.setattr(services, "create_invoice", fake_create_invoice) + return created, updated + + +def test_happy_path_holds_then_awaits_payment(monkeypatch): + # sat currency + price 100, 3 nights -> canonical amount_sat = 300. + _setup(monkeypatch, make_room(price=100.0, currency="sat")) + quote = asyncio.run(services.request_booking(make_request())) + + assert isinstance(quote, BookingQuote) + assert quote.booking.amount_sat == 300 # 100 * 3 nights, canonical + assert quote.booking.deposit_sat == 300 # deposit_percent 100 + assert quote.booking.status == BookingStatus.awaiting_payment + assert quote.booking.payment_hash == "ph_1" + assert quote.payment_request == "lnbc_fake" + + +def test_min_nights_enforced(monkeypatch): + _setup(monkeypatch, make_room(min_nights=5)) + with pytest.raises(ValueError, match="Minimum stay"): + asyncio.run(services.request_booking(make_request())) # 3 nights < 5 + + +def test_invoice_failure_releases_hold(monkeypatch): + created, updated = _setup( + monkeypatch, make_room(), invoice_raises=True + ) + with pytest.raises(services.BookingError): + asyncio.run(services.request_booking(make_request())) + + # The hold was written, then flipped to declined so the dates free up — + # a dead hold must not block the calendar. + assert created and created[0].status == BookingStatus.held + assert updated and updated[-1].status == BookingStatus.declined