test: booking-flow, availability, and #4 concurrency regression
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD
This commit is contained in:
parent
8b50263aac
commit
7d704a8549
5 changed files with 353 additions and 0 deletions
82
tests/test_booking_flow.py
Normal file
82
tests/test_booking_flow.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue