Răsfoiți Sursa

Add Homestay.com service client

Implements _HomestayClient (ServiceClient protocol) with calendar read,
price and availability writes, and Playwright-based credential refresh.
ZiB 4 luni în urmă
părinte
comite
6c5a96e692

+ 1 - 0
pyproject.toml

@@ -15,6 +15,7 @@ dependencies = [
 
 [project.scripts]
 refresh-creds = "room_cal.service_api._scripts:refresh_cookies"
+refresh-homestay = "room_cal.service_api._scripts:refresh_homestay_cookies"
 booking-2fa = "room_cal.service_api._scripts:_trigger_booking_chat_auth_cli"
 room-cal = "room_cal.actions._cli:main"
 room-api = "room_cal.api._server:main"

+ 4 - 8
room_cal/_utils.py

@@ -154,11 +154,9 @@ class ServiceTaxes:
 class ServiceConfig:
   key: str
   name: str
-  listing_id: str = ''
-  hotel_id: str = ''
-  hotel_account_id: str = ''
-  host_account_id: str = ''
   room_id: str = ''
+  account_id: str = ''
+  hotel_id: str = ''
   rate_id: str = ''
   fees: ServiceFees = field(default_factory=ServiceFees)
   taxes: ServiceTaxes = field(default_factory=ServiceTaxes)
@@ -168,11 +166,9 @@ class ServiceConfig:
     return cls(
       key=key,
       name=d['name'],
-      listing_id=d.get('listing_id', ''),
-      hotel_id=str(d.get('hotel_id', '')),
-      hotel_account_id=str(d.get('hotel_account_id', '')),
-      host_account_id=str(d.get('host_account_id', '')),
       room_id=str(d.get('room_id', '')),
+      account_id=str(d.get('account_id', '')),
+      hotel_id=str(d.get('hotel_id', '')),
       rate_id=str(d.get('rate_id', '')),
       fees=ServiceFees.from_dict(d.get('fees', {})),
       taxes=ServiceTaxes.from_dict(d.get('taxes', {})),

+ 3 - 1
room_cal/api/_routes/services.py

@@ -2,11 +2,13 @@ from fastapi import APIRouter
 from room_cal._utils import services as _services
 from room_cal.service_api._airbnb import _AirbnbClient
 from room_cal.service_api._booking import _BookingClient
+from room_cal.service_api._homestay import _HomestayClient
 router = APIRouter()
 
 _CLIENT_CLASSES: dict[str, type] = {
   'airbnb': _AirbnbClient,
   'booking': _BookingClient,
+  'homestay': _HomestayClient,
 }
 
 
@@ -17,7 +19,7 @@ async def get_services():
       'key': s.key,
       'name': s.name,
       'supports_chat': hasattr(_CLIENT_CLASSES.get(s.key), 'get_thread'),
-      'host_account_id': s.host_account_id,
+      'account_id': s.account_id,
     }
     for s in _services()
   ]

+ 7 - 0
room_cal/config.json

@@ -29,6 +29,13 @@
       "fees": {
         "commission": 0.15
       }
+    },
+    "homestay": {
+      "name": "Homestay",
+      "fees": {
+        "commission": 0.16,
+        "client_fee": 0.0
+      }
     }
   },
   "rules": {

+ 2 - 2
room_cal/service_api/_airbnb.py

@@ -91,9 +91,9 @@ class _AirbnbClient:
     cookies = env.get('AIRBNB_COOKIES', '')
     if not cookies:
       raise ValueError(f'AIRBNB_COOKIES must be set in {env_file}')
-    listing_id = get_config().services['airbnb'].listing_id
+    listing_id = get_config().services['airbnb'].room_id
     if not listing_id:
-      raise ValueError('airbnb.listing_id must be set in config.json')
+      raise ValueError('airbnb.room_id must be set in config.json')
     return cls(cookies=cookies, listing_id=listing_id)
 
   def clear_cache(self) -> None:

+ 5 - 5
room_cal/service_api/_booking.py

@@ -151,7 +151,7 @@ class _BookingClient:
     cookies: str,
     session: str,
     hotel_id: int,
-    hotel_account_id: int,
+    account_id: int,
     room_id: int,
     rate_id: str,
     csrf_token: str | None = None,
@@ -160,7 +160,7 @@ class _BookingClient:
     self._cookies = cookies
     self._session = session
     self._hotel_id = hotel_id
-    self._hotel_account_id = hotel_account_id
+    self._account_id = account_id
     self._room_id = room_id
     self._rate_id = rate_id
     self._csrf_token = csrf_token
@@ -181,14 +181,14 @@ class _BookingClient:
     if missing_env:
       raise ValueError(f'Missing in {env_file}: {", ".join(missing_env)}')
     svc = get_config().services['booking']
-    missing_ids = [k for k in ('hotel_id', 'hotel_account_id', 'room_id', 'rate_id') if not getattr(svc, k)]
+    missing_ids = [k for k in ('hotel_id', 'account_id', 'room_id', 'rate_id') if not getattr(svc, k)]
     if missing_ids:
       raise ValueError(f'Missing from config.json booking: {", ".join(missing_ids)}')
     return cls(
       cookies=env['BOOKING_COOKIES'],
       session=env['BOOKING_SESSION'],
       hotel_id=int(svc.hotel_id),
-      hotel_account_id=int(svc.hotel_account_id),
+      account_id=int(svc.account_id),
       room_id=int(svc.room_id),
       rate_id=svc.rate_id,
       csrf_token=env.get('BOOKING_CSRF_TOKEN'),
@@ -279,7 +279,7 @@ class _BookingClient:
       'ses': self._session,
       'hotel_id': str(self._hotel_id),
       'lang': self._lang,
-      'hotel_account_id': str(self._hotel_account_id),
+      'hotel_account_id': str(self._account_id),
     }
     if extra:
       p.update(extra)

+ 202 - 0
room_cal/service_api/_homestay.py

@@ -0,0 +1,202 @@
+import asyncio
+import logging
+import os
+from datetime import date, timedelta
+
+import httpx
+
+from room_cal._utils import data_home, get_config, read_env
+from .credentials import is_stale, mark_stale
+from .types import DayInfo, Reservation, ServiceAuthError
+
+_log = logging.getLogger(__name__)
+_BASE = 'https://www.homestay.com'
+_AUTH_HINT = 'refresh-creds'
+
+
+class HomestayAuthError(ServiceAuthError):
+  pass
+
+
+def _fmt_date(d: str) -> str:
+  """Convert YYYY-MM-DD to 'D Mon YYYY' for Homestay form fields."""
+  dt = date.fromisoformat(d)
+  return f'{dt.day} {dt.strftime("%b")} {dt.year}'
+
+
+class _HomestayClient:
+  def __init__(self, cookies: str, csrf_token: str, room_id: str):
+    self._cookies = cookies
+    self._csrf_token = csrf_token
+    self._room_id = room_id
+    self._calendar_data: dict | None = None
+    self._calendar_inflight: asyncio.Task | None = None
+
+  @classmethod
+  def from_env(cls) -> '_HomestayClient':
+    env_file = data_home() / 'homestay.env'
+    env = {**read_env(env_file), **os.environ}
+    cookies = env.get('HOMESTAY_COOKIES', '')
+    csrf_token = env.get('HOMESTAY_CSRF_TOKEN', '')
+    if not cookies:
+      raise ValueError(f'HOMESTAY_COOKIES must be set in {env_file}')
+    room_id = get_config().services['homestay'].room_id
+    if not room_id:
+      raise ValueError('homestay.room_id must be set in config.json')
+    return cls(cookies=cookies, csrf_token=csrf_token, room_id=room_id)
+
+  def _headers(self, extra: dict | None = None) -> dict[str, str]:
+    h = {
+      'Cookie': self._cookies,
+      'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:152.0) Gecko/20100101 Firefox/152.0',
+      'Accept': 'application/json, text/javascript, */*; q=0.01',
+      'Accept-Language': 'en-US,en;q=0.9',
+      'X-Requested-With': 'XMLHttpRequest',
+      'X-CSRF-Token': self._csrf_token,
+    }
+    if extra:
+      h.update(extra)
+    return h
+
+  def _check_auth(self, r: httpx.Response) -> None:
+    if r.status_code in (401, 403) or r.is_redirect:
+      mark_stale('homestay')
+      raise HomestayAuthError(f'Homestay session expired — run: {_AUTH_HINT}')
+
+  async def _fetch_calendar(self) -> dict:
+    async with httpx.AsyncClient(follow_redirects=False) as client:
+      r = await client.get(f'{_BASE}/homestay_rooms/calendar', headers=self._headers())
+    self._check_auth(r)
+    if r.status_code >= 400:
+      mark_stale('homestay')
+      raise HomestayAuthError(f'Homestay calendar fetch failed (HTTP {r.status_code}) — run: {_AUTH_HINT}')
+    return r.json()
+
+  async def _get_calendar_data(self) -> dict:
+    if self._calendar_data is not None:
+      return self._calendar_data
+
+    if self._calendar_inflight is None or self._calendar_inflight.done():
+      self._calendar_inflight = asyncio.create_task(self._fetch_calendar())
+    data = await self._calendar_inflight
+
+    all_empty = (
+      not data.get('leisure_bookings')
+      and not data.get('unavailabilities')
+      and not data.get('room_price_overrides')
+    )
+    if all_empty:
+      _log.warning('room_cal.service_api._homestay: empty calendar; retrying')
+      await asyncio.sleep(1)
+      data = await self._fetch_calendar()
+      if not data.get('leisure_bookings') and not data.get('unavailabilities') and not data.get('room_price_overrides'):
+        _log.warning('room_cal.service_api._homestay: empty calendar on retry; using empty response')
+
+    self._calendar_data = data
+    return data
+
+  def _build_day_map(self, data: dict, date_from: str, date_to: str) -> dict[str, DayInfo]:
+    d_from = date.fromisoformat(date_from)
+    d_to = date.fromisoformat(date_to)
+
+    price_map: dict[str, int] = {}
+    for override in data.get('room_price_overrides', []):
+      d = override.get('date')
+      amount = (override.get('nightly_rate') or {}).get('amount')
+      if d and amount is not None:
+        price_map[d] = amount // 100
+
+    booked: set[str] = set()
+    for booking in data.get('leisure_bookings', []):
+      ci = booking.get('start_date') or booking.get('check_in', '')
+      co = booking.get('end_date') or booking.get('check_out', '')
+      if not (ci and co):
+        continue
+      cur = date.fromisoformat(ci)
+      end = date.fromisoformat(co)
+      while cur < end:
+        booked.add(cur.isoformat())
+        cur += timedelta(days=1)
+
+    closed: set[str] = set()
+    for unav in data.get('unavailabilities', []):
+      start_s = unav.get('start_date')
+      end_s = unav.get('end_date')
+      if not (start_s and end_s):
+        continue
+      cur = date.fromisoformat(start_s)
+      end = date.fromisoformat(end_s)
+      while cur <= end:
+        closed.add(cur.isoformat())
+        cur += timedelta(days=1)
+
+    result: dict[str, DayInfo] = {}
+    cur = d_from
+    while cur <= d_to:
+      ds = cur.isoformat()
+      is_booked = ds in booked
+      is_closed = ds in closed and not is_booked
+      result[ds] = DayInfo(
+        date=ds,
+        price=price_map.get(ds),
+        available=not is_booked and not is_closed,
+        booked=is_booked,
+        rooms_to_sell=None,
+      )
+      cur += timedelta(days=1)
+    return result
+
+  async def get_calendar(self, date_from: str, date_to: str) -> dict[str, DayInfo]:
+    if is_stale('homestay'):
+      raise HomestayAuthError(f'Homestay credentials are stale — run: {_AUTH_HINT}')
+    data = await self._get_calendar_data()
+    return self._build_day_map(data, date_from, date_to)
+
+  async def get_reservations(self, date_from: str, date_to: str) -> list[Reservation]:
+    # leisure_bookings field structure not yet validated against real data
+    return []
+
+  async def _post_form(self, path: str, fields: dict) -> dict:
+    body = {
+      'utf8': '✓',
+      '_method': 'put',
+      'authenticity_token': self._csrf_token,
+      'room_id': self._room_id,
+      **fields,
+      'commit': 'Save Details',
+    }
+    async with httpx.AsyncClient(follow_redirects=False) as client:
+      r = await client.post(f'{_BASE}{path}', data=body, headers=self._headers())
+    self._check_auth(r)
+    if r.status_code >= 400:
+      mark_stale('homestay')
+      raise HomestayAuthError(f'Homestay write failed (HTTP {r.status_code}) — run: {_AUTH_HINT}')
+    if 'application/json' not in r.headers.get('content-type', ''):
+      # Session expired — Homestay returns 200 with a JS redirect body instead of a proper 302
+      mark_stale('homestay')
+      raise HomestayAuthError(f'Homestay write session expired (JS redirect) — run: {_AUTH_HINT}')
+    data = r.json()
+    self._calendar_data = data
+    self._calendar_inflight = None
+    return data
+
+  async def set_price(self, d: str, price: int) -> None:
+    if is_stale('homestay'):
+      raise HomestayAuthError(f'Homestay credentials are stale — run: {_AUTH_HINT}')
+    fmt = _fmt_date(d)
+    await self._post_form('/homestay_rooms/update_price_overrides', {
+      'start_date': fmt,
+      'end_date': fmt,
+      'nightly_rate': str(price),
+      'single_nightly_rate': str(price),
+    })
+
+  async def set_availability(self, d: str, available: bool) -> None:
+    if is_stale('homestay'):
+      raise HomestayAuthError(f'Homestay credentials are stale — run: {_AUTH_HINT}')
+    fmt = _fmt_date(d)
+    await self._post_form('/homestay_rooms/update_calendar', {
+      'start_date': fmt,
+      'end_date': fmt,
+      'mark_as': 'available' if available else 'unavailable',
+    })

+ 11 - 0
room_cal/service_api/_scripts.py

@@ -57,6 +57,17 @@ def refresh_airbnb_cookies() -> None:
     raise ServiceAuthError(f'Airbnb session expired — re-run without HEADLESS to re-authenticate (exit {e.returncode})') from None
 
 
+def refresh_homestay_cookies() -> None:
+  ts_dir = _ts_dir()
+  _ensure_installed(ts_dir)
+  env = _npm_env()
+  try:
+    subprocess.run(['npm', 'run', 'refresh-homestay'], cwd=ts_dir, env=env, check=True)
+  except subprocess.CalledProcessError as e:
+    from .types import ServiceAuthError
+    raise ServiceAuthError(f'Homestay session expired — re-run without HEADLESS to re-authenticate (exit {e.returncode})') from None
+
+
 def trigger_booking_chat_auth() -> None:
   """Run booking-2fa; raises ServiceAuthError on failure. Safe to call from within the server."""
   ts_dir = _ts_dir()

+ 2 - 0
room_cal/service_api/client.py

@@ -3,6 +3,7 @@ from datetime import date
 
 from ._airbnb import _AirbnbClient
 from ._booking import _BookingClient
+from ._homestay import _HomestayClient
 from ._protocols import ChatClient, ServiceClient
 from .types import DayInfo
 
@@ -21,6 +22,7 @@ class ServiceAPI:
     self._clients: dict[str, ServiceClient] = clients if clients is not None else {
       'airbnb': _AirbnbClient.from_env(),
       'booking': _BookingClient.from_env(),
+      'homestay': _HomestayClient.from_env(),
     }
 
   def client(self, key: str) -> ServiceClient:

+ 74 - 0
room_cal/service_api/typescript/homestay/refresh-cookies.ts

@@ -0,0 +1,74 @@
+import * as path from 'path';
+import * as os from 'os';
+import { filterCookies, parseOutputMode } from '../shared/cookie-utils';
+import { handleOutput } from '../shared/api-client';
+import { openPersistentContext, profileDir } from '../shared/browser';
+import type { BrowserContext } from 'playwright';
+import type { CredentialsPayload } from '../shared/types';
+
+const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
+const ENV_PATH = path.join(DATA_HOME, 'homestay.env');
+const LISTING_URL = 'https://www.homestay.com/listing';
+const CALENDAR_URL = 'https://www.homestay.com/listing#calendar-pane';
+const STRIP = new Set(['_ga', '_gid', '_ga_2EDPYD9GBG', '_ga_7KE177XPGB', '_fbp', '_gcl_au']);
+
+function attachCsrfCapture(ctx: BrowserContext, onCapture: (token: string) => void): void {
+  ctx.on('request', req => {
+    if (!req.url().includes('homestay.com')) return;
+    const v = req.headers()['x-csrf-token'];
+    if (v) onCapture(v);
+  });
+}
+
+export async function refresh(mode: string): Promise<CredentialsPayload> {
+  const profile = profileDir('homestay', mode);
+  let capturedCsrf: string | null = null;
+  const context = await openPersistentContext(profile, false);
+  attachCsrfCapture(context, t => { if (!capturedCsrf) capturedCsrf = t; });
+  const page = context.pages()[0] ?? await context.newPage();
+
+  await page.goto(LISTING_URL, { waitUntil: 'domcontentloaded', timeout: 20_000 }).catch(() => {});
+  const metaCsrf = await page.locator('meta[name="csrf-token"]').getAttribute('content').catch(() => null);
+  if (metaCsrf) capturedCsrf = metaCsrf;
+
+  if (!capturedCsrf) {
+    // Not logged in — wait for user to authenticate.
+    await page.waitForURL('**/listing**', { timeout: 300_000 });
+
+    // Navigate to #calendar-pane to trigger calendar XHRs and capture CSRF.
+    const calXhr = page.waitForRequest(
+      req => req.url().includes('/homestay_rooms/calendar'),
+      { timeout: 30_000 },
+    ).catch(() => null);
+    await page.goto(CALENDAR_URL, { waitUntil: 'domcontentloaded' });
+    await calXhr;
+
+    if (capturedCsrf) console.error('Login detected — CSRF captured from calendar XHR.');
+    else console.error('Warning: CSRF not captured after login — HOMESTAY_CSRF_TOKEN unchanged.');
+  } else {
+    console.error('Session valid — CSRF captured.');
+  }
+
+  console.error('Capturing cookies...');
+  const all = await context.cookies();
+  await context.close();
+
+  const cookieString = filterCookies(all, STRIP, 'homestay.com');
+  if (!cookieString) throw new Error('No Homestay cookies found — are you logged in?');
+
+  const credentials: CredentialsPayload = { HOMESTAY_COOKIES: cookieString };
+  if (capturedCsrf) credentials.HOMESTAY_CSRF_TOKEN = capturedCsrf;
+
+  console.error(`Captured ${all.filter(c => c.domain.includes('homestay.com') && !STRIP.has(c.name)).length} cookies.`);
+  return credentials;
+}
+
+async function main(): Promise<void> {
+  const mode = parseOutputMode(process.argv);
+  const credentials = await refresh(mode);
+  await handleOutput('homestay', credentials, mode, ENV_PATH);
+}
+
+if (require.main === module) {
+  main().catch(err => { console.error(err.message); process.exit(1); });
+}

+ 1 - 0
room_cal/service_api/typescript/package.json

@@ -5,6 +5,7 @@
   "scripts": {
     "refresh-airbnb": "tsx airbnb/refresh-cookies.ts",
     "refresh-booking": "tsx booking/refresh-cookies.ts",
+    "refresh-homestay": "tsx homestay/refresh-cookies.ts",
     "booking-2fa": "tsx booking/trigger-2fa.ts",
     "refresh-all": "tsx scripts/refresh-all.ts",
     "install-browsers": "tsx scripts/install-browsers.ts",

+ 4 - 1
room_cal/service_api/typescript/scripts/refresh-all.ts

@@ -1,5 +1,6 @@
 import { refresh as refreshAirbnb } from '../airbnb/refresh-cookies';
 import { refresh as refreshBooking } from '../booking/refresh-cookies';
+import { refresh as refreshHomestay } from '../homestay/refresh-cookies';
 import { handleOutput } from '../shared/api-client';
 import { parseOutputMode } from '../shared/cookie-utils';
 import * as path from 'path';
@@ -9,13 +10,15 @@ const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.lo
 const mode = parseOutputMode(process.argv);
 
 async function main(): Promise<void> {
-  const [airbnbCreds, bookingCreds] = await Promise.all([
+  const [airbnbCreds, bookingCreds, homestayCreds] = await Promise.all([
     refreshAirbnb(mode),
     refreshBooking(mode),
+    refreshHomestay(mode),
   ]);
   await Promise.all([
     handleOutput('airbnb', airbnbCreds, mode, path.join(DATA_HOME, 'airbnb.env')),
     handleOutput('booking', bookingCreds, mode, path.join(DATA_HOME, 'booking.env')),
+    handleOutput('homestay', homestayCreds, mode, path.join(DATA_HOME, 'homestay.env')),
   ]);
   if (mode !== 'stdout') console.error('\nAll credentials refreshed.');
 }

+ 167 - 0
tests/test_homestay.py

@@ -0,0 +1,167 @@
+import pytest
+from datetime import date, timedelta
+
+from room_cal.service_api._homestay import _HomestayClient, HomestayAuthError, _fmt_date
+
+
+# ── helpers ────────────────────────────────────────────────────────────────────
+
+def _client(raw_data: dict) -> _HomestayClient:
+  c = _HomestayClient(cookies='x', csrf_token='y', room_id='354616')
+  c._calendar_data = raw_data
+  return c
+
+
+# ── _fmt_date ──────────────────────────────────────────────────────────────────
+
+def test_fmt_date_single_digit_day():
+  assert _fmt_date('2026-07-05') == '5 Jul 2026'
+
+def test_fmt_date_double_digit_day():
+  assert _fmt_date('2026-12-27') == '27 Dec 2026'
+
+def test_fmt_date_jan():
+  assert _fmt_date('2027-01-01') == '1 Jan 2027'
+
+
+# ── _build_day_map ─────────────────────────────────────────────────────────────
+
+_EMPTY = {'unavailabilities': [], 'room_price_overrides': [], 'leisure_bookings': []}
+
+
+def test_build_day_map_returns_all_dates_in_range():
+  c = _client(_EMPTY)
+  result = c._build_day_map(_EMPTY, '2026-08-01', '2026-08-03')
+  assert set(result.keys()) == {'2026-08-01', '2026-08-02', '2026-08-03'}
+
+
+def test_build_day_map_default_state():
+  c = _client(_EMPTY)
+  day = c._build_day_map(_EMPTY, '2026-08-01', '2026-08-01')['2026-08-01']
+  assert day.available is True
+  assert day.booked is False
+  assert day.price is None
+  assert day.rooms_to_sell is None
+
+
+def test_build_day_map_unavailability_closed():
+  data = {**_EMPTY, 'unavailabilities': [{'start_date': '2026-08-01', 'end_date': '2026-08-05'}]}
+  c = _client(data)
+  result = c._build_day_map(data, '2026-07-31', '2026-08-06')
+  assert result['2026-07-31'].available is True
+  assert result['2026-08-01'].available is False
+  assert result['2026-08-01'].booked is False
+  assert result['2026-08-05'].available is False
+  assert result['2026-08-06'].available is True
+
+
+def test_build_day_map_price_cents_to_usd():
+  data = {**_EMPTY, 'room_price_overrides': [
+    {'date': '2026-08-10', 'nightly_rate': {'amount': 8300, 'currency': 'USD'}},
+  ]}
+  c = _client(data)
+  day = c._build_day_map(data, '2026-08-10', '2026-08-10')['2026-08-10']
+  assert day.price == 83
+  assert day.available is True
+
+
+def test_build_day_map_booking_exclusive_end():
+  data = {**_EMPTY, 'leisure_bookings': [{'start_date': '2026-09-01', 'end_date': '2026-09-03', 'id': 99}]}
+  c = _client(data)
+  result = c._build_day_map(data, '2026-08-31', '2026-09-04')
+  assert result['2026-08-31'].booked is False
+  assert result['2026-09-01'].booked is True
+  assert result['2026-09-01'].available is False
+  assert result['2026-09-02'].booked is True
+  assert result['2026-09-03'].booked is False  # checkout day — exclusive
+  assert result['2026-09-04'].booked is False
+
+
+def test_build_day_map_booked_overrides_closed():
+  data = {
+    'unavailabilities': [{'start_date': '2026-09-01', 'end_date': '2026-09-05'}],
+    'room_price_overrides': [],
+    'leisure_bookings': [{'start_date': '2026-09-02', 'end_date': '2026-09-04', 'id': 1}],
+  }
+  c = _client(data)
+  result = c._build_day_map(data, '2026-09-01', '2026-09-05')
+  assert result['2026-09-01'].available is False and result['2026-09-01'].booked is False
+  assert result['2026-09-02'].booked is True
+  assert result['2026-09-03'].booked is True
+  assert result['2026-09-04'].booked is False  # exclusive end — falls back to closed
+  assert result['2026-09-04'].available is False
+
+
+async def test_get_reservations_always_empty():
+  data = {**_EMPTY, 'leisure_bookings': [
+    {'start_date': '2026-08-01', 'end_date': '2026-08-05', 'id': 1},
+  ]}
+  c = _client(data)
+  assert await c.get_reservations('2026-08-01', '2026-09-01') == []
+
+
+# ── live tests (require homestay.env + config.json room_id) ───────────────────
+
+@pytest.fixture(scope='module')
+def live():
+  try:
+    return _HomestayClient.from_env()
+  except (ValueError, KeyError):
+    pytest.skip('homestay credentials not configured')
+
+
+async def test_get_calendar_returns_full_range(live):
+  start = (date.today() + timedelta(days=1)).isoformat()
+  end = (date.today() + timedelta(days=30)).isoformat()
+  result = await live.get_calendar(start, end)
+  assert len(result) == 30
+  for ds, day in result.items():
+    assert day.date == ds
+    assert isinstance(day.available, bool)
+    assert isinstance(day.booked, bool)
+    assert day.price is None or isinstance(day.price, int)
+
+
+async def test_get_calendar_uses_cache(live):
+  start = (date.today() + timedelta(days=1)).isoformat()
+  end = (date.today() + timedelta(days=7)).isoformat()
+  first = await live.get_calendar(start, end)
+  second = await live.get_calendar(start, end)
+  assert first == second
+
+
+async def test_set_price_roundtrip(live):
+  start = date.today() + timedelta(days=1)
+  end = start + timedelta(days=30)
+  cal = await live.get_calendar(start.isoformat(), end.isoformat())
+  target = next(
+    (ds for ds, d in sorted(cal.items()) if d.available and not d.booked and d.price is not None),
+    None,
+  )
+  if target is None:
+    pytest.skip('no available priced date found in the next 30 days')
+
+  original = cal[target].price
+  await live.set_price(target, original - 1)
+  assert (await live.get_calendar(target, target))[target].price == original - 1
+
+  await live.set_price(target, original)
+  assert (await live.get_calendar(target, target))[target].price == original
+
+
+async def test_set_availability_roundtrip(live):
+  start = date.today() + timedelta(days=7)
+  end = start + timedelta(days=30)
+  cal = await live.get_calendar(start.isoformat(), end.isoformat())
+  target = next(
+    (ds for ds, d in sorted(cal.items()) if d.available and not d.booked),
+    None,
+  )
+  if target is None:
+    pytest.skip('no available unbooked date found in the next 30 days')
+
+  await live.set_availability(target, False)
+  assert (await live.get_calendar(target, target))[target].available is False
+
+  await live.set_availability(target, True)
+  assert (await live.get_calendar(target, target))[target].available is True