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
88
tests/conftest.py
Normal file
88
tests/conftest.py
Normal 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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue