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:
Padreug 2026-07-19 17:48:11 +02:00
commit 7d704a8549
5 changed files with 353 additions and 0 deletions

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