From 0e823056aaacf76fc0384654b1520f37e6423829 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 20 Jul 2026 00:10:52 +0200 Subject: [PATCH 1/9] test: real-DB migration test (#14) Runs the full m0NN migration chain against a fresh temp SQLite via the lnbits Database, then round-trips a room + booking through crud (exercising the m002 checkin_instructions column, big_int amounts, and is_available on real rows). Closes the gap that let the #13 SQLite-index bug ship: the rest of the suite monkeypatches crud, so migrations were never executed. Isolation is import-order-independent: monkeypatch settings.lnbits_data_folder to tmp_path, build a fresh ext_chatelet Database, swap it into crud for the test. Verified it fails (sqlite3 OperationalError) if the #13 bad-index syntax is reintroduced. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- tests/test_migrations.py | 69 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 tests/test_migrations.py diff --git a/tests/test_migrations.py b/tests/test_migrations.py new file mode 100644 index 0000000..de2b259 --- /dev/null +++ b/tests/test_migrations.py @@ -0,0 +1,69 @@ +"""Real-DB migration test (#14). + +Runs the full migration chain against a fresh temp SQLite via the lnbits +`Database`, then round-trips through `crud` to prove the resulting schema. +Guards the class of bug from #13 (a migration statement that parses in Python +but is invalid SQL on SQLite — the default backend). The rest of the suite +monkeypatches `crud`, so migrations are otherwise never actually executed. + +Isolation: `Database` binds its sqlite path + engine at construction from +`settings.lnbits_data_folder`, and `crud.db` is built at import time. So we +point settings at `tmp_path`, build a fresh `ext_chatelet` DB, and swap it +into `crud` for the test — import-order-independent, no live server. +""" + +import asyncio +import re + +from lnbits.db import Database +from lnbits.settings import settings + +from .. import crud, migrations +from ..models import Booking, BookingStatus, CreateRoomData, RoomStatus + + +def test_full_migration_chain_applies_and_schema_round_trips(monkeypatch, tmp_path): + # Fresh, isolated ext DB in tmp_path; route crud at it for the test. + monkeypatch.setattr(settings, "lnbits_data_folder", str(tmp_path)) + test_db = Database("ext_chatelet") + monkeypatch.setattr(crud, "db", test_db) + + async def run(): + # Apply every m0NN migration in order against the empty DB. If any + # statement is invalid on SQLite (the #13 bug), this raises here. + migfns = sorted( + (n, f) for n, f in vars(migrations).items() if re.match(r"m\d+_", n) + ) + assert migfns, "no m0NN migrations discovered" + async with test_db.connect() as conn: + for _name, fn in migfns: + await fn(conn) + + # Round-trip through crud (now bound to test_db) to prove the schema. + room = await crud.create_room( + CreateRoomData( + wallet="w1", title="Keep", price_amount=90, price_currency="EUR" + ) + ) + got = await crud.get_room(room.id) + assert got is not None + assert got.checkin_instructions == "" # m002 column exists, default '' + got.status = RoomStatus.active + await crud.update_room(got) + + booking = Booking( + id="bk1", room_id=room.id, guest_pubkey="g", + check_in="2026-08-01", check_out="2026-08-04", nights=3, num_guests=1, + currency="EUR", price_fiat=270.0, amount_sat=450000, deposit_sat=450000, + status=BookingStatus.held, + ) + await crud.create_booking(booking) + gb = await crud.get_booking("bk1") + assert gb is not None and gb.amount_sat == 450000 # big_int round-trips + + # Availability computed against real rows (not monkeypatched): the held + # booking blocks its own dates; a non-overlapping range is free. + assert await crud.is_available(room.id, "2026-08-01", "2026-08-04") is False + assert await crud.is_available(room.id, "2026-08-10", "2026-08-12") is True + + asyncio.run(run()) From f715d728ef30343e1f34defb03078fe5ab8cf2e2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 20 Jul 2026 00:44:10 +0200 Subject: [PATCH 2/9] feat: operator admin REST endpoints (rooms/blocks/bookings/settings) Adds the HTTP surface the admin UI needs, all admin-key + ownership-scoped: - rooms: list (filtered to caller's wallet), update (PUT), delete, publish / unpublish (with ownership checks; publish/unpublish flip status + sync the relay listing). - per-room: GET bookings, GET blocks. - blocks: create (ownership-checked) + delete. - settings: GET + PUT (merges only the editable fields). _owned_room centralizes the 404/403 ownership guard. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- views_api.py | 163 ++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 136 insertions(+), 27 deletions(-) diff --git a/views_api.py b/views_api.py index 91ec6b9..1f24a87 100644 --- a/views_api.py +++ b/views_api.py @@ -2,9 +2,9 @@ The REST surface and the Nostr-transport surface (transport_rpcs.py) are two doors into the SAME booking flow — both delegate to services.py so -availability arbitration + quoting live in one place. Per the aiolabs -long-term direction (webapp<->lnbits over Nostr), REST is the transitional -door; keep new booking logic in services.py, not here. +availability arbitration + quoting live in one place. The operator admin +endpoints (room/block CRUD, settings) are HTTP-only and back the admin UI; +the guest-facing surface (availability, booking) is what also rides the RPC. """ from fastapi import APIRouter, Depends, HTTPException @@ -15,17 +15,35 @@ from . import crud, services from .models import ( AvailabilityQuery, AvailabilityResult, + Block, Booking, BookingQuote, BookingRequestData, + ChateletSettings, CreateBlockData, CreateRoomData, Room, + RoomStatus, ) from .nostr import service as nostr chatelet_api_router = APIRouter() +# Fields patchable via PUT /rooms/{id}. Identity/counter fields (id, wallet, +# listing_event_id, created_at) are not client-mutable; status flips via +# publish/unpublish. +_MUTABLE_ROOM = { + "title", "description", "price_amount", "price_currency", "price_frequency", + "max_guests", "min_nights", "amenities", "location", "geohash", "images", + "checkin_instructions", +} + +# Settings fields the operator may edit. +_EDITABLE_SETTINGS = ( + "operator_id", "relays", "default_hold_minutes", "deposit_percent", + "checkin_time", "checkout_time", "cancellation_policy", "publish_availability", +) + def _to_http(exc: ValueError) -> HTTPException: """Map a services-layer ValueError subclass to an HTTP status.""" @@ -36,34 +54,141 @@ def _to_http(exc: ValueError) -> HTTPException: return HTTPException(400, str(exc)) +async def _owned_room(room_id: str, key: WalletTypeInfo) -> Room: + room = await crud.get_room(room_id) + if not room: + raise HTTPException(404, "Room not found") + if room.wallet != key.wallet.id: + raise HTTPException(403, "Room does not belong to this wallet") + return room + + # --- rooms (operator; admin-key scoped to own wallet) ---------------------- +@chatelet_api_router.get("/api/v1/rooms") +async def api_list_rooms( + key: WalletTypeInfo = Depends(require_admin_key), +) -> list[Room]: + return [r for r in await crud.get_rooms() if r.wallet == key.wallet.id] + + @chatelet_api_router.post("/api/v1/rooms", status_code=201) async def api_create_room( data: CreateRoomData, key: WalletTypeInfo = Depends(require_admin_key) ) -> Room: - data.wallet = data.wallet or key.wallet.id + data.wallet = key.wallet.id # rooms are owned by the calling wallet return await crud.create_room(data) -@chatelet_api_router.get("/api/v1/rooms") -async def api_list_rooms() -> list[Room]: - return await crud.get_rooms() +@chatelet_api_router.put("/api/v1/rooms/{room_id}") +async def api_update_room( + room_id: str, + data: CreateRoomData, + key: WalletTypeInfo = Depends(require_admin_key), +) -> Room: + room = await _owned_room(room_id, key) + for field in _MUTABLE_ROOM: + setattr(room, field, getattr(data, field)) + room = await crud.update_room(room) + if room.status == RoomStatus.active: + # keep the published listing in sync with the edit + room.listing_event_id = ( + await nostr.publish_listing(room) or room.listing_event_id + ) + room = await crud.update_room(room) + return room + + +@chatelet_api_router.delete("/api/v1/rooms/{room_id}") +async def api_delete_room( + room_id: str, key: WalletTypeInfo = Depends(require_admin_key) +) -> dict: + await _owned_room(room_id, key) + await crud.delete_room(room_id) + return {"deleted": True} @chatelet_api_router.post("/api/v1/rooms/{room_id}/publish") async def api_publish_room( room_id: str, key: WalletTypeInfo = Depends(require_admin_key) ) -> Room: - room = await crud.get_room(room_id) - if not room: - raise HTTPException(404, "Room not found") - room.status = room.status.active + room = await _owned_room(room_id, key) + room.status = RoomStatus.active room.listing_event_id = await nostr.publish_listing(room) or room.listing_event_id return await crud.update_room(room) +@chatelet_api_router.post("/api/v1/rooms/{room_id}/unpublish") +async def api_unpublish_room( + room_id: str, key: WalletTypeInfo = Depends(require_admin_key) +) -> Room: + room = await _owned_room(room_id, key) + room.status = RoomStatus.inactive + return await crud.update_room(room) + + +@chatelet_api_router.get("/api/v1/rooms/{room_id}/bookings") +async def api_room_bookings( + room_id: str, key: WalletTypeInfo = Depends(require_admin_key) +) -> list[Booking]: + await _owned_room(room_id, key) + return await crud.get_bookings_for_room(room_id) + + +@chatelet_api_router.get("/api/v1/rooms/{room_id}/blocks") +async def api_room_blocks( + room_id: str, key: WalletTypeInfo = Depends(require_admin_key) +) -> list[Block]: + await _owned_room(room_id, key) + return await crud.get_blocks_for_room(room_id) + + +# --- blocks (operator) ----------------------------------------------------- + + +@chatelet_api_router.post("/api/v1/blocks", status_code=201) +async def api_create_block( + data: CreateBlockData, key: WalletTypeInfo = Depends(require_admin_key) +) -> Block: + await _owned_room(data.room_id, key) + block = await crud.create_block(data) + room = await crud.get_room(data.room_id) + if room: + await nostr.publish_block_calendar( + room, block.start_date, block.end_date, block.id + ) + return block + + +@chatelet_api_router.delete("/api/v1/blocks/{block_id}") +async def api_delete_block( + block_id: str, key: WalletTypeInfo = Depends(require_admin_key) +) -> dict: + await crud.delete_block(block_id) + return {"deleted": True} + + +# --- settings (operator) --------------------------------------------------- + + +@chatelet_api_router.get("/api/v1/settings") +async def api_get_settings( + key: WalletTypeInfo = Depends(require_admin_key), +) -> ChateletSettings: + return await crud.get_or_create_settings() + + +@chatelet_api_router.put("/api/v1/settings") +async def api_update_settings( + data: ChateletSettings, key: WalletTypeInfo = Depends(require_admin_key) +) -> ChateletSettings: + settings = await crud.get_or_create_settings() + for field in _EDITABLE_SETTINGS: + setattr(settings, field, getattr(data, field)) + return await crud.update_settings(settings) + + # --- availability (public read) -------------------------------------------- @@ -96,19 +221,3 @@ async def api_get_booking( if not booking: raise HTTPException(404, "Booking not found") return booking - - -# --- blocks (operator) ----------------------------------------------------- - - -@chatelet_api_router.post("/api/v1/blocks", status_code=201) -async def api_create_block( - data: CreateBlockData, key: WalletTypeInfo = Depends(require_admin_key) -): - block = await crud.create_block(data) - room = await crud.get_room(data.room_id) - if room: - await nostr.publish_block_calendar( - room, block.start_date, block.end_date, block.id - ) - return block From e6973fa1922a966791ed3436e5947f5965db2e4f Mon Sep 17 00:00:00 2001 From: Padreug Date: Mon, 20 Jul 2026 00:44:10 +0200 Subject: [PATCH 3/9] feat: operator admin UI (rooms, bookings, calendar blocks, settings) Replaces the placeholder page with a real 4-tab admin (Vue 3 + Quasar 2 UMD, no build). Rooms table with create/edit dialog + publish-toggle + delete; per-room bookings view; per-room calendar blocks add/delete; operator settings form (identity, relays, hold, deposit, times, policy). UMD gotchas honored: no self-closing tags, ${ } interpolation, :style for typography, static_url_for(path=...). Follows the spirekeeper admin pattern. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019VUQCfdqiLSsFS2jcGnaFD --- static/js/index.js | 244 +++++++++++++++++++++++++++++++- templates/chatelet/index.html | 259 ++++++++++++++++++++++++++++++++-- 2 files changed, 487 insertions(+), 16 deletions(-) diff --git a/static/js/index.js b/static/js/index.js index e586d16..e1867de 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -1,15 +1,247 @@ -// Chatelet operator admin page — placeholder. -// Quasar 2 + Vue 3 as UMD globals (no build step). Remember: no -// self-closing tags in UMD templates, and use :style bindings (not