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>
133 lines
4.4 KiB
Python
133 lines
4.4 KiB
Python
"""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
|