test_action_chains.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. import pytest
  2. from datetime import date, timedelta
  3. from room_cal.closed import Closed, ClosedData
  4. from room_cal.discount import Discount, DiscountData, DiscountType
  5. from room_cal.pricing import CalPrice
  6. from room_cal.reservations import Reservation
  7. from room_cal.user_cal import UserCal
  8. from room_cal._utils import filter_events, iter_dates, services as _services
  9. from room_cal.actions.fetch_user_cal import fetch_user_cal
  10. from room_cal.actions.fetch_closed import fetch_closed
  11. from room_cal.actions.fetch_prices import fetch_prices
  12. from room_cal.actions.push_prices import push_prices
  13. from room_cal.actions.apply_discounts import apply_discounts
  14. from room_cal.actions.diff import diff
  15. # ── mock / stub helpers ────────────────────────────────────────────────────────
  16. class _MockAPI:
  17. """Stub ServiceAPI: all reads return empty, all writes are no-ops."""
  18. async def get_prices_range(self, start, end): return {}
  19. async def get_prices(self, d): return {}
  20. async def set_prices(self, d, prices): pass
  21. async def get_available(self, d): return True
  22. async def set_available(self, d, available): pass
  23. async def get_closed_by_date(self, start, end): return {}
  24. async def get_reservations(self, start, end, *, _out_errors=None): return []
  25. class _RecordingAPI(_MockAPI):
  26. """_MockAPI that records write calls for inspection."""
  27. def __init__(self):
  28. self.set_prices_calls: list[tuple[date, dict]] = []
  29. self.set_available_calls: list[tuple[date, bool]] = []
  30. async def set_prices(self, d, prices):
  31. self.set_prices_calls.append((d, dict(prices)))
  32. async def set_available(self, d, available):
  33. self.set_available_calls.append((d, available))
  34. class _ClosedReadAPI(_MockAPI):
  35. """_MockAPI whose get_closed_by_date returns a caller-supplied mapping."""
  36. def __init__(self, closed_by_date: dict[str, list[str]]):
  37. self._closed = closed_by_date
  38. async def get_closed_by_date(self, start, end):
  39. return {
  40. ds: svcs for ds, svcs in self._closed.items()
  41. if start.isoformat() <= ds <= end.isoformat()
  42. }
  43. async def _delete_price_event(cal: CalPrice, target: date) -> None:
  44. """Delete the price event for target (if present)."""
  45. events = await cal._fetch_date_range(target, target)
  46. for e in filter_events(events, 'Price: $', all_day=True, require_x_data=False):
  47. await cal._backend.delete(e.uid)
  48. # ── fetch_user_cal ─────────────────────────────────────────────────────────────
  49. @pytest.mark.asyncio
  50. @pytest.mark.usefixtures('require_user_cal')
  51. async def test_fetch_user_cal_closed():
  52. """House closed events → Room Closed after import; removed on re-import."""
  53. ucal = UserCal()
  54. closed_cal = Closed()
  55. scan_start = date.today() - timedelta(days=80)
  56. scan_end = date.today() - timedelta(days=75)
  57. occupied = {e.start.date() for e in await closed_cal.list(scan_start, scan_end)}
  58. free = [d for d in iter_dates(scan_start, scan_end) if d not in occupied]
  59. assert len(free) >= 1, "Need at least 1 free date in the 75–80-day-ago window"
  60. closed_date = free[0]
  61. try:
  62. await ucal._create_all_day(closed_date, 'Room Closed')
  63. result = await fetch_user_cal(scan_start, scan_end)
  64. assert closed_date in result.data['closed'], f"{closed_date} not in synced closed"
  65. svcs = await closed_cal.get(closed_date)
  66. assert svcs is not None and svcs.services
  67. for ev in await ucal.find_closed(closed_date, closed_date):
  68. await ucal.delete(ev)
  69. result2 = await fetch_user_cal(scan_start, scan_end)
  70. assert closed_date not in result2.data['closed']
  71. assert await closed_cal.get(closed_date) is None, f"Closed still present on {closed_date}"
  72. finally:
  73. for ev in await ucal.find_closed(scan_start, scan_end):
  74. if ev.start.date() == closed_date:
  75. await ucal.delete(ev)
  76. await closed_cal.set(closed_date, ClosedData(services=[]))
  77. # ── fetch_closed ───────────────────────────────────────────────────────────────
  78. @pytest.mark.asyncio
  79. async def test_fetch_closed_writes_and_removes_closed_events():
  80. """API-reported blocked dates → Closed instances; open-report removes them."""
  81. from room_cal.reservations import Reservation
  82. closed_cal = Closed()
  83. res_cal = Reservation()
  84. scan_start = date.today() - timedelta(days=75)
  85. scan_end = date.today() - timedelta(days=73)
  86. existing = {e.start.date() for e in await closed_cal.list(scan_start, scan_end)}
  87. free = []
  88. for d in iter_dates(scan_start, scan_end):
  89. if d in existing:
  90. continue
  91. if await res_cal.list(d, d):
  92. continue
  93. free.append(d)
  94. assert free, "No free dates in the 73–75-day-ago window for fetch_closed test"
  95. target = free[0]
  96. service_keys = sorted(_services())
  97. try:
  98. # API reports target blocked
  99. result = await fetch_closed(
  100. scan_start, scan_end,
  101. api=_ClosedReadAPI({target.isoformat(): service_keys}),
  102. )
  103. matched = [r for r in result.data if r['date'] == target]
  104. assert matched, f"fetch_closed did not include {target} in results"
  105. assert sorted(matched[0]['closed_services']) == service_keys
  106. svcs = await closed_cal.get(target)
  107. assert svcs is not None
  108. assert sorted(svcs.services) == service_keys
  109. # API now reports target open: re-run should remove the Closed event
  110. await fetch_closed(scan_start, scan_end, api=_ClosedReadAPI({}))
  111. assert await closed_cal.get(target) is None, f"Closed event still present on {target} after open-sync"
  112. finally:
  113. await closed_cal.set(target, ClosedData(services=[]))
  114. # ── fetch_prices ───────────────────────────────────────────────────────────────
  115. @pytest.mark.asyncio
  116. @pytest.mark.live
  117. async def test_fetch_prices_syncs_api_prices_to_caldav():
  118. """fetch_prices writes API list prices (converted to net) to the calendar."""
  119. from room_cal.service_api import ServiceAPI
  120. api = ServiceAPI()
  121. scan_start = date.today() - timedelta(days=90)
  122. scan_end = date.today() - timedelta(days=85)
  123. api_prices = await api.get_prices_range(scan_start, scan_end)
  124. if not api_prices:
  125. pytest.skip("No API prices in the past 85–90-day window")
  126. cal = CalPrice()
  127. before = {d for d in iter_dates(scan_start, scan_end) if await cal._get_prices(d) is not None}
  128. try:
  129. result = await fetch_prices(scan_start, scan_end, api=api, booked_only=False)
  130. assert result.data, "fetch_prices returned nothing despite API reporting prices"
  131. for r in result.data:
  132. stored = await cal._get_prices(r['date'])
  133. assert stored is not None, f"No price event found for {r['date']}"
  134. assert stored.net == r['net'], f"Stored net {stored.net} != returned net {r['net']} for {r['date']}"
  135. finally:
  136. for r in result.data if 'result' in dir() else []:
  137. if r['date'] not in before:
  138. await _delete_price_event(cal, r['date'])
  139. # ── push_prices ────────────────────────────────────────────────────────────────
  140. @pytest.mark.asyncio
  141. async def test_push_prices_calls_api_with_list_prices():
  142. """push_prices reads price events and calls api.set_prices with per-service list prices."""
  143. cal = CalPrice()
  144. # Find a past date that already has a price event in CalDAV
  145. scan_start = date.today() - timedelta(days=90)
  146. scan_end = date.today() - timedelta(days=80)
  147. target = None
  148. for d in iter_dates(scan_start, scan_end):
  149. if await cal._get_prices(d) is not None:
  150. target = d
  151. break
  152. if target is None:
  153. pytest.skip("No priced date found in the past 80–90-day window")
  154. api = _RecordingAPI()
  155. results = await push_prices(target, target, api=api)
  156. assert results, f"push_prices returned nothing for {target}"
  157. assert len(api.set_prices_calls) == 1, "Expected exactly one set_prices call"
  158. called_date, called_prices = api.set_prices_calls[0]
  159. assert called_date == target
  160. svc_keys = set(_services())
  161. for k in called_prices:
  162. assert k in svc_keys, f"Unexpected key '{k}' in pushed prices"
  163. assert called_prices[k] is not None and called_prices[k] > 0
  164. # ── apply_discounts ────────────────────────────────────────────────────────────
  165. @pytest.mark.asyncio
  166. async def test_apply_discounts_pushes_discounted_prices():
  167. """Discount on an existing price → apply_discounts recomputes and pushes reduced list prices."""
  168. cal = CalPrice()
  169. discount_cal = Discount()
  170. scan_start = date.today() - timedelta(days=90)
  171. scan_end = date.today() - timedelta(days=80)
  172. # Find a priced date with no existing discount and no reservation
  173. # (apply_discounts skips booked nights, so booked dates would always fail)
  174. existing_discounts = {e.start.date() for e in await discount_cal.list(scan_start, scan_end)}
  175. booked: set[date] = set()
  176. for e in await Reservation().list(scan_start, scan_end):
  177. d = e.start.date()
  178. while d < e.end.date():
  179. booked.add(d)
  180. d += timedelta(days=1)
  181. target = None
  182. original_base = None
  183. for d in iter_dates(scan_start, scan_end):
  184. if d in existing_discounts or d in booked:
  185. continue
  186. prices = await cal._get_prices(d)
  187. if prices is not None and prices.base is not None:
  188. target = d
  189. original_base = prices.base
  190. break
  191. if target is None:
  192. pytest.skip("No priced, non-discounted date found in the past 80–90-day window")
  193. discount_amount = 10
  194. source = {'domain': 'test', 'name': 'Test => ', 'id': 'test-apply-discounts'}
  195. try:
  196. await discount_cal.set(target, DiscountData(type=DiscountType.FLAT, value=discount_amount, source=source))
  197. results = await apply_discounts(scan_start, scan_end)
  198. matched = [r for r in results if r['date'] == target]
  199. assert matched, f"apply_discounts did not report a change on {target}"
  200. prices_after = await cal._get_prices(target)
  201. assert prices_after is not None
  202. assert prices_after.net == original_base - discount_amount
  203. finally:
  204. await discount_cal.delete_on(target)
  205. await cal.set(target, original_base) # restore original prices
  206. # ── diff ───────────────────────────────────────────────────────────────────────
  207. @pytest.mark.asyncio
  208. @pytest.mark.live
  209. @pytest.mark.usefixtures('require_user_cal')
  210. async def test_diff_returns_complete_structure():
  211. """diff() on a past window returns all expected keys with valid list/dict shapes."""
  212. from room_cal.service_api import ServiceAPI
  213. api = ServiceAPI()
  214. start = date.today() - timedelta(days=90)
  215. end = date.today() - timedelta(days=85)
  216. result = await diff(start, end, api=api)
  217. assert {'prices', 'closed', 'reservations', 'user_cal_closed', 'ical_feeds_closed', 'rule_discounts', 'rule_closed', 'rule_prices', 'orphans'} <= set(result)
  218. assert isinstance(result['prices'], list)
  219. assert isinstance(result['closed'], list)
  220. assert isinstance(result['reservations'], list)
  221. assert isinstance(result['user_cal_closed'], list)
  222. assert isinstance(result['ical_feeds_closed'], dict)
  223. assert isinstance(result['rule_discounts'], dict)
  224. assert isinstance(result['rule_closed'], dict)
  225. assert isinstance(result['orphans'], list)
  226. for row in result['prices']:
  227. assert 'date' in row and 'per_service' in row
  228. for row in result['closed']:
  229. assert 'date' in row and 'local' in row and 'remote' in row
  230. for row in result['reservations']:
  231. assert 'status' in row
  232. assert row['status'] in ('added', 'removed', 'changed')
  233. for pipeline_name, diffs in result['rule_discounts'].items():
  234. assert isinstance(diffs, list)
  235. for row in diffs:
  236. assert 'date' in row and 'local' in row and 'rule' in row
  237. @pytest.mark.asyncio
  238. async def test_diff_reservations_ignores_boundary_touching_checkout(monkeypatch):
  239. """A reservation whose checkout lands exactly on start_date has zero real nights in
  240. this window and belongs to the prior period's diff — it must not be reported as
  241. 'added' just because the service's own range query includes boundary-touching stays
  242. (e.g. Booking.com returning a checkout == date_from reservation)."""
  243. import room_cal.actions.diff as diff_mod
  244. class _FakeReservationCal:
  245. async def list(self, start, end):
  246. return []
  247. class _ReservationsAPI(_MockAPI):
  248. async def get_reservations(self, start, end, *, _out_errors=None):
  249. return [
  250. { # touches the boundary only — checkout == start_date
  251. 'id': 'boundary', 'guest_name': 'Boundary Guest', 'service': 'booking',
  252. 'start_date': start - timedelta(days=1), 'end_date': start,
  253. },
  254. { # genuinely starts inside the window
  255. 'id': 'real', 'guest_name': 'Real Guest', 'service': 'booking',
  256. 'start_date': start, 'end_date': start + timedelta(days=2),
  257. },
  258. ]
  259. monkeypatch.setattr(diff_mod, 'Reservation', _FakeReservationCal)
  260. start = date(2026, 10, 1)
  261. end = date(2026, 10, 31)
  262. result = await diff_mod._diff_reservations(start, end, _ReservationsAPI())
  263. ids = {row.get('reservation_id') for row in result}
  264. assert 'boundary' not in ids
  265. assert 'real' in ids