test_action_chains.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320
  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): 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. async def test_fetch_user_cal_closed():
  51. """House closed events → Room Closed after import; removed on re-import."""
  52. ucal = UserCal()
  53. closed_cal = Closed()
  54. scan_start = date.today() - timedelta(days=80)
  55. scan_end = date.today() - timedelta(days=75)
  56. occupied = {e.start.date() for e in await closed_cal.list(scan_start, scan_end)}
  57. free = [d for d in iter_dates(scan_start, scan_end) if d not in occupied]
  58. assert len(free) >= 1, "Need at least 1 free date in the 75–80-day-ago window"
  59. closed_date = free[0]
  60. try:
  61. await ucal._create_all_day(closed_date, 'Room Closed')
  62. result = await fetch_user_cal(scan_start, scan_end)
  63. assert closed_date in result.data['closed'], f"{closed_date} not in synced closed"
  64. svcs = await closed_cal.get(closed_date)
  65. assert svcs is not None and svcs.services
  66. for ev in await ucal.find_closed(closed_date, closed_date):
  67. await ucal.delete(ev)
  68. result2 = await fetch_user_cal(scan_start, scan_end)
  69. assert closed_date not in result2.data['closed']
  70. assert await closed_cal.get(closed_date) is None, f"Closed still present on {closed_date}"
  71. finally:
  72. for ev in await ucal.find_closed(scan_start, scan_end):
  73. if ev.start.date() == closed_date:
  74. await ucal.delete(ev)
  75. await closed_cal.set(closed_date, ClosedData(services=[]))
  76. # ── fetch_closed ───────────────────────────────────────────────────────────────
  77. @pytest.mark.asyncio
  78. async def test_fetch_closed_writes_and_removes_closed_events():
  79. """API-reported blocked dates → Closed instances; open-report removes them."""
  80. from room_cal.reservations import Reservation
  81. closed_cal = Closed()
  82. res_cal = Reservation()
  83. scan_start = date.today() - timedelta(days=75)
  84. scan_end = date.today() - timedelta(days=73)
  85. existing = {e.start.date() for e in await closed_cal.list(scan_start, scan_end)}
  86. free = []
  87. for d in iter_dates(scan_start, scan_end):
  88. if d in existing:
  89. continue
  90. if await res_cal.list(d, d):
  91. continue
  92. free.append(d)
  93. assert free, "No free dates in the 73–75-day-ago window for fetch_closed test"
  94. target = free[0]
  95. service_keys = sorted(_services())
  96. try:
  97. # API reports target blocked
  98. result = await fetch_closed(
  99. scan_start, scan_end,
  100. api=_ClosedReadAPI({target.isoformat(): service_keys}),
  101. )
  102. matched = [r for r in result.data if r['date'] == target]
  103. assert matched, f"fetch_closed did not include {target} in results"
  104. assert sorted(matched[0]['closed_services']) == service_keys
  105. svcs = await closed_cal.get(target)
  106. assert svcs is not None
  107. assert sorted(svcs.services) == service_keys
  108. # API now reports target open: re-run should remove the Closed event
  109. await fetch_closed(scan_start, scan_end, api=_ClosedReadAPI({}))
  110. assert await closed_cal.get(target) is None, f"Closed event still present on {target} after open-sync"
  111. finally:
  112. await closed_cal.set(target, ClosedData(services=[]))
  113. # ── fetch_prices ───────────────────────────────────────────────────────────────
  114. @pytest.mark.asyncio
  115. async def test_fetch_prices_syncs_api_prices_to_caldav():
  116. """fetch_prices writes API list prices (converted to net) to the calendar."""
  117. from room_cal.service_api import ServiceAPI
  118. api = ServiceAPI()
  119. scan_start = date.today() - timedelta(days=90)
  120. scan_end = date.today() - timedelta(days=85)
  121. api_prices = await api.get_prices_range(scan_start, scan_end)
  122. if not api_prices:
  123. pytest.skip("No API prices in the past 85–90-day window")
  124. cal = CalPrice()
  125. before = {d for d in iter_dates(scan_start, scan_end) if await cal._get_prices(d) is not None}
  126. try:
  127. result = await fetch_prices(scan_start, scan_end, api=api, booked_only=False)
  128. assert result.data, "fetch_prices returned nothing despite API reporting prices"
  129. for r in result.data:
  130. stored = await cal._get_prices(r['date'])
  131. assert stored is not None, f"No price event found for {r['date']}"
  132. assert stored.net == r['net'], f"Stored net {stored.net} != returned net {r['net']} for {r['date']}"
  133. finally:
  134. for r in result.data if 'result' in dir() else []:
  135. if r['date'] not in before:
  136. await _delete_price_event(cal, r['date'])
  137. # ── push_prices ────────────────────────────────────────────────────────────────
  138. @pytest.mark.asyncio
  139. async def test_push_prices_calls_api_with_list_prices():
  140. """push_prices reads price events and calls api.set_prices with per-service list prices."""
  141. cal = CalPrice()
  142. # Find a past date that already has a price event in CalDAV
  143. scan_start = date.today() - timedelta(days=90)
  144. scan_end = date.today() - timedelta(days=80)
  145. target = None
  146. for d in iter_dates(scan_start, scan_end):
  147. if await cal._get_prices(d) is not None:
  148. target = d
  149. break
  150. if target is None:
  151. pytest.skip("No priced date found in the past 80–90-day window")
  152. api = _RecordingAPI()
  153. results = await push_prices(target, target, api=api)
  154. assert results, f"push_prices returned nothing for {target}"
  155. assert len(api.set_prices_calls) == 1, "Expected exactly one set_prices call"
  156. called_date, called_prices = api.set_prices_calls[0]
  157. assert called_date == target
  158. svc_keys = set(_services())
  159. for k in called_prices:
  160. assert k in svc_keys, f"Unexpected key '{k}' in pushed prices"
  161. assert called_prices[k] is not None and called_prices[k] > 0
  162. # ── apply_discounts ────────────────────────────────────────────────────────────
  163. @pytest.mark.asyncio
  164. async def test_apply_discounts_pushes_discounted_prices():
  165. """Discount on an existing price → apply_discounts recomputes and pushes reduced list prices."""
  166. cal = CalPrice()
  167. discount_cal = Discount()
  168. scan_start = date.today() - timedelta(days=90)
  169. scan_end = date.today() - timedelta(days=80)
  170. # Find a priced date with no existing discount and no reservation
  171. # (apply_discounts skips booked nights, so booked dates would always fail)
  172. existing_discounts = {e.start.date() for e in await discount_cal.list(scan_start, scan_end)}
  173. booked: set[date] = set()
  174. for e in await Reservation().list(scan_start, scan_end):
  175. d = e.start.date()
  176. while d < e.end.date():
  177. booked.add(d)
  178. d += timedelta(days=1)
  179. target = None
  180. original_base = None
  181. for d in iter_dates(scan_start, scan_end):
  182. if d in existing_discounts or d in booked:
  183. continue
  184. prices = await cal._get_prices(d)
  185. if prices is not None and prices.base is not None:
  186. target = d
  187. original_base = prices.base
  188. break
  189. if target is None:
  190. pytest.skip("No priced, non-discounted date found in the past 80–90-day window")
  191. discount_amount = 10
  192. source = {'domain': 'test', 'name': 'Test => ', 'id': 'test-apply-discounts'}
  193. try:
  194. await discount_cal.set(target, DiscountData(type=DiscountType.FLAT, value=discount_amount, source=source))
  195. results = await apply_discounts(scan_start, scan_end)
  196. matched = [r for r in results if r['date'] == target]
  197. assert matched, f"apply_discounts did not report a change on {target}"
  198. prices_after = await cal._get_prices(target)
  199. assert prices_after is not None
  200. assert prices_after.net == original_base - discount_amount
  201. finally:
  202. await discount_cal.delete_on(target)
  203. await cal.set(target, original_base) # restore original prices
  204. # ── diff ───────────────────────────────────────────────────────────────────────
  205. @pytest.mark.asyncio
  206. async def test_diff_returns_complete_structure():
  207. """diff() on a past window returns all expected keys with valid list/dict shapes."""
  208. from room_cal.service_api import ServiceAPI
  209. api = ServiceAPI()
  210. start = date.today() - timedelta(days=90)
  211. end = date.today() - timedelta(days=85)
  212. result = await diff(start, end, api=api)
  213. assert {'prices', 'closed', 'reservations', 'user_cal_closed', 'ical_feeds_closed', 'rule_discounts', 'rule_closed', 'rule_prices', 'orphans'} <= set(result)
  214. assert isinstance(result['prices'], list)
  215. assert isinstance(result['closed'], list)
  216. assert isinstance(result['reservations'], list)
  217. assert isinstance(result['user_cal_closed'], list)
  218. assert isinstance(result['ical_feeds_closed'], dict)
  219. assert isinstance(result['rule_discounts'], dict)
  220. assert isinstance(result['rule_closed'], dict)
  221. assert isinstance(result['orphans'], list)
  222. for row in result['prices']:
  223. assert 'date' in row and 'per_service' in row
  224. for row in result['closed']:
  225. assert 'date' in row and 'local' in row and 'remote' in row
  226. for row in result['reservations']:
  227. assert 'status' in row
  228. assert row['status'] in ('added', 'removed', 'changed')
  229. for pipeline_name, diffs in result['rule_discounts'].items():
  230. assert isinstance(diffs, list)
  231. for row in diffs:
  232. assert 'date' in row and 'local' in row and 'rule' in row