test: add test harness + suite (incl. #4 concurrency regression) #10

Merged
padreug merged 2 commits from feat/tests-harness into main 2026-07-19 15:49:25 +00:00
5 changed files with 353 additions and 0 deletions
Showing only changes of commit 7d704a8549 - Show all commits

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
Padreug 2026-07-19 17:48:11 +02:00

0
tests/__init__.py Normal file
View file

88
tests/conftest.py Normal file
View file

@ -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,
)

96
tests/test_atomic_hold.py Normal file
View file

@ -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

View file

@ -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"))

View 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