test_notify_widgets.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790
  1. """Persisted Notify widget ownership and write cadence against a fake gateway."""
  2. import asyncio
  3. import json
  4. from datetime import datetime, timedelta
  5. from types import SimpleNamespace
  6. from unittest.mock import AsyncMock
  7. import pytest
  8. from sqlalchemy import delete, event, select, text, update
  9. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  10. import backend.app.models # noqa: F401
  11. from backend.app.core.database import Base
  12. from backend.app.models.notification import NotificationProvider
  13. from backend.app.models.notification_lock_screen_widget import NotificationLockScreenWidget
  14. from backend.app.models.printer import Printer
  15. from backend.app.services import notify_widgets as module
  16. from backend.app.services.notify_client import NotifyError
  17. from backend.app.services.notify_widgets import NotifyWidgetService, widget_content
  18. def state(**values):
  19. defaults = {
  20. "connected": True,
  21. "state": "RUNNING",
  22. "subtask_id": "job1",
  23. "subtask_name": "job.3mf",
  24. "current_print": "job.3mf",
  25. "progress": 42,
  26. "remaining_time": 15,
  27. "layer_num": 42,
  28. "total_layers": 100,
  29. "hms_errors": [],
  30. "raw_data": {},
  31. "temperatures": {},
  32. "stg_cur": -1,
  33. }
  34. return SimpleNamespace(**{**defaults, **values})
  35. @pytest.fixture
  36. async def setup(tmp_path, monkeypatch):
  37. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'widgets.db'}")
  38. @event.listens_for(engine.sync_engine, "connect")
  39. def enable_fk(connection, _):
  40. cursor = connection.cursor()
  41. cursor.execute("PRAGMA foreign_keys=ON")
  42. cursor.close()
  43. async with engine.begin() as conn:
  44. await conn.run_sync(Base.metadata.create_all)
  45. factory = async_sessionmaker(engine, expire_on_commit=False)
  46. async with factory() as db:
  47. db.add(Printer(id=1, name="Printer 1", serial_number="serial1", ip_address="127.0.0.1", access_code="12345678"))
  48. db.add(
  49. NotificationProvider(
  50. id=1,
  51. name="Phone",
  52. provider_type="notify",
  53. enabled=True,
  54. config=json.dumps({"device_id": "ABC12345", "token": "secret", "lock_screen_widgets": True}),
  55. on_print_start=False,
  56. on_print_complete=False,
  57. daily_digest_enabled=True,
  58. quiet_hours_enabled=True,
  59. quiet_hours_start="00:00",
  60. quiet_hours_end="23:59",
  61. )
  62. )
  63. await db.commit()
  64. api = SimpleNamespace(
  65. create_widget=AsyncMock(return_value={"widgetId": "WG123456"}),
  66. update_widget=AsyncMock(return_value={"widgetId": "WG123456"}),
  67. delete_widget=AsyncMock(return_value={"success": True}),
  68. list_widgets=AsyncMock(return_value={"widgets": []}),
  69. )
  70. clock = [datetime(2026, 1, 1, 12)]
  71. monkeypatch.setattr(module, "_now", lambda: clock[0])
  72. states = {1: state()}
  73. service = NotifyWidgetService(factory, api, states.get)
  74. yield service, api, factory, clock, states
  75. await engine.dispose()
  76. async def rows(factory):
  77. async with factory() as db:
  78. return (await db.scalars(select(NotificationLockScreenWidget))).all()
  79. async def configure(factory, **values):
  80. async with factory() as db:
  81. provider = await db.get(NotificationProvider, 1)
  82. config = json.loads(provider.config)
  83. config.update(values)
  84. provider.config = json.dumps(config)
  85. await db.commit()
  86. @pytest.mark.asyncio
  87. async def test_default_opt_out_and_independent_of_push_digest_quiet_hours(setup):
  88. service, api, factory, clock, states = setup
  89. await configure(factory, lock_screen_widgets=False)
  90. await service.tick()
  91. api.create_widget.assert_not_awaited()
  92. await configure(factory, lock_screen_widgets=True)
  93. await service.tick()
  94. api.create_widget.assert_awaited_once()
  95. assert (await rows(factory))[0].widget_id == "WG123456"
  96. @pytest.mark.asyncio
  97. async def test_restart_reuses_exact_widget_across_jobs_and_completion(setup):
  98. service, api, factory, clock, states = setup
  99. await service.tick()
  100. restarted = NotifyWidgetService(factory, api, states.get)
  101. states[1] = state(state="FINISH", progress=100)
  102. clock[0] += timedelta(minutes=1)
  103. await restarted.tick()
  104. assert api.update_widget.call_args.args[:2] == ("WG123456", "secret")
  105. assert api.update_widget.call_args.args[2]["value"] == "Complete"
  106. states[1] = state(subtask_id="job2", progress=2)
  107. clock[0] += timedelta(minutes=1)
  108. await restarted.tick()
  109. assert api.create_widget.await_count == 1
  110. assert api.update_widget.call_args.args[2]["value"] == "2"
  111. api.delete_widget.assert_not_awaited()
  112. @pytest.mark.asyncio
  113. async def test_only_changed_values_send_and_at_most_once_per_minute(setup):
  114. service, api, factory, clock, states = setup
  115. await service.tick()
  116. states[1] = state(progress=43)
  117. clock[0] += timedelta(seconds=59)
  118. await service.tick()
  119. api.update_widget.assert_not_awaited()
  120. clock[0] += timedelta(seconds=1)
  121. await service.tick()
  122. assert api.update_widget.await_count == 1
  123. clock[0] += timedelta(minutes=60)
  124. await service.tick()
  125. assert api.update_widget.await_count == 1
  126. api.list_widgets.assert_awaited_once_with("ABC12345", "secret")
  127. @pytest.mark.asyncio
  128. async def test_offline_and_idle_widgets_are_persistent(setup):
  129. service, api, factory, clock, states = setup
  130. states.clear()
  131. await service.tick()
  132. assert api.create_widget.call_args.args[2]["value"] == "Offline"
  133. states[1] = state(state="IDLE")
  134. clock[0] += timedelta(minutes=1)
  135. await service.tick()
  136. assert api.update_widget.call_args.args[2]["value"] == "Idle"
  137. assert api.create_widget.await_count == 1
  138. @pytest.mark.asyncio
  139. @pytest.mark.parametrize("device_id", ["GRP12345", "WB123456", "MC123456"])
  140. async def test_unsupported_devices_are_push_only(setup, device_id):
  141. service, api, factory, clock, states = setup
  142. await configure(factory, device_id=device_id)
  143. await service.tick()
  144. api.create_widget.assert_not_awaited()
  145. @pytest.mark.asyncio
  146. async def test_ambiguous_create_is_not_repeated_and_has_manual_recovery_message(setup):
  147. service, api, factory, clock, states = setup
  148. api.create_widget.side_effect = NotifyError("Timeout", delivery_state="unknown")
  149. await service.tick()
  150. clock[0] += timedelta(hours=2)
  151. restarted = NotifyWidgetService(factory, api, states.get)
  152. await restarted.tick()
  153. assert api.create_widget.await_count == 1
  154. assert (await rows(factory))[0].state == "uncertain"
  155. async with factory() as db:
  156. provider = await db.get(NotificationProvider, 1)
  157. assert "turn Lock Screen widgets off and on" in provider.last_error
  158. # Explicit opt-out clears unknown ownership after the user can inspect Notify.
  159. await configure(factory, lock_screen_widgets=False)
  160. await restarted.tick()
  161. assert await rows(factory) == []
  162. await configure(factory, lock_screen_widgets=True)
  163. api.create_widget.side_effect = None
  164. await restarted.tick()
  165. assert api.create_widget.await_count == 2
  166. @pytest.mark.asyncio
  167. async def test_process_crash_after_create_intent_does_not_duplicate(setup):
  168. service, api, factory, clock, states = setup
  169. await service.tick()
  170. async with factory() as db:
  171. row = await db.get(NotificationLockScreenWidget, 1)
  172. row.state, row.widget_id = "uncertain", None
  173. await db.commit()
  174. restarted = NotifyWidgetService(factory, api, states.get)
  175. await restarted.tick()
  176. assert api.create_widget.await_count == 1
  177. async with factory() as db:
  178. assert "Previous creation was interrupted" in (await db.get(NotificationProvider, 1)).last_error
  179. @pytest.mark.asyncio
  180. async def test_ambiguous_create_with_id_recovers_exact_id(setup):
  181. service, api, factory, clock, states = setup
  182. api.create_widget.side_effect = NotifyError(
  183. "unknown", status_code=502, payload={"widgetId": "WG123456"}, delivery_state="unknown"
  184. )
  185. await service.tick()
  186. clock[0] += timedelta(minutes=2)
  187. await service.tick()
  188. assert api.create_widget.await_count == 1
  189. assert api.update_widget.call_args.args[0] == "WG123456"
  190. @pytest.mark.asyncio
  191. @pytest.mark.parametrize(
  192. "error",
  193. [
  194. NotifyError("disabled", status_code=503),
  195. NotifyError("throttled", status_code=429, retry_after_seconds=1800),
  196. ],
  197. )
  198. async def test_retryable_creates_back_off(setup, error):
  199. service, api, factory, clock, states = setup
  200. api.create_widget.side_effect = error
  201. await service.tick()
  202. await service.tick()
  203. assert api.create_widget.await_count == 1
  204. clock[0] += timedelta(minutes=31)
  205. api.create_widget.side_effect = None
  206. await service.tick()
  207. assert api.create_widget.await_count == 2
  208. @pytest.mark.asyncio
  209. async def test_delete_retry_survives_restart_and_reenable(setup):
  210. service, api, factory, clock, states = setup
  211. await service.tick()
  212. await configure(factory, lock_screen_widgets=False)
  213. api.delete_widget.side_effect = NotifyError("timeout", delivery_state="unknown")
  214. await service.tick()
  215. assert (await rows(factory))[0].state == "deleting"
  216. restarted = NotifyWidgetService(factory, api, states.get)
  217. await configure(factory, lock_screen_widgets=True)
  218. await restarted.tick()
  219. assert api.create_widget.await_count == 1
  220. assert api.delete_widget.await_count == 1
  221. clock[0] += timedelta(minutes=2)
  222. api.delete_widget.side_effect = None
  223. await restarted.tick()
  224. await restarted.tick()
  225. assert api.create_widget.await_count == 2
  226. @pytest.mark.asyncio
  227. async def test_deleted_printer_widget_is_deleted_with_fk_enforced(setup):
  228. service, api, factory, clock, states = setup
  229. await service.tick()
  230. async with factory() as db:
  231. await db.delete(await db.get(Printer, 1))
  232. await db.commit()
  233. await service.tick()
  234. api.delete_widget.assert_awaited_once_with("WG123456", "secret")
  235. assert await rows(factory) == []
  236. @pytest.mark.asyncio
  237. async def test_cleanup_addresses_exact_saved_widget_with_old_token(setup):
  238. service, api, factory, clock, states = setup
  239. await service.tick()
  240. await service.cleanup_provider(1, {"device_id": "ABC12345", "token": "secret"})
  241. api.delete_widget.assert_awaited_once_with("WG123456", "secret")
  242. assert await rows(factory) == []
  243. @pytest.mark.asyncio
  244. async def test_deleted_remote_widget_403_is_confirmed_by_device_list(setup):
  245. service, api, factory, clock, states = setup
  246. await service.tick()
  247. await configure(factory, lock_screen_widgets=False)
  248. api.delete_widget.side_effect = NotifyError("Forbidden", status_code=403)
  249. await service.tick()
  250. assert api.list_widgets.await_count == 2
  251. assert api.list_widgets.call_args.args == ("ABC12345", "secret")
  252. assert await rows(factory) == []
  253. @pytest.mark.asyncio
  254. async def test_bad_credentials_do_not_discard_widget_needing_cleanup(setup):
  255. service, api, factory, clock, states = setup
  256. await service.tick()
  257. await configure(factory, lock_screen_widgets=False)
  258. api.delete_widget.side_effect = NotifyError("Forbidden", status_code=403)
  259. api.list_widgets.side_effect = NotifyError("Forbidden", status_code=403)
  260. await service.tick()
  261. assert (await rows(factory))[0].state == "deleting"
  262. @pytest.mark.asyncio
  263. async def test_success_does_not_clear_push_error(setup):
  264. service, api, factory, clock, states = setup
  265. async with factory() as db:
  266. provider = await db.get(NotificationProvider, 1)
  267. provider.last_error = "Push failed"
  268. await db.commit()
  269. await service.tick()
  270. async with factory() as db:
  271. assert (await db.get(NotificationProvider, 1)).last_error == "Push failed"
  272. @pytest.mark.asyncio
  273. async def test_no_database_session_during_http_and_concurrent_ticks_deduplicate(setup):
  274. service, api, factory, clock, states = setup
  275. entered = 0
  276. class TrackedSession:
  277. async def __aenter__(self):
  278. nonlocal entered
  279. entered += 1
  280. self.db = factory()
  281. return await self.db.__aenter__()
  282. async def __aexit__(self, *args):
  283. nonlocal entered
  284. await self.db.__aexit__(*args)
  285. entered -= 1
  286. service._session = TrackedSession
  287. async def send(*args):
  288. assert entered == 0
  289. await asyncio.sleep(0)
  290. return {"widgetId": "WG123456"}
  291. api.create_widget.side_effect = send
  292. await asyncio.gather(service.tick(), service.tick())
  293. assert api.create_widget.await_count == 1
  294. def test_faults_replace_gauge_and_advisories_do_not():
  295. fault = SimpleNamespace(description="Filament has run out", severity=2, full_code="07004000", actions=None)
  296. content = widget_content("P1", state(hms_errors=[fault]))
  297. assert content["value"] == "Error" and content["detail"] == "Filament has run out"
  298. assert content["unit"] is None and content["progress"] is None
  299. advisory = SimpleNamespace(description="Cover is open", severity=3, full_code="0000000012345678", actions=None)
  300. content = widget_content("P1", state(hms_errors=[advisory]))
  301. assert content["value"] == "42" and content["progress"] == 42
  302. assert "endsIn" not in content and "metrics" not in content
  303. def test_payload_caps_and_static_eta():
  304. content = widget_content("🖨" * 120, state(remaining_time=1500))
  305. assert "25h 0m" in content["detail"]
  306. assert len(json.dumps(content, ensure_ascii=False, separators=(",", ":")).encode()) <= 1024
  307. content = widget_content("P1", state(state="PAUSE"))
  308. assert content["value"] == "Paused" and content["progress"] is None
  309. @pytest.mark.asyncio
  310. async def test_opt_out_during_create_cannot_be_overwritten_by_response(setup):
  311. service, api, factory, clock, states = setup
  312. async def create(*args):
  313. async with factory() as db:
  314. await db.execute(
  315. update(NotificationLockScreenWidget).values(state="deleting", failures=0, next_attempt_at=None)
  316. )
  317. await db.commit()
  318. return {"widgetId": "WG123456"}
  319. api.create_widget.side_effect = create
  320. await service.tick()
  321. row = (await rows(factory))[0]
  322. assert row.state == "deleting" and row.widget_id == "WG123456"
  323. await service.tick()
  324. api.delete_widget.assert_awaited_once_with("WG123456", "secret")
  325. assert await rows(factory) == []
  326. @pytest.mark.asyncio
  327. async def test_uncertain_intent_reset_does_not_wait_for_backoff(setup):
  328. service, api, factory, clock, states = setup
  329. api.create_widget.side_effect = NotifyError("Timeout", delivery_state="unknown")
  330. await service.tick()
  331. await configure(factory, lock_screen_widgets=False)
  332. await service.tick()
  333. assert await rows(factory) == []
  334. @pytest.mark.asyncio
  335. async def test_token_rotation_preserves_widget_and_resumes_credential_failure(setup):
  336. service, api, factory, clock, states = setup
  337. await service.tick()
  338. async with factory() as db:
  339. row = await db.get(NotificationLockScreenWidget, 1)
  340. row.state = "suppressed"
  341. await db.commit()
  342. await configure(factory, token="rotated")
  343. await service.cleanup_provider(1, {"device_id": "ABC12345", "token": "secret"})
  344. row = (await rows(factory))[0]
  345. assert row.widget_id == "WG123456" and row.state == "active"
  346. await service.tick()
  347. api.update_widget.assert_awaited_once()
  348. assert api.update_widget.call_args.args[:2] == ("WG123456", "rotated")
  349. api.delete_widget.assert_not_awaited()
  350. assert api.create_widget.await_count == 1
  351. @pytest.mark.asyncio
  352. async def test_token_rotation_does_not_retry_unknown_creation(setup):
  353. service, api, factory, clock, states = setup
  354. api.create_widget.side_effect = NotifyError("unknown", delivery_state="unknown")
  355. await service.tick()
  356. await configure(factory, token="rotated")
  357. await service.cleanup_provider(1, {"device_id": "ABC12345", "token": "secret"})
  358. await service.tick()
  359. assert api.create_widget.await_count == 1
  360. assert (await rows(factory))[0].state == "uncertain"
  361. @pytest.mark.asyncio
  362. async def test_device_change_cleanup_failure_remains_visible_after_new_device_success(setup):
  363. service, api, factory, clock, states = setup
  364. await service.tick()
  365. await configure(factory, device_id="ABC12346", token="other")
  366. api.delete_widget.side_effect = NotifyError("timeout", delivery_state="unknown")
  367. await service.cleanup_provider(1, {"device_id": "ABC12345", "token": "secret"})
  368. assert await rows(factory) == []
  369. await service.tick()
  370. async with factory() as db:
  371. provider = await db.get(NotificationProvider, 1)
  372. assert "Remove the previous device" in provider.last_error
  373. assert api.create_widget.await_count == 2
  374. @pytest.mark.asyncio
  375. async def test_printer_scope_exclusion_deletes_only_owned_widget(setup):
  376. service, api, factory, clock, states = setup
  377. async with factory() as db:
  378. db.add(Printer(id=2, name="Printer 2", serial_number="serial2", ip_address="127.0.0.2", access_code="12345678"))
  379. await db.commit()
  380. api.create_widget.side_effect = [{"widgetId": "WG123456"}, {"widgetId": "WG654321"}]
  381. await service.tick()
  382. async with factory() as db:
  383. provider = await db.get(NotificationProvider, 1)
  384. provider.printer_id = 2
  385. await db.commit()
  386. await service.tick()
  387. api.delete_widget.assert_awaited_once_with("WG123456", "secret")
  388. assert [(row.printer_id, row.widget_id) for row in await rows(factory)] == [(2, "WG654321")]
  389. @pytest.mark.asyncio
  390. async def test_read_preflight_never_creates_or_adopts_unrelated_widget(setup):
  391. service, api, factory, clock, states = setup
  392. api.list_widgets.side_effect = NotifyError(
  393. "Expected a device widget list", delivery_state="unknown", payload={"widgetId": "WG654321"}
  394. )
  395. await service.tick()
  396. api.create_widget.assert_not_awaited()
  397. row = (await rows(factory))[0]
  398. assert row.widget_id is None and row.state == "pending"
  399. clock[0] += timedelta(minutes=2)
  400. api.list_widgets.side_effect = None
  401. await service.tick()
  402. api.create_widget.assert_awaited_once()
  403. @pytest.mark.asyncio
  404. async def test_credential_whitespace_does_not_create_duplicate_ownership(setup):
  405. service, api, factory, clock, states = setup
  406. await configure(factory, device_id=" ABC12345 ", token=" secret ")
  407. await service.tick()
  408. await configure(factory, device_id="ABC12345", token="rotated")
  409. await service.cleanup_provider(1, {"device_id": " ABC12345 ", "token": " secret "})
  410. await service.tick()
  411. assert api.create_widget.await_count == 1
  412. api.delete_widget.assert_not_awaited()
  413. @pytest.mark.asyncio
  414. async def test_dormant_widgets_stop_all_periodic_database_work(setup):
  415. service, api, factory, clock, states = setup
  416. await configure(factory, lock_screen_widgets=False)
  417. queries = []
  418. engine = factory.kw["bind"]
  419. def record(connection, cursor, statement, parameters, context, many):
  420. queries.append(statement)
  421. event.listen(engine.sync_engine, "before_cursor_execute", record)
  422. service.worker_interval = 0.01
  423. service.start()
  424. try:
  425. for _ in range(100):
  426. if not service._initial_discovery:
  427. break
  428. await asyncio.sleep(0.001)
  429. queries.clear()
  430. await asyncio.sleep(0.05)
  431. assert queries == []
  432. api.create_widget.assert_not_awaited()
  433. finally:
  434. await service.close()
  435. event.remove(engine.sync_engine, "before_cursor_execute", record)
  436. @pytest.mark.asyncio
  437. async def test_restart_drops_rows_of_deleted_provider_before_id_is_reused(setup):
  438. service, api, factory, clock, states = setup
  439. await service.tick()
  440. assert len(await rows(factory)) == 1
  441. # Production SQLite does not enforce the cascade, and the in-memory cleanup
  442. # job is lost when Bambuddy restarts before it drains.
  443. async with factory() as db:
  444. await db.execute(text("PRAGMA foreign_keys=OFF"))
  445. await db.execute(delete(NotificationProvider).where(NotificationProvider.id == 1))
  446. await db.commit()
  447. await db.execute(text("PRAGMA foreign_keys=ON"))
  448. assert len(await rows(factory)) == 1
  449. restarted = NotifyWidgetService(factory, api, states.get)
  450. restarted.start()
  451. try:
  452. for _ in range(100):
  453. if not restarted._initial_discovery:
  454. break
  455. await asyncio.sleep(0.001)
  456. finally:
  457. await restarted.close()
  458. assert await rows(factory) == []
  459. # The next provider reuses the freed id and starts from a clean slate.
  460. async with factory() as db:
  461. db.add(
  462. NotificationProvider(
  463. id=1,
  464. name="New phone",
  465. provider_type="notify",
  466. enabled=True,
  467. config=json.dumps({"device_id": "XYZ98765", "token": "other", "lock_screen_widgets": True}),
  468. )
  469. )
  470. await db.commit()
  471. api.create_widget.reset_mock()
  472. await restarted.tick()
  473. api.create_widget.assert_awaited_once()
  474. assert [row.credential_key for row in await rows(factory)] == [
  475. module._credential_key({"device_id": "XYZ98765", "token": "other"})
  476. ]
  477. @pytest.mark.asyncio
  478. async def test_purge_leaves_rows_of_existing_providers_alone(setup):
  479. service, api, factory, clock, states = setup
  480. await service.tick()
  481. await service._purge_orphans()
  482. assert len(await rows(factory)) == 1
  483. @pytest.mark.asyncio
  484. async def test_inactive_printer_never_gets_widget_and_existing_widget_is_removed(setup):
  485. service, api, factory, clock, states = setup
  486. await service.tick()
  487. async with factory() as db:
  488. (await db.get(Printer, 1)).is_active = False
  489. await db.commit()
  490. await service.tick()
  491. api.delete_widget.assert_awaited_once_with("WG123456", "secret")
  492. await service.tick()
  493. assert api.create_widget.await_count == 1
  494. @pytest.mark.asyncio
  495. async def test_capacity_refusal_waits_for_provider_save_instead_of_forever_retrying(setup):
  496. service, api, factory, clock, states = setup
  497. api.create_widget.side_effect = NotifyError(
  498. "capacity", status_code=400, payload={"message": "Maximum of 10 widgets"}
  499. )
  500. await service.tick()
  501. for _ in range(3):
  502. clock[0] += timedelta(hours=1)
  503. await service.tick()
  504. assert api.create_widget.await_count == 1
  505. assert (await rows(factory))[0].state == "capacity"
  506. service.providers_changed()
  507. api.create_widget.side_effect = None
  508. await service.tick()
  509. assert api.create_widget.await_count == 2
  510. @pytest.mark.asyncio
  511. async def test_all_printers_is_bounded_to_ten_local_widgets(setup):
  512. service, api, factory, clock, states = setup
  513. async with factory() as db:
  514. for printer_id in range(2, 13):
  515. db.add(
  516. Printer(
  517. id=printer_id,
  518. name=f"Printer {printer_id}",
  519. serial_number=f"serial{printer_id}",
  520. ip_address="127.0.0.1",
  521. access_code="12345678",
  522. )
  523. )
  524. await db.commit()
  525. await service.tick()
  526. assert api.create_widget.await_count == 10
  527. for _ in range(3):
  528. clock[0] += timedelta(hours=1)
  529. await service.tick()
  530. assert api.create_widget.await_count == 10
  531. assert len(await rows(factory)) == 10
  532. @pytest.mark.asyncio
  533. async def test_delete_during_inflight_create_is_nonblocking_and_cleans_returned_handle(setup):
  534. service, api, factory, clock, states = setup
  535. entered, release = asyncio.Event(), asyncio.Event()
  536. async def create(*args):
  537. entered.set()
  538. await release.wait()
  539. return {"widgetId": "WG123456"}
  540. api.create_widget.side_effect = create
  541. tick = asyncio.create_task(service.tick())
  542. await entered.wait()
  543. await asyncio.wait_for(service.schedule_cleanup(1, {"device_id": "ABC12345", "token": "secret"}), 0.2)
  544. async with factory() as db:
  545. await db.delete(await db.get(NotificationProvider, 1))
  546. await db.commit()
  547. release.set()
  548. await tick
  549. try:
  550. for _ in range(100):
  551. if api.delete_widget.await_count:
  552. break
  553. await asyncio.sleep(0.005)
  554. api.delete_widget.assert_awaited_once_with("WG123456", "secret")
  555. assert await rows(factory) == []
  556. finally:
  557. await service.close()
  558. @pytest.mark.asyncio
  559. async def test_retiring_during_device_preflight_prevents_create(setup):
  560. service, api, factory, clock, states = setup
  561. entered, release = asyncio.Event(), asyncio.Event()
  562. async def preflight(*args):
  563. entered.set()
  564. await release.wait()
  565. return {"widgets": []}
  566. api.list_widgets.side_effect = preflight
  567. tick = asyncio.create_task(service.tick())
  568. await entered.wait()
  569. await service.schedule_cleanup(1, {"device_id": "ABC12345", "token": "secret"})
  570. async with factory() as db:
  571. await db.delete(await db.get(NotificationProvider, 1))
  572. await db.commit()
  573. release.set()
  574. await tick
  575. await service.close()
  576. api.create_widget.assert_not_awaited()
  577. @pytest.mark.asyncio
  578. async def test_boot_cleans_active_widget_for_disabled_provider_without_printer_queries(setup):
  579. service, api, factory, clock, states = setup
  580. await service.tick()
  581. await configure(factory, lock_screen_widgets=False)
  582. statements = []
  583. engine = factory.kw["bind"]
  584. def record(connection, cursor, statement, parameters, context, many):
  585. statements.append(statement.lower())
  586. event.listen(engine.sync_engine, "before_cursor_execute", record)
  587. restarted = NotifyWidgetService(factory, api, states.get)
  588. restarted.start()
  589. try:
  590. for _ in range(100):
  591. if not await rows(factory):
  592. break
  593. await asyncio.sleep(0.005)
  594. assert await rows(factory) == []
  595. api.delete_widget.assert_awaited_once_with("WG123456", "secret")
  596. assert not any("from printers" in statement for statement in statements)
  597. finally:
  598. await restarted.close()
  599. event.remove(engine.sync_engine, "before_cursor_execute", record)
  600. @pytest.mark.asyncio
  601. async def test_cleanup_queued_during_same_generation_cleanup_is_not_lost(setup):
  602. service, api, factory, clock, states = setup
  603. await service.tick()
  604. entered, release = asyncio.Event(), asyncio.Event()
  605. batches = []
  606. async def cleanup(provider_id, config, *, captured_rows):
  607. batches.append(captured_rows)
  608. if len(batches) == 1:
  609. entered.set()
  610. await release.wait()
  611. service.cleanup_provider = cleanup
  612. config = {"device_id": "ABC12345", "token": "secret"}
  613. await service.schedule_cleanup(1, config)
  614. await entered.wait()
  615. await service.schedule_cleanup(1, config)
  616. release.set()
  617. try:
  618. for _ in range(100):
  619. if len(batches) == 2:
  620. break
  621. await asyncio.sleep(0.005)
  622. assert len(batches) == 2
  623. assert batches[0][0].widget_id == batches[1][0].widget_id == "WG123456"
  624. finally:
  625. await service.close()
  626. @pytest.mark.asyncio
  627. async def test_transient_startup_database_failure_retries_then_becomes_dormant(setup):
  628. service, api, factory, clock, states = setup
  629. await configure(factory, lock_screen_widgets=False)
  630. attempts = 0
  631. class FailOnce:
  632. async def __aenter__(self):
  633. nonlocal attempts
  634. attempts += 1
  635. if attempts == 1:
  636. raise RuntimeError("temporary database failure")
  637. self.db = factory()
  638. return await self.db.__aenter__()
  639. async def __aexit__(self, *args):
  640. await self.db.__aexit__(*args)
  641. service._session = FailOnce
  642. service._retry_delay = 0.001
  643. service.start()
  644. try:
  645. for _ in range(100):
  646. if not service._initial_discovery:
  647. break
  648. await asyncio.sleep(0.002)
  649. assert service._provider_enabled is False
  650. assert attempts >= 2
  651. settled = attempts
  652. await asyncio.sleep(0.02)
  653. assert attempts == settled
  654. api.create_widget.assert_not_awaited()
  655. finally:
  656. await service.close()
  657. @pytest.mark.asyncio
  658. async def test_existing_widget_success_does_not_clear_another_widgets_capacity_warning(setup):
  659. service, api, factory, clock, states = setup
  660. async with factory() as db:
  661. db.add(Printer(id=2, name="Printer 2", serial_number="serial2", ip_address="127.0.0.2", access_code="12345678"))
  662. await db.commit()
  663. capacity = NotifyError("capacity", status_code=400, payload={"message": "Maximum of 10 widgets"})
  664. api.create_widget.side_effect = [{"widgetId": "WG123456"}, capacity]
  665. await service.tick()
  666. states[1] = state(progress=43)
  667. clock[0] += timedelta(minutes=2)
  668. await service.tick()
  669. api.update_widget.assert_awaited_once()
  670. async with factory() as db:
  671. provider = await db.get(NotificationProvider, 1)
  672. assert provider.last_error.startswith("Notify! widget capacity: ")
  673. service.providers_changed()
  674. api.create_widget.side_effect = None
  675. api.create_widget.return_value = {"widgetId": "WG654321"}
  676. await service.tick()
  677. async with factory() as db:
  678. assert (await db.get(NotificationProvider, 1)).last_error is None