| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320 |
- import pytest
- from datetime import date, timedelta
- from room_cal.closed import Closed, ClosedData
- from room_cal.discount import Discount, DiscountData, DiscountType
- from room_cal.pricing import CalPrice
- from room_cal.reservations import Reservation
- from room_cal.user_cal import UserCal
- from room_cal._utils import filter_events, iter_dates, services as _services
- from room_cal.actions.fetch_user_cal import fetch_user_cal
- from room_cal.actions.fetch_closed import fetch_closed
- from room_cal.actions.fetch_prices import fetch_prices
- from room_cal.actions.push_prices import push_prices
- from room_cal.actions.apply_discounts import apply_discounts
- from room_cal.actions.diff import diff
- # ── mock / stub helpers ────────────────────────────────────────────────────────
- class _MockAPI:
- """Stub ServiceAPI: all reads return empty, all writes are no-ops."""
- async def get_prices_range(self, start, end): return {}
- async def get_prices(self, d): return {}
- async def set_prices(self, d, prices): pass
- async def get_available(self, d): return True
- async def set_available(self, d, available): pass
- async def get_closed_by_date(self, start, end): return {}
- async def get_reservations(self, start, end): return []
- class _RecordingAPI(_MockAPI):
- """_MockAPI that records write calls for inspection."""
- def __init__(self):
- self.set_prices_calls: list[tuple[date, dict]] = []
- self.set_available_calls: list[tuple[date, bool]] = []
- async def set_prices(self, d, prices):
- self.set_prices_calls.append((d, dict(prices)))
- async def set_available(self, d, available):
- self.set_available_calls.append((d, available))
- class _ClosedReadAPI(_MockAPI):
- """_MockAPI whose get_closed_by_date returns a caller-supplied mapping."""
- def __init__(self, closed_by_date: dict[str, list[str]]):
- self._closed = closed_by_date
- async def get_closed_by_date(self, start, end):
- return {
- ds: svcs for ds, svcs in self._closed.items()
- if start.isoformat() <= ds <= end.isoformat()
- }
- async def _delete_price_event(cal: CalPrice, target: date) -> None:
- """Delete the price event for target (if present)."""
- events = await cal._fetch_date_range(target, target)
- for e in filter_events(events, 'Price: $', all_day=True, require_x_data=False):
- await cal._backend.delete(e.uid)
- # ── fetch_user_cal ─────────────────────────────────────────────────────────────
- @pytest.mark.asyncio
- async def test_fetch_user_cal_closed():
- """House closed events → Room Closed after import; removed on re-import."""
- ucal = UserCal()
- closed_cal = Closed()
- scan_start = date.today() - timedelta(days=80)
- scan_end = date.today() - timedelta(days=75)
- occupied = {e.start.date() for e in await closed_cal.list(scan_start, scan_end)}
- free = [d for d in iter_dates(scan_start, scan_end) if d not in occupied]
- assert len(free) >= 1, "Need at least 1 free date in the 75–80-day-ago window"
- closed_date = free[0]
- try:
- await ucal._create_all_day(closed_date, 'Room Closed')
- result = await fetch_user_cal(scan_start, scan_end)
- assert closed_date in result.data['closed'], f"{closed_date} not in synced closed"
- svcs = await closed_cal.get(closed_date)
- assert svcs is not None and svcs.services
- for ev in await ucal.find_closed(closed_date, closed_date):
- await ucal.delete(ev)
- result2 = await fetch_user_cal(scan_start, scan_end)
- assert closed_date not in result2.data['closed']
- assert await closed_cal.get(closed_date) is None, f"Closed still present on {closed_date}"
- finally:
- for ev in await ucal.find_closed(scan_start, scan_end):
- if ev.start.date() == closed_date:
- await ucal.delete(ev)
- await closed_cal.set(closed_date, ClosedData(services=[]))
- # ── fetch_closed ───────────────────────────────────────────────────────────────
- @pytest.mark.asyncio
- async def test_fetch_closed_writes_and_removes_closed_events():
- """API-reported blocked dates → Closed instances; open-report removes them."""
- from room_cal.reservations import Reservation
- closed_cal = Closed()
- res_cal = Reservation()
- scan_start = date.today() - timedelta(days=75)
- scan_end = date.today() - timedelta(days=73)
- existing = {e.start.date() for e in await closed_cal.list(scan_start, scan_end)}
- free = []
- for d in iter_dates(scan_start, scan_end):
- if d in existing:
- continue
- if await res_cal.list(d, d):
- continue
- free.append(d)
- assert free, "No free dates in the 73–75-day-ago window for fetch_closed test"
- target = free[0]
- service_keys = sorted(_services())
- try:
- # API reports target blocked
- result = await fetch_closed(
- scan_start, scan_end,
- api=_ClosedReadAPI({target.isoformat(): service_keys}),
- )
- matched = [r for r in result.data if r['date'] == target]
- assert matched, f"fetch_closed did not include {target} in results"
- assert sorted(matched[0]['closed_services']) == service_keys
- svcs = await closed_cal.get(target)
- assert svcs is not None
- assert sorted(svcs.services) == service_keys
- # API now reports target open: re-run should remove the Closed event
- await fetch_closed(scan_start, scan_end, api=_ClosedReadAPI({}))
- assert await closed_cal.get(target) is None, f"Closed event still present on {target} after open-sync"
- finally:
- await closed_cal.set(target, ClosedData(services=[]))
- # ── fetch_prices ───────────────────────────────────────────────────────────────
- @pytest.mark.asyncio
- async def test_fetch_prices_syncs_api_prices_to_caldav():
- """fetch_prices writes API list prices (converted to net) to the calendar."""
- from room_cal.service_api import ServiceAPI
- api = ServiceAPI()
- scan_start = date.today() - timedelta(days=90)
- scan_end = date.today() - timedelta(days=85)
- api_prices = await api.get_prices_range(scan_start, scan_end)
- if not api_prices:
- pytest.skip("No API prices in the past 85–90-day window")
- cal = CalPrice()
- before = {d for d in iter_dates(scan_start, scan_end) if await cal._get_prices(d) is not None}
- try:
- result = await fetch_prices(scan_start, scan_end, api=api, booked_only=False)
- assert result.data, "fetch_prices returned nothing despite API reporting prices"
- for r in result.data:
- stored = await cal._get_prices(r['date'])
- assert stored is not None, f"No price event found for {r['date']}"
- assert stored.net == r['net'], f"Stored net {stored.net} != returned net {r['net']} for {r['date']}"
- finally:
- for r in result.data if 'result' in dir() else []:
- if r['date'] not in before:
- await _delete_price_event(cal, r['date'])
- # ── push_prices ────────────────────────────────────────────────────────────────
- @pytest.mark.asyncio
- async def test_push_prices_calls_api_with_list_prices():
- """push_prices reads price events and calls api.set_prices with per-service list prices."""
- cal = CalPrice()
- # Find a past date that already has a price event in CalDAV
- scan_start = date.today() - timedelta(days=90)
- scan_end = date.today() - timedelta(days=80)
- target = None
- for d in iter_dates(scan_start, scan_end):
- if await cal._get_prices(d) is not None:
- target = d
- break
- if target is None:
- pytest.skip("No priced date found in the past 80–90-day window")
- api = _RecordingAPI()
- results = await push_prices(target, target, api=api)
- assert results, f"push_prices returned nothing for {target}"
- assert len(api.set_prices_calls) == 1, "Expected exactly one set_prices call"
- called_date, called_prices = api.set_prices_calls[0]
- assert called_date == target
- svc_keys = set(_services())
- for k in called_prices:
- assert k in svc_keys, f"Unexpected key '{k}' in pushed prices"
- assert called_prices[k] is not None and called_prices[k] > 0
- # ── apply_discounts ────────────────────────────────────────────────────────────
- @pytest.mark.asyncio
- async def test_apply_discounts_pushes_discounted_prices():
- """Discount on an existing price → apply_discounts recomputes and pushes reduced list prices."""
- cal = CalPrice()
- discount_cal = Discount()
- scan_start = date.today() - timedelta(days=90)
- scan_end = date.today() - timedelta(days=80)
- # Find a priced date with no existing discount and no reservation
- # (apply_discounts skips booked nights, so booked dates would always fail)
- existing_discounts = {e.start.date() for e in await discount_cal.list(scan_start, scan_end)}
- booked: set[date] = set()
- for e in await Reservation().list(scan_start, scan_end):
- d = e.start.date()
- while d < e.end.date():
- booked.add(d)
- d += timedelta(days=1)
- target = None
- original_base = None
- for d in iter_dates(scan_start, scan_end):
- if d in existing_discounts or d in booked:
- continue
- prices = await cal._get_prices(d)
- if prices is not None and prices.base is not None:
- target = d
- original_base = prices.base
- break
- if target is None:
- pytest.skip("No priced, non-discounted date found in the past 80–90-day window")
- discount_amount = 10
- source = {'domain': 'test', 'name': 'Test => ', 'id': 'test-apply-discounts'}
- try:
- await discount_cal.set(target, DiscountData(type=DiscountType.FLAT, value=discount_amount, source=source))
- results = await apply_discounts(scan_start, scan_end)
- matched = [r for r in results if r['date'] == target]
- assert matched, f"apply_discounts did not report a change on {target}"
- prices_after = await cal._get_prices(target)
- assert prices_after is not None
- assert prices_after.net == original_base - discount_amount
- finally:
- await discount_cal.delete_on(target)
- await cal.set(target, original_base) # restore original prices
- # ── diff ───────────────────────────────────────────────────────────────────────
- @pytest.mark.asyncio
- async def test_diff_returns_complete_structure():
- """diff() on a past window returns all expected keys with valid list/dict shapes."""
- from room_cal.service_api import ServiceAPI
- api = ServiceAPI()
- start = date.today() - timedelta(days=90)
- end = date.today() - timedelta(days=85)
- result = await diff(start, end, api=api)
- assert {'prices', 'closed', 'reservations', 'user_cal_closed', 'ical_feeds_closed', 'rule_discounts', 'rule_closed', 'rule_prices', 'orphans'} <= set(result)
- assert isinstance(result['prices'], list)
- assert isinstance(result['closed'], list)
- assert isinstance(result['reservations'], list)
- assert isinstance(result['user_cal_closed'], list)
- assert isinstance(result['ical_feeds_closed'], dict)
- assert isinstance(result['rule_discounts'], dict)
- assert isinstance(result['rule_closed'], dict)
- assert isinstance(result['orphans'], list)
- for row in result['prices']:
- assert 'date' in row and 'per_service' in row
- for row in result['closed']:
- assert 'date' in row and 'local' in row and 'remote' in row
- for row in result['reservations']:
- assert 'status' in row
- assert row['status'] in ('added', 'removed', 'changed')
- for pipeline_name, diffs in result['rule_discounts'].items():
- assert isinstance(diffs, list)
- for row in diffs:
- assert 'date' in row and 'local' in row and 'rule' in row
|