feat(api): keyless unavailable-ranges feed for the guest calendar

GET /api/v1/public/rooms/{id}/unavailable?start=&end= and its RPC twin
chatelet_room_unavailable return the nights a guest cannot book over a
window (default today → +365 d, capped at 400 d): occupying bookings —
live holds included, so the feed always agrees with POST /availability —
and manual blocks, clipped, sorted and coalesced into anonymous half-open
spans. Adjacent spans merge on purpose so a guest cannot tell where one
occupant's dates end and the next begin, nor a block from a stay.

is_available now uses the OCCUPYING_STATUSES constant it was inlining.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-09-16 11:45:51 +02:00
commit feaaef93e3
8 changed files with 278 additions and 8 deletions

View file

@ -0,0 +1,133 @@
"""Guest calendar feed: merged, anonymous occupied spans for one room."""
import asyncio
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import HTTPException
from .. import crud, services, transport_rpcs, views_api
from ..models import RoomStatus
from .conftest import make_room
# --- merge_ranges (pure) ---------------------------------------------------
def test_merge_sorts_and_coalesces_overlap_and_adjacency():
spans = [
("2026-10-10", "2026-10-12"),
("2026-10-01", "2026-10-04"),
("2026-10-04", "2026-10-06"), # adjacent to the first: one span
("2026-10-11", "2026-10-15"), # overlaps the 10-12 stay
]
out = crud.merge_ranges(spans, "2026-09-01", "2027-09-01")
assert out == [("2026-10-01", "2026-10-06"), ("2026-10-10", "2026-10-15")]
def test_merge_clips_to_window_and_drops_outside():
spans = [
("2026-08-20", "2026-09-05"), # straddles the window start
("2026-12-28", "2027-01-10"), # straddles the window end
("2026-07-01", "2026-07-03"), # entirely before
("2027-02-01", "2027-02-03"), # entirely after
]
out = crud.merge_ranges(spans, "2026-09-01", "2027-01-01")
assert out == [("2026-09-01", "2026-09-05"), ("2026-12-28", "2027-01-01")]
def test_merge_empty():
assert crud.merge_ranges([], "2026-09-01", "2027-01-01") == []
# --- services.get_unavailable_ranges ----------------------------------------
def _patch(monkeypatch, room, spans):
seen: dict = {}
async def gr(_):
return room
async def occupied(room_id, start, end):
seen.update(room_id=room_id, start=start, end=end)
return spans
monkeypatch.setattr(crud, "get_room", gr)
monkeypatch.setattr(crud, "get_occupied_ranges", occupied)
return seen
def test_defaults_to_today_plus_365(monkeypatch):
seen = _patch(monkeypatch, make_room("a", status=RoomStatus.active), [])
out = asyncio.run(services.get_unavailable_ranges("a"))
today = datetime.now(timezone.utc).date()
assert out.start == today.isoformat()
assert out.end == (today + timedelta(days=365)).isoformat()
assert (seen["start"], seen["end"]) == (out.start, out.end)
assert out.ranges == []
def test_explicit_window_and_shape(monkeypatch):
_patch(
monkeypatch,
make_room("a", status=RoomStatus.active),
[("2026-10-05", "2026-10-07")],
)
out = asyncio.run(services.get_unavailable_ranges("a", "2026-10-01", "2026-11-01"))
assert (out.room_id, out.start, out.end) == ("a", "2026-10-01", "2026-11-01")
assert [(r.start, r.end) for r in out.ranges] == [("2026-10-05", "2026-10-07")]
@pytest.mark.parametrize(
("start", "end"),
[
("2026-10-10", "2026-10-10"), # empty window
("2026-10-10", "2026-10-01"), # reversed
("2026-01-01", "2027-03-01"), # > 400 days
("not-a-date", None),
],
)
def test_window_validation(monkeypatch, start, end):
_patch(monkeypatch, make_room("a", status=RoomStatus.active), [])
with pytest.raises(ValueError):
asyncio.run(services.get_unavailable_ranges("a", start, end))
def test_inactive_room_is_not_found(monkeypatch):
_patch(monkeypatch, make_room("a", status=RoomStatus.inactive), [])
with pytest.raises(services.NotFound):
asyncio.run(services.get_unavailable_ranges("a"))
# --- both doors -------------------------------------------------------------
def test_http_and_rpc_doors_agree(monkeypatch):
_patch(
monkeypatch,
make_room("a", status=RoomStatus.active),
[("2026-10-05", "2026-10-07")],
)
http = asyncio.run(
views_api.api_public_room_unavailable("a", "2026-10-01", "2026-11-01")
)
rpc = asyncio.run(
transport_rpcs.handle_room_unavailable(
None,
transport_rpcs.NostrRpcRequest(
rpc_name="chatelet_room_unavailable",
request_id="req-1",
body={"room_id": "a", "start": "2026-10-01", "end": "2026-11-01"},
sender_pubkey="ab" * 32,
),
)
)
assert rpc == http.dict()
assert rpc["ranges"] == [{"start": "2026-10-05", "end": "2026-10-07"}]
def test_http_maps_errors(monkeypatch):
_patch(monkeypatch, make_room("a", status=RoomStatus.inactive), [])
with pytest.raises(HTTPException) as e:
asyncio.run(views_api.api_public_room_unavailable("a", None, None))
assert e.value.status_code == 404