Przeglądaj źródła

remove Homestay service client (Python + TypeScript)

ZiB 4 miesięcy temu
rodzic
commit
187bb1eb0f

+ 0 - 15
room_cal/config-all.json

@@ -113,21 +113,6 @@
         "flat_tax": 0
         "flat_tax": 0
       }
       }
     },
     },
-    "homestay": {
-      "name": "Homestay",
-      "room_id": "",
-      "account_id": "",
-      "hotel_id": "",
-      "rate_id": "",
-      "disabled": true,
-      "fees": {
-        "commission": 0.0,
-        "client_fee": 0.16
-      },
-      "taxes": {
-        "flat_tax": 0
-      }
-    }
   },
   },
   "calendars": [
   "calendars": [
     {
     {

+ 0 - 7
room_cal/config.json

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

+ 0 - 220
room_cal/service_api/_homestay.py

@@ -1,220 +0,0 @@
-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',
-      'Referer': f'{_BASE}/listing',
-      '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',
-        params={'room_id': self._room_id},
-        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}')
-    if 'application/json' not in r.headers.get('content-type', ''):
-      mark_stale('homestay')
-      raise HomestayAuthError(
-        f'Homestay session expired (HTTP {r.status_code}, content-type: {r.headers.get("content-type", "none")!r}, body: {r.text[:200]!r}) — 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 probe(self) -> None:
-    from .credentials import check_credentials
-    if is_stale('homestay'):
-      raise HomestayAuthError(f'Homestay credentials are stale — run: {_AUTH_HINT}')
-    info = check_credentials('homestay')
-    if not info['valid']:
-      raise HomestayAuthError(f'Homestay credentials missing — run: {_AUTH_HINT}')
-
-  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',
-    })

+ 0 - 15
room_cal/service_api/_scripts.py

@@ -71,20 +71,6 @@ def refresh_airbnb_cookies() -> None:
   _log.info('airbnb: credential refresh complete')
   _log.info('airbnb: credential refresh complete')
 
 
 
 
-def refresh_homestay_cookies() -> None:
-  ts_dir = _ts_dir()
-  _ensure_installed(ts_dir)
-  _log.info('homestay: starting credential refresh')
-  env = _npm_env()
-  try:
-    subprocess.run(['npm', 'run', 'refresh-homestay'], cwd=ts_dir, env=env, check=True)
-  except subprocess.CalledProcessError as e:
-    _log.error('homestay: credential refresh failed (exit %d)', e.returncode)
-    from .types import ServiceAuthError
-    raise ServiceAuthError(f'Homestay session expired — re-run without HEADLESS to re-authenticate (exit {e.returncode})') from None
-  _log.info('homestay: credential refresh complete')
-
-
 def trigger_booking_chat_auth() -> None:
 def trigger_booking_chat_auth() -> None:
   """Run booking-2fa; raises ServiceAuthError on failure. Safe to call from within the server."""
   """Run booking-2fa; raises ServiceAuthError on failure. Safe to call from within the server."""
   ts_dir = _ts_dir()
   ts_dir = _ts_dir()
@@ -112,7 +98,6 @@ def refresh_service(key: str) -> None:
   handlers = {
   handlers = {
     'booking': refresh_booking_cookies,
     'booking': refresh_booking_cookies,
     'airbnb': refresh_airbnb_cookies,
     'airbnb': refresh_airbnb_cookies,
-    'homestay': refresh_homestay_cookies,
   }
   }
   fn = handlers.get(key)
   fn = handlers.get(key)
   if fn is None:
   if fn is None:

+ 0 - 2
room_cal/service_api/client.py

@@ -4,7 +4,6 @@ from datetime import date
 
 
 from ._airbnb import _AirbnbClient
 from ._airbnb import _AirbnbClient
 from ._booking import _BookingClient
 from ._booking import _BookingClient
-from ._homestay import _HomestayClient
 from ._protocols import ChatClient, ServiceClient
 from ._protocols import ChatClient, ServiceClient
 from .types import DayInfo, ServiceAuthError
 from .types import DayInfo, ServiceAuthError
 
 
@@ -31,7 +30,6 @@ class ServiceAPI:
     for key, factory in {
     for key, factory in {
       'airbnb': _AirbnbClient.from_env,
       'airbnb': _AirbnbClient.from_env,
       'booking': _BookingClient.from_env,
       'booking': _BookingClient.from_env,
-      'homestay': _HomestayClient.from_env,
     }.items():
     }.items():
       if (cfg := svc_cfg.get(key)) and cfg.disabled:
       if (cfg := svc_cfg.get(key)) and cfg.disabled:
         _log.info('service %s disabled by config', key)
         _log.info('service %s disabled by config', key)

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

@@ -1,74 +0,0 @@
-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); });
-}

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

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

+ 5 - 17
room_cal/service_api/typescript/scripts/refresh-all.ts

@@ -1,33 +1,21 @@
 import { refresh as refreshAirbnb } from '../airbnb/refresh-cookies';
 import { refresh as refreshAirbnb } from '../airbnb/refresh-cookies';
 import { refresh as refreshBooking } from '../booking/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 { handleOutput } from '../shared/api-client';
 import { parseOutputMode } from '../shared/cookie-utils';
 import { parseOutputMode } from '../shared/cookie-utils';
 import * as path from 'path';
 import * as path from 'path';
 import * as os from 'os';
 import * as os from 'os';
 
 
-// Keep in sync with services[*].disabled in config.json.
-// Test: tests/test_refresh_cookies.py::test_refresh_all_flags_match_config
-const ENABLED: Record<string, boolean> = {
-  airbnb: true,
-  booking: true,
-  homestay: false,
-};
-
 const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
 const DATA_HOME = process.env.ROOM_CAL_DATA_HOME ?? path.join(os.homedir(), '.local', 'room_cal');
 const mode = parseOutputMode(process.argv);
 const mode = parseOutputMode(process.argv);
 
 
 async function main(): Promise<void> {
 async function main(): Promise<void> {
-  const results = await Promise.all([
-    ENABLED.airbnb  ? refreshAirbnb(mode)  : Promise.resolve(null),
-    ENABLED.booking ? refreshBooking(mode) : Promise.resolve(null),
-    ENABLED.homestay ? refreshHomestay(mode) : Promise.resolve(null),
+  const [airbnbCreds, bookingCreds] = await Promise.all([
+    refreshAirbnb(mode),
+    refreshBooking(mode),
   ]);
   ]);
-  const [airbnbCreds, bookingCreds, homestayCreds] = results;
   await Promise.all([
   await Promise.all([
-    ENABLED.airbnb   && handleOutput('airbnb',   airbnbCreds!,   mode, path.join(DATA_HOME, 'airbnb.env')),
-    ENABLED.booking  && handleOutput('booking',  bookingCreds!,  mode, path.join(DATA_HOME, 'booking.env')),
-    ENABLED.homestay && handleOutput('homestay', homestayCreds!, mode, path.join(DATA_HOME, 'homestay.env')),
+    handleOutput('airbnb',  airbnbCreds,  mode, path.join(DATA_HOME, 'airbnb.env')),
+    handleOutput('booking', bookingCreds, mode, path.join(DATA_HOME, 'booking.env')),
   ]);
   ]);
   if (mode !== 'stdout') console.error('\nAll credentials refreshed.');
   if (mode !== 'stdout') console.error('\nAll credentials refreshed.');
 }
 }

+ 0 - 167
tests/test_homestay.py

@@ -1,167 +0,0 @@
-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