test_action_chains.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  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. async def test_fetch_prices_syncs_api_prices_to_caldav():
  117. """fetch_prices writes API list prices (converted to net) to the calendar."""
  118. from room_cal.service_api import ServiceAPI
  119. api = ServiceAPI()
  120. scan_start = date.today() - timedelta(days=90)
  121. scan_end = date.today() - timedelta(days=85)
  122. api_prices = await api.get_prices_range(scan_start, scan_end)
  123. if not api_prices:
  124. pytest.skip("No API prices in the past 85–90-day window")
  125. cal = CalPrice()
  126. before = {d for d in iter_dates(scan_start, scan_end) if await cal._get_prices(d) is not None}
  127. try:
  128. result = await fetch_prices(scan_start, scan_end, api=api, booked_only=False)
  129. assert result.data, "fetch_prices returned nothing despite API reporting prices"
  130. for r in result.data:
  131. stored = await cal._get_prices(r['date'])
  132. assert stored is not None, f"No price event found for {r['date']}"
  133. assert stored.net == r['net'], f"Stored net {stored.net} != returned net {r['net']} for {r['date']}"
  134. finally:
  135. for r in result.data if 'result' in dir() else []:
  136. if r['date'] not in before:
  137. await _delete_price_event(cal, r['date'])
  138. # ── push_prices ────────────────────────────────────────────────────────────────
  139. @pytest.mark.asyncio
  140. async def test_push_prices_calls_api_with_list_prices():
  141. """push_prices reads price events and calls api.set_prices with per-service list prices."""
  142. cal = CalPrice()
  143. # Find a past date that already has a price event in CalDAV
  144. scan_start = date.today() - timedelta(days=90)
  145. scan_end = date.today() - timedelta(days=80)
  146. target = None
  147. for d in iter_dates(scan_start, scan_end):
  148. if await cal._get_prices(d) is not None:
  149. target = d
  150. break
  151. if target is None:
  152. pytest.skip("No priced date found in the past 80–90-day window")
  153. api = _RecordingAPI()
  154. results = await push_prices(target, target, api=api)
  155. assert results, f"push_prices returned nothing for {target}"
  156. assert len(api.set_prices_calls) == 1, "Expected exactly one set_prices call"
  157. called_date, called_prices = api.set_prices_calls[0]
  158. assert called_date == target
  159. svc_keys = set(_services())
  160. for k in called_prices:
  161. assert k in svc_keys, f"Unexpected key '{k}' in pushed prices"
  162. assert called_prices[k] is not None and called_prices[k] > 0
  163. # ── apply_discounts ────────────────────────────────────────────────────────────
  164. @pytest.mark.asyncio
  165. async def test_apply_discounts_pushes_discounted_prices():
  166. """Discount on an existing price → apply_discounts recomputes and pushes reduced list prices."""
  167. cal = CalPrice()
  168. discount_cal = Discount()
  169. scan_start = date.today() - timedelta(days=90)
  170. scan_end = date.today() - timedelta(days=80)
  171. # Find a priced date with no existing discount and no reservation
  172. # (apply_discounts skips booked nights, so booked dates would always fail)
  173. existing_discounts = {e.start.date() for e in await discount_cal.list(scan_start, scan_end)}
  174. booked: set[date] = set()
  175. for e in await Reservation().list(scan_start, scan_end):
  176. d = e.start.date()
  177. while d < e.end.date():
  178. booked.add(d)
  179. d += timedelta(days=1)
  180. target = None
  181. original_base = None
  182. for d in iter_dates(scan_start, scan_end):
  183. if d in existing_discounts or d in booked:
  184. continue
  185. prices = await cal._get_prices(d)
  186. if prices is not None and prices.base is not None:
  187. target = d
  188. original_base = prices.base
  189. break
  190. if target is None:
  191. pytest.skip("No priced, non-discounted date found in the past 80–90-day window")
  192. discount_amount = 10
  193. source = {'domain': 'test', 'name': 'Test => ', 'id': 'test-apply-discounts'}
  194. try:
  195. await discount_cal.set(target, DiscountData(type=DiscountType.FLAT, value=discount_amount, source=source))
  196. results = await apply_discounts(scan_start, scan_end)
  197. matched = [r for r in results if r['date'] == target]
  198. assert matched, f"apply_discounts did not report a change on {target}"
  199. prices_after = await cal._get_prices(target)
  200. assert prices_after is not None
  201. assert prices_after.net == original_base - discount_amount
  202. finally:
  203. await discount_cal.delete_on(target)
  204. await cal.set(target, original_base) # restore original prices
  205. # ── diff ───────────────────────────────────────────────────────────────────────
  206. @pytest.mark.asyncio
  207. @pytest.mark.usefixtures('require_user_cal')
  208. async def test_diff_returns_complete_structure():
  209. """diff() on a past window returns all expected keys with valid list/dict shapes."""
  210. from room_cal.service_api import ServiceAPI
  211. api = ServiceAPI()
  212. start = date.today() - timedelta(days=90)
  213. end = date.today() - timedelta(days=85)
  214. result = await diff(start, end, api=api)
  215. assert {'prices', 'closed', 'reservations', 'user_cal_closed', 'ical_feeds_closed', 'rule_discounts', 'rule_closed', 'rule_prices', 'orphans'} <= set(result)
  216. assert isinstance(result['prices'], list)
  217. assert isinstance(result['closed'], list)
  218. assert isinstance(result['reservations'], list)
  219. assert isinstance(result['user_cal_closed'], list)
  220. assert isinstance(result['ical_feeds_closed'], dict)
  221. assert isinstance(result['rule_discounts'], dict)
  222. assert isinstance(result['rule_closed'], dict)
  223. assert isinstance(result['orphans'], list)
  224. for row in result['prices']:
  225. assert 'date' in row and 'per_service' in row
  226. for row in result['closed']:
  227. assert 'date' in row and 'local' in row and 'remote' in row
  228. for row in result['reservations']:
  229. assert 'status' in row
  230. assert row['status'] in ('added', 'removed', 'changed')
  231. for pipeline_name, diffs in result['rule_discounts'].items():
  232. assert isinstance(diffs, list)
  233. for row in diffs:
  234. assert 'date' in row and 'local' in row and 'rule' in row