test_notify_live_activities.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896
  1. """Lifecycle protocol tests with real persistence and a fake Notify gateway."""
  2. import json
  3. from datetime import datetime, timedelta
  4. from types import SimpleNamespace
  5. from unittest.mock import AsyncMock
  6. import pytest
  7. from sqlalchemy import delete, event, select, text
  8. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  9. import backend.app.models # noqa: F401
  10. from backend.app.core.database import Base
  11. from backend.app.models.notification import NotificationProvider
  12. from backend.app.models.notification_live_activity import NotificationLiveActivity
  13. from backend.app.models.printer import Printer
  14. from backend.app.services import notify_live_activities as module
  15. from backend.app.services.notify_client import NotifyError
  16. from backend.app.services.notify_live_activities import NotifyLiveActivityService, PrintSnapshot
  17. @pytest.fixture
  18. async def setup(tmp_path, monkeypatch):
  19. engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'activities.db'}")
  20. @event.listens_for(engine.sync_engine, "connect")
  21. def enable_foreign_keys(connection, _):
  22. cursor = connection.cursor()
  23. cursor.execute("PRAGMA foreign_keys=ON")
  24. cursor.close()
  25. async with engine.begin() as conn:
  26. await conn.run_sync(Base.metadata.create_all)
  27. factory = async_sessionmaker(engine, expire_on_commit=False)
  28. async with factory() as db:
  29. db.add(Printer(id=1, name="P1", serial_number="serial1", ip_address="127.0.0.1", access_code="12345678"))
  30. db.add(
  31. NotificationProvider(
  32. id=1,
  33. name="Phone",
  34. provider_type="notify",
  35. enabled=True,
  36. config=json.dumps({"device_id": "ABC12345", "token": "secret", "live_activities": True}),
  37. on_print_start=False,
  38. on_print_progress=False,
  39. on_print_complete=False,
  40. daily_digest_enabled=True,
  41. )
  42. )
  43. await db.commit()
  44. api = SimpleNamespace(
  45. start_activity=AsyncMock(return_value={"activityId": "LA123456"}),
  46. get_activity=AsyncMock(return_value={"state": "active"}),
  47. update_activity=AsyncMock(return_value={"success": True}),
  48. end_activity=AsyncMock(return_value={"success": True}),
  49. )
  50. clock = [datetime(2026, 1, 1, 12)]
  51. monkeypatch.setattr(module, "_now", lambda: clock[0])
  52. service = NotifyLiveActivityService(factory, api)
  53. monkeypatch.setattr(service, "_quiet", lambda provider: False)
  54. yield service, api, factory, clock
  55. await engine.dispose()
  56. def state(**values):
  57. return (
  58. SimpleNamespace(
  59. connected=True,
  60. state="RUNNING",
  61. subtask_id="job1",
  62. subtask_name="Test print",
  63. current_print="test.3mf",
  64. progress=42,
  65. remaining_time=15,
  66. layer_num=42,
  67. total_layers=100,
  68. raw_data={},
  69. hms_errors=[],
  70. temperatures={"nozzle": 210, "bed": 60},
  71. stg_cur=-1,
  72. **values,
  73. )
  74. if not values
  75. else SimpleNamespace(
  76. **{
  77. **vars(state()),
  78. **values,
  79. }
  80. )
  81. )
  82. async def rows(factory):
  83. async with factory() as db:
  84. return (await db.scalars(select(NotificationLiveActivity))).all()
  85. async def start(setup):
  86. service, api, factory, clock = setup
  87. service.observe(1, state())
  88. await service.tick()
  89. return service, api, factory, clock
  90. @pytest.mark.asyncio
  91. async def test_start_independent_of_push_digest_and_group_thread(setup):
  92. service, api, factory, clock = setup
  93. async with factory() as db:
  94. provider = await db.get(NotificationProvider, 1)
  95. config = json.loads(provider.config)
  96. config["group_type"] = "Workshop"
  97. provider.config = json.dumps(config)
  98. await db.commit()
  99. await start(setup)
  100. assert api.start_activity.await_count == 1
  101. assert (await rows(factory))[0].activity_id == "LA123456"
  102. await service.tick()
  103. assert api.start_activity.await_count == 1
  104. @pytest.mark.asyncio
  105. async def test_startup_drops_rows_of_provider_deleted_before_cleanup_drained(setup):
  106. service, api, factory, clock = await start(setup)
  107. assert len(await rows(factory)) == 1
  108. # Production SQLite does not enforce the cascade.
  109. async with factory() as db:
  110. await db.execute(text("PRAGMA foreign_keys=OFF"))
  111. await db.execute(delete(NotificationProvider).where(NotificationProvider.id == 1))
  112. await db.commit()
  113. await db.execute(text("PRAGMA foreign_keys=ON"))
  114. restarted = NotifyLiveActivityService(factory, api)
  115. await restarted._purge_orphans()
  116. assert await rows(factory) == []
  117. @pytest.mark.asyncio
  118. async def test_restart_reuses_exact_id_without_new_start(setup, monkeypatch):
  119. service, api, factory, clock = await start(setup)
  120. clock[0] += timedelta(minutes=2)
  121. restarted = NotifyLiveActivityService(factory, api)
  122. monkeypatch.setattr(restarted, "_quiet", lambda provider: False)
  123. restarted.observe(1, state(progress=46))
  124. await restarted.tick()
  125. api.get_activity.assert_awaited_once_with("LA123456", "secret")
  126. assert api.start_activity.await_count == 1
  127. assert api.update_activity.call_args.args[0] == "LA123456"
  128. @pytest.mark.asyncio
  129. @pytest.mark.parametrize("phase,connected,expected", [("PAUSE", True, "Paused"), ("RUNNING", False, "Printer offline")])
  130. async def test_pause_disconnect_clear_countdown_then_resume(setup, phase, connected, expected):
  131. service, api, factory, clock = await start(setup)
  132. service.observe(1, state(state=phase, connected=connected))
  133. await service.tick()
  134. content = api.update_activity.call_args.args[2]
  135. assert content["endsIn"] is None
  136. assert content["status"] == expected
  137. service.observe(1, state())
  138. await service.tick()
  139. assert api.update_activity.call_args.args[2]["endsIn"] == 900
  140. assert api.start_activity.await_count == 1
  141. @pytest.mark.asyncio
  142. async def test_frozen_eta_does_not_drift(setup):
  143. service, api, factory, clock = await start(setup)
  144. clock[0] += timedelta(minutes=2)
  145. await service.tick()
  146. assert api.update_activity.call_args.args[2]["endsIn"] == 780
  147. service.observe(1, state(remaining_time=10))
  148. clock[0] += timedelta(minutes=1)
  149. await service.tick()
  150. assert api.update_activity.call_args.args[2]["endsIn"] == 600
  151. @pytest.mark.asyncio
  152. async def test_quiet_hours_block_starts_but_not_updates_or_ends(setup, monkeypatch):
  153. service, api, factory, clock = setup
  154. monkeypatch.setattr(service, "_quiet", lambda provider: True)
  155. service.observe(1, state())
  156. await service.tick()
  157. api.start_activity.assert_not_awaited()
  158. monkeypatch.setattr(service, "_quiet", lambda provider: False)
  159. await service.tick()
  160. monkeypatch.setattr(service, "_quiet", lambda provider: True)
  161. clock[0] += timedelta(minutes=2)
  162. await service.tick()
  163. api.update_activity.assert_awaited_once()
  164. service.print_finished(1, {"subtask_id": "job1", "status": "completed"})
  165. await service.tick()
  166. assert api.end_activity.call_args.args[2]["progress"] == 100
  167. @pytest.mark.asyncio
  168. @pytest.mark.parametrize("reason", ["dismissed", "dead-token", "never-started", "script"])
  169. async def test_terminal_suppression_survives_restart(setup, monkeypatch, reason):
  170. service, api, factory, clock = await start(setup)
  171. api.get_activity.return_value = {"state": "dismissed" if reason == "dismissed" else "ended", "endReason": reason}
  172. clock[0] += timedelta(minutes=2)
  173. await service.tick()
  174. assert (await rows(factory))[0].state == "suppressed"
  175. restarted = NotifyLiveActivityService(factory, api)
  176. monkeypatch.setattr(restarted, "_quiet", lambda provider: False)
  177. restarted.observe(1, state())
  178. await restarted.tick()
  179. assert api.start_activity.await_count == 1
  180. restarted.observe(1, state(subtask_id="job2"))
  181. await restarted.tick()
  182. assert api.start_activity.await_count == 2
  183. @pytest.mark.asyncio
  184. @pytest.mark.parametrize("reason", ["expired", "overdue", "abandoned"])
  185. async def test_expiry_rolls_over_only_for_live_print(setup, reason):
  186. service, api, factory, clock = await start(setup)
  187. api.get_activity.return_value = {"state": "ended", "endReason": reason}
  188. clock[0] += timedelta(hours=8)
  189. api.start_activity.return_value = {"activityId": "LA654321"}
  190. await service.tick()
  191. assert api.start_activity.await_count == 2
  192. assert (await rows(factory))[0].activity_id == "LA654321"
  193. @pytest.mark.asyncio
  194. async def test_eight_hour_ceiling_polls_before_rollover(setup):
  195. service, api, factory, clock = await start(setup)
  196. clock[0] += timedelta(hours=8)
  197. await service.tick()
  198. api.get_activity.assert_awaited_once()
  199. api.end_activity.assert_awaited_once_with("LA123456", "secret")
  200. assert api.start_activity.await_count == 2
  201. @pytest.mark.asyncio
  202. async def test_ambiguous_start_with_id_is_reconciled_without_duplication(setup):
  203. service, api, factory, clock = setup
  204. api.start_activity.side_effect = NotifyError(
  205. "unknown", status_code=502, payload={"activityId": "LA123456", "deliveryState": "unknown"}
  206. )
  207. await start(setup)
  208. assert (await rows(factory))[0].activity_id == "LA123456"
  209. clock[0] += timedelta(minutes=2)
  210. await service.tick()
  211. api.get_activity.assert_awaited_once_with("LA123456", "secret")
  212. assert api.start_activity.await_count == 1
  213. @pytest.mark.asyncio
  214. async def test_ambiguous_start_without_id_never_repeats(setup):
  215. service, api, factory, clock = setup
  216. api.start_activity.side_effect = NotifyError("timeout", delivery_state="unknown")
  217. await start(setup)
  218. clock[0] += timedelta(hours=2)
  219. await service.tick()
  220. assert api.start_activity.await_count == 1
  221. assert (await rows(factory))[0].state == "suppressed"
  222. @pytest.mark.asyncio
  223. @pytest.mark.parametrize(
  224. "error",
  225. [
  226. NotifyError("full", status_code=400, payload={"message": "Maximum 5 activities"}),
  227. NotifyError("throttled", status_code=429, retry_after_seconds=1800),
  228. NotifyError("not delivered", status_code=502, retry_after_seconds=1800, delivery_state="not-delivered"),
  229. NotifyError("unavailable", status_code=503),
  230. ],
  231. )
  232. async def test_retryable_starts_honor_backoff(setup, error):
  233. service, api, factory, clock = setup
  234. api.start_activity.side_effect = error
  235. await start(setup)
  236. await service.tick()
  237. assert api.start_activity.await_count == 1
  238. clock[0] += timedelta(minutes=31)
  239. api.start_activity.side_effect = None
  240. await service.tick()
  241. assert api.start_activity.await_count == 2
  242. @pytest.mark.asyncio
  243. async def test_end_intent_survives_failure_restart_and_next_job(setup, monkeypatch):
  244. service, api, factory, clock = await start(setup)
  245. api.end_activity.side_effect = NotifyError("timeout", delivery_state="unknown")
  246. service.print_finished(1, {"subtask_id": "job1", "status": "completed"})
  247. await service.tick()
  248. assert (await rows(factory))[0].state == "ending"
  249. await service.tick()
  250. assert api.end_activity.await_count == 1
  251. restarted = NotifyLiveActivityService(factory, api)
  252. monkeypatch.setattr(restarted, "_quiet", lambda provider: False)
  253. restarted.observe(1, state(subtask_id="job2"))
  254. clock[0] += timedelta(minutes=2)
  255. api.end_activity.side_effect = None
  256. await restarted.tick()
  257. assert api.end_activity.call_args.args[2]["status"] == "Complete"
  258. assert api.start_activity.await_count == 2
  259. assert (await rows(factory))[0].state == "ended"
  260. @pytest.mark.asyncio
  261. async def test_late_complete_does_not_end_new_job(setup):
  262. service, api, factory, clock = await start(setup)
  263. service.observe(1, state(subtask_id="job2"))
  264. await service.tick()
  265. api.end_activity.reset_mock()
  266. service.print_finished(1, {"subtask_id": "job1", "status": "completed"})
  267. await service.tick()
  268. api.end_activity.assert_not_awaited()
  269. @pytest.mark.asyncio
  270. async def test_fallback_survives_delta_and_late_job_id(setup):
  271. service, api, factory, clock = setup
  272. service.print_started(1, state(subtask_id=None, raw_data={"gcode_start_time": 12345}))
  273. await service.tick()
  274. clock[0] += timedelta(minutes=2)
  275. service.observe(1, state(subtask_id=None, raw_data={}))
  276. await service.tick()
  277. service.observe(1, state(subtask_id="finally-known"))
  278. clock[0] += timedelta(minutes=2)
  279. await service.tick()
  280. assert api.start_activity.await_count == 1
  281. assert api.end_activity.await_count == 0
  282. @pytest.mark.asyncio
  283. async def test_scope_and_disabled_provider_end_tiles(setup):
  284. service, api, factory, clock = await start(setup)
  285. async with factory() as db:
  286. provider = await db.get(NotificationProvider, 1)
  287. provider.enabled = False
  288. await db.commit()
  289. await service.tick()
  290. assert api.end_activity.await_count == 1
  291. assert (await rows(factory))[0].state == "ended"
  292. @pytest.mark.asyncio
  293. async def test_delete_printer_retains_id_until_remote_end(setup):
  294. service, api, factory, clock = await start(setup)
  295. async with factory() as db:
  296. await db.delete(await db.get(Printer, 1))
  297. await db.commit()
  298. await service.tick()
  299. assert api.end_activity.await_count == 1
  300. assert (await rows(factory))[0].state == "ended"
  301. @pytest.mark.asyncio
  302. async def test_cleanup_uses_old_token_and_removes_ownership(setup):
  303. service, api, factory, clock = await start(setup)
  304. await service.cleanup_provider(1, {"device_id": "ABC12345", "token": "secret"})
  305. api.end_activity.assert_awaited_once_with("LA123456", "secret")
  306. assert await rows(factory) == []
  307. @pytest.mark.asyncio
  308. async def test_http_never_holds_database_session_and_concurrent_ticks_deduplicate(setup):
  309. import asyncio
  310. service, api, factory, clock = setup
  311. entered = 0
  312. class TrackedSession:
  313. async def __aenter__(self):
  314. nonlocal entered
  315. entered += 1
  316. self.db = factory()
  317. return await self.db.__aenter__()
  318. async def __aexit__(self, *args):
  319. nonlocal entered
  320. await self.db.__aexit__(*args)
  321. entered -= 1
  322. service._session = TrackedSession
  323. async def send(*args):
  324. assert entered == 0
  325. await asyncio.sleep(0)
  326. return {"activityId": "LA123456"}
  327. api.start_activity.side_effect = send
  328. service.observe(1, state())
  329. await asyncio.gather(service.tick(), service.tick(), service.tick())
  330. assert api.start_activity.await_count == 1
  331. def test_advanced_content_and_utf8_budget():
  332. snapshot = PrintSnapshot.from_state(state(subtask_name="🔒" * 255, remaining_time=1500))
  333. config = {
  334. "live_activity_style": "segments",
  335. "live_activity_metrics": ["progress", "eta", "layers", "nozzle", "bed", "chamber"],
  336. "live_activity_button_url": "https://bambuddy.example/" + "a" * 480,
  337. "live_activity_privacy": True,
  338. }
  339. content = snapshot.content("🖨" * 100, config)
  340. assert "🔒" not in content["body"]
  341. assert content["steps"] == 10 and content["step"] == 4
  342. assert content["endsIn"] is None and content["trailing"] == "25h 0m"
  343. assert len(content["metrics"]) == 5
  344. assert len(json.dumps(content, ensure_ascii=False).encode()) < 2048
  345. assert content["button"] is None or content["button"]["open"] is True
  346. assert snapshot.content("P1", {"live_activity_style": "none"})["progress"] is None
  347. @pytest.mark.asyncio
  348. async def test_fallback_dismissal_follows_late_id_across_restart(setup, monkeypatch):
  349. service, api, factory, clock = setup
  350. service.print_started(1, state(subtask_id=None))
  351. await service.tick()
  352. clock[0] += timedelta(minutes=2)
  353. api.get_activity.return_value = {"state": "dismissed", "endReason": "dismissed"}
  354. await service.tick()
  355. service.observe(1, state(subtask_id="late-id"))
  356. await service.tick()
  357. assert (await rows(factory))[0].job_key == "job:late-id"
  358. restarted = NotifyLiveActivityService(factory, api)
  359. monkeypatch.setattr(restarted, "_quiet", lambda provider: False)
  360. restarted.observe(1, state(subtask_id="late-id"))
  361. await restarted.tick()
  362. assert api.start_activity.await_count == 1
  363. @pytest.mark.asyncio
  364. async def test_end_retry_without_any_printer_snapshot(setup):
  365. service, api, factory, clock = await start(setup)
  366. api.end_activity.side_effect = NotifyError("unavailable", status_code=503)
  367. service.print_finished(1, {"subtask_id": "job1", "status": "failed"})
  368. await service.tick()
  369. restarted = NotifyLiveActivityService(factory, api)
  370. clock[0] += timedelta(minutes=2)
  371. api.end_activity.side_effect = None
  372. await restarted.tick()
  373. assert api.end_activity.call_args.args[2]["status"] == "Failed"
  374. assert (await rows(factory))[0].state == "ended"
  375. @pytest.mark.asyncio
  376. async def test_printer_scope_new_provider_separate_ownership(setup):
  377. service, api, factory, clock = setup
  378. async with factory() as db:
  379. db.add(Printer(id=2, name="P2", serial_number="serial2", ip_address="127.0.0.2", access_code="12345678"))
  380. db.add(
  381. NotificationProvider(
  382. id=2,
  383. name="Other Phone",
  384. provider_type="notify",
  385. printer_id=2,
  386. config=json.dumps({"device_id": "ABC12346", "token": "other", "live_activities": True}),
  387. )
  388. )
  389. await db.commit()
  390. await start(setup)
  391. assert api.start_activity.await_count == 1
  392. async with factory() as db:
  393. provider = await db.get(NotificationProvider, 2)
  394. provider.printer_id = 1
  395. await db.commit()
  396. await service.tick()
  397. assert api.start_activity.await_count == 2
  398. assert {r.provider_id for r in await rows(factory)} == {1, 2}
  399. @pytest.mark.asyncio
  400. async def test_unknown_start_record_after_process_crash_is_never_restarted(setup):
  401. service, api, factory, clock = await start(setup)
  402. async with factory() as db:
  403. row = await db.get(NotificationLiveActivity, 1)
  404. row.activity_id = None
  405. row.state = "uncertain"
  406. await db.commit()
  407. clock[0] += timedelta(minutes=2)
  408. await service.tick()
  409. assert api.start_activity.await_count == 1
  410. assert (await rows(factory))[0].state == "suppressed"
  411. @pytest.mark.asyncio
  412. async def test_410_is_polled_for_reason_before_any_rollover(setup):
  413. service, api, factory, clock = await start(setup)
  414. api.update_activity.side_effect = NotifyError("gone", status_code=410)
  415. clock[0] += timedelta(minutes=2)
  416. await service.tick()
  417. api.get_activity.return_value = {"state": "dismissed", "endReason": "dismissed"}
  418. clock[0] += timedelta(minutes=2)
  419. await service.tick()
  420. assert api.start_activity.await_count == 1
  421. assert (await rows(factory))[0].state == "suppressed"
  422. def fault(description="Nozzle temperature malfunction", severity=2, full_code="05004003", actions=None):
  423. return SimpleNamespace(description=description, severity=severity, full_code=full_code, actions=actions)
  424. @pytest.mark.asyncio
  425. async def test_running_printer_fault_replaces_counter_and_restores_after_clear(setup):
  426. service, api, factory, clock = await start(setup)
  427. async with factory() as db:
  428. provider = await db.get(NotificationProvider, 1)
  429. config = json.loads(provider.config)
  430. config.update(live_activity_metrics=["progress", "eta"], live_activity_stage=True)
  431. provider.config = json.dumps(config)
  432. await db.commit()
  433. service.observe(1, state(hms_errors=[fault()]))
  434. await service.tick()
  435. content = api.update_activity.call_args.args[2]
  436. assert content["status"] == "Printer error"
  437. assert content["body"] == "Nozzle temperature malfunction"
  438. assert content["endsIn"] is None
  439. assert content["metrics"] is None
  440. service.observe(1, state(hms_errors=[]))
  441. await service.tick()
  442. content = api.update_activity.call_args.args[2]
  443. assert content["status"] == "Printing"
  444. assert content["endsIn"] == 900
  445. assert len(content["metrics"]) == 2
  446. assert api.start_activity.await_count == 1
  447. def test_runout_and_actionable_level_three_prompt():
  448. snapshot = PrintSnapshot.from_state(state(hms_errors=[fault("Filament has run out", 3)]))
  449. content = snapshot.content("P1", {"live_activity_privacy": True})
  450. assert content["status"] == "Filament runout"
  451. assert content["endsIn"] is None
  452. assert "Test print" not in content["body"]
  453. snapshot = PrintSnapshot.from_state(
  454. state(hms_errors=[fault("Heatbreak fan problem", 3, "0000000012345678", ["retry"])])
  455. )
  456. assert snapshot.content("P1")["status"] == "Printer error"
  457. @pytest.mark.parametrize(
  458. "error",
  459. [
  460. fault("The cover is open", 3, "0000000012345678"),
  461. fault("Invalid level", 0),
  462. fault("", 2),
  463. ],
  464. )
  465. def test_advisory_does_not_interrupt_live_countdown(error):
  466. content = PrintSnapshot.from_state(state(hms_errors=[error])).content("P1")
  467. assert content["status"] == "Printing"
  468. assert content["endsIn"] == 900
  469. @pytest.mark.asyncio
  470. @pytest.mark.parametrize("device_id", ["GRP12345", "WB123456", "MC123456"])
  471. async def test_non_ios_devices_never_start_activity(setup, device_id):
  472. service, api, factory, clock = setup
  473. async with factory() as db:
  474. provider = await db.get(NotificationProvider, 1)
  475. config = json.loads(provider.config)
  476. config["device_id"] = device_id
  477. provider.config = json.dumps(config)
  478. await db.commit()
  479. service.observe(1, state())
  480. await service.tick()
  481. api.start_activity.assert_not_awaited()
  482. @pytest.mark.asyncio
  483. async def test_new_start_resets_previous_progress_and_eta_until_fresh_telemetry(setup):
  484. service, api, factory, clock = setup
  485. service.print_started(1, state(progress=99, remaining_time=1))
  486. await service.tick()
  487. assert api.start_activity.call_args.args[2]["progress"] == 0
  488. assert api.start_activity.call_args.args[2]["endsIn"] is None
  489. service.observe(1, state(progress=99, remaining_time=1))
  490. clock[0] += timedelta(seconds=61)
  491. await service.tick()
  492. assert api.update_activity.call_args.args[2]["progress"] == 0
  493. assert api.update_activity.call_args.args[2]["endsIn"] is None
  494. service.observe(1, state(progress=2, remaining_time=50))
  495. clock[0] += timedelta(seconds=61)
  496. await service.tick()
  497. assert api.update_activity.call_args.args[2]["progress"] == 2
  498. assert api.update_activity.call_args.args[2]["endsIn"] == 3000
  499. @pytest.mark.asyncio
  500. async def test_old_tile_must_end_before_starting_next_job(setup):
  501. service, api, factory, clock = await start(setup)
  502. api.end_activity.side_effect = NotifyError("unavailable", status_code=503)
  503. service.observe(1, state(subtask_id="job2"))
  504. await service.tick()
  505. assert api.start_activity.await_count == 1
  506. assert (await rows(factory))[0].state == "ending"
  507. api.end_activity.side_effect = None
  508. clock[0] += timedelta(minutes=2)
  509. await service.tick()
  510. assert api.start_activity.await_count == 2
  511. @pytest.mark.asyncio
  512. async def test_complete_matches_learned_job_id_for_fallback_row(setup):
  513. service, api, factory, clock = setup
  514. service.print_started(1, state(subtask_id=None))
  515. await service.tick()
  516. service.observe(1, state(subtask_id="learned"))
  517. await service.tick()
  518. service.print_finished(1, {"raw_data": {"subtask_id": "learned"}, "status": "completed"})
  519. await service.tick()
  520. assert api.end_activity.call_args.args[2]["status"] == "Complete"
  521. assert (await rows(factory))[0].state == "ended"
  522. def test_preparation_does_not_display_previous_print_progress():
  523. snapshot = PrintSnapshot.from_state(state(layer_num=0, total_layers=100, progress=85))
  524. content = snapshot.content("P1", {"live_activity_metrics": ["progress"]})
  525. assert content["status"] == "Preparing"
  526. assert content["progress"] == 0
  527. assert content["metrics"][0]["value"] == "0%"
  528. @pytest.mark.asyncio
  529. async def test_reenable_resumes_current_print_after_successful_cleanup(setup):
  530. service, api, factory, clock = await start(setup)
  531. async with factory() as db:
  532. provider = await db.get(NotificationProvider, 1)
  533. provider.enabled = False
  534. await db.commit()
  535. await service.tick()
  536. assert (await rows(factory))[0].end_reason == "disabled"
  537. async with factory() as db:
  538. provider = await db.get(NotificationProvider, 1)
  539. provider.enabled = True
  540. await db.commit()
  541. await service.tick()
  542. assert api.start_activity.await_count == 1 # Wait for fresh telemetry after dormant time.
  543. service.observe(1, state())
  544. await service.tick()
  545. assert api.start_activity.await_count == 2
  546. @pytest.mark.asyncio
  547. async def test_reenable_waits_for_failed_cleanup_before_restarting(setup):
  548. service, api, factory, clock = await start(setup)
  549. async with factory() as db:
  550. provider = await db.get(NotificationProvider, 1)
  551. provider.enabled = False
  552. await db.commit()
  553. api.end_activity.side_effect = NotifyError("unavailable", status_code=503)
  554. await service.tick()
  555. async with factory() as db:
  556. provider = await db.get(NotificationProvider, 1)
  557. provider.enabled = True
  558. await db.commit()
  559. await service.tick()
  560. assert api.start_activity.await_count == 1
  561. api.end_activity.side_effect = None
  562. clock[0] += timedelta(minutes=2)
  563. await service.tick()
  564. service.observe(1, state())
  565. await service.tick()
  566. assert api.start_activity.await_count == 2
  567. @pytest.mark.parametrize("config", [{}, {"live_activity_metrics": []}])
  568. def test_default_tile_uses_native_timer_without_metric_chips(config):
  569. content = PrintSnapshot.from_state(state()).content("P1", config)
  570. assert content["endsIn"] == 900
  571. assert content["metrics"] is None
  572. assert content["trailing"] is None
  573. @pytest.mark.asyncio
  574. async def test_disabling_optional_metrics_clears_chips_and_preserves_timer(setup):
  575. service, api, factory, clock = setup
  576. async with factory() as db:
  577. provider = await db.get(NotificationProvider, 1)
  578. config = json.loads(provider.config)
  579. config["live_activity_metrics"] = ["progress", "eta"]
  580. provider.config = json.dumps(config)
  581. await db.commit()
  582. await start(setup)
  583. content = api.start_activity.call_args.args[2]
  584. assert content["endsIn"] == 900
  585. assert content["metrics"][0] == {"label": "Progress", "value": "42%", "unit": ""}
  586. async with factory() as db:
  587. provider = await db.get(NotificationProvider, 1)
  588. config["live_activity_metrics"] = []
  589. provider.config = json.dumps(config)
  590. await db.commit()
  591. clock[0] += timedelta(minutes=1)
  592. await service.tick()
  593. content = api.update_activity.call_args.args[2]
  594. assert content["metrics"] is None
  595. assert content["endsIn"] == 840
  596. assert api.start_activity.await_count == 1
  597. @pytest.mark.asyncio
  598. async def test_dormant_worker_has_no_periodic_sql_or_mqtt_wakeups(setup):
  599. import asyncio
  600. from sqlalchemy import event
  601. service, api, factory, clock = setup
  602. async with factory() as db:
  603. provider = await db.get(NotificationProvider, 1)
  604. provider.enabled = False
  605. await db.commit()
  606. queries = []
  607. engine = factory.kw["bind"]
  608. def record(connection, cursor, statement, parameters, context, many):
  609. queries.append(statement)
  610. event.listen(engine.sync_engine, "before_cursor_execute", record)
  611. service.worker_interval = 0.01
  612. service.start()
  613. try:
  614. for _ in range(100):
  615. if not service._initial_discovery:
  616. break
  617. await asyncio.sleep(0.001)
  618. queries.clear()
  619. service.observe(1, state())
  620. service.print_started(1, state())
  621. service.print_finished(1, {"subtask_id": "job1", "status": "completed"})
  622. await asyncio.sleep(0.05)
  623. assert queries == []
  624. assert service._snapshots == {}
  625. api.start_activity.assert_not_awaited()
  626. finally:
  627. await service.close()
  628. event.remove(engine.sync_engine, "before_cursor_execute", record)
  629. @pytest.mark.asyncio
  630. async def test_startup_waits_for_real_status_before_touching_existing_activity(setup):
  631. service, api, factory, clock = await start(setup)
  632. clock[0] += timedelta(minutes=2)
  633. restarted = NotifyLiveActivityService(factory, api)
  634. await restarted.tick()
  635. api.get_activity.assert_not_awaited()
  636. restarted.observe(1, state(state="unknown", progress=0))
  637. await restarted.tick()
  638. api.get_activity.assert_not_awaited()
  639. restarted.observe(1, state())
  640. await restarted.tick()
  641. api.get_activity.assert_awaited_once()
  642. @pytest.mark.asyncio
  643. @pytest.mark.parametrize("connected", [None, False, True])
  644. async def test_startup_grace_expires_without_real_status_and_reconnect_resumes(setup, connected):
  645. service, api, factory, clock = await start(setup)
  646. clock[0] += timedelta(minutes=2)
  647. restarted = NotifyLiveActivityService(factory, api)
  648. # Importing the service before worker startup must not consume the grace.
  649. clock[0] += timedelta(minutes=5)
  650. # Neither complete silence nor broker-only status may extend the grace.
  651. for seconds in (0, 90, 29):
  652. clock[0] += timedelta(seconds=seconds)
  653. if connected is not None:
  654. restarted.observe(1, state(state="unknown", connected=connected, progress=0))
  655. await restarted.tick()
  656. api.get_activity.assert_not_awaited()
  657. api.update_activity.assert_not_awaited()
  658. clock[0] += timedelta(seconds=1)
  659. await restarted.tick()
  660. api.get_activity.assert_awaited_once_with("LA123456", "secret")
  661. content = api.update_activity.call_args.args[2]
  662. assert content["status"] == "Printer offline"
  663. assert content["endsIn"] is None
  664. assert content["progress"] == 42
  665. saved = (await rows(factory))[0]
  666. assert saved.eta_seconds is None
  667. assert saved.eta_deadline is None
  668. assert saved.activity_id == "LA123456"
  669. api.end_activity.assert_not_awaited()
  670. assert api.start_activity.await_count == 1
  671. # A real status resumes the same tile immediately, even during throttling.
  672. restarted.observe(1, state(progress=46, remaining_time=10))
  673. await restarted.tick()
  674. assert api.update_activity.await_count == 2
  675. assert api.update_activity.call_args.args[0] == "LA123456"
  676. content = api.update_activity.call_args.args[2]
  677. assert content["status"] == "Printing"
  678. assert content["endsIn"] == 600
  679. assert content["progress"] == 46
  680. assert api.start_activity.await_count == 1
  681. @pytest.mark.asyncio
  682. @pytest.mark.parametrize("reason", ["expired", "dismissed"])
  683. async def test_startup_grace_never_restarts_an_offline_printers_activity(setup, reason):
  684. service, api, factory, clock = await start(setup)
  685. restarted = NotifyLiveActivityService(factory, api)
  686. await restarted.tick()
  687. api.get_activity.return_value = {"state": "ended", "endReason": reason}
  688. clock[0] += timedelta(minutes=2)
  689. await restarted.tick()
  690. api.get_activity.assert_awaited_once_with("LA123456", "secret")
  691. api.update_activity.assert_not_awaited()
  692. api.end_activity.assert_not_awaited()
  693. assert api.start_activity.await_count == 1
  694. assert (await rows(factory))[0].state == ("pending" if reason == "expired" else "suppressed")
  695. @pytest.mark.asyncio
  696. async def test_startup_offline_clears_saved_eta_labels(setup):
  697. service, api, factory, clock = setup
  698. async with factory() as db:
  699. provider = await db.get(NotificationProvider, 1)
  700. config = json.loads(provider.config)
  701. config["live_activity_metrics"] = ["progress", "eta", "nozzle"]
  702. provider.config = json.dumps(config)
  703. await db.commit()
  704. service.observe(1, state(remaining_time=25 * 60))
  705. await service.tick()
  706. original = api.start_activity.call_args.args[2]
  707. assert original["trailing"] == "25h 0m"
  708. assert any(metric["label"] == "Remaining" for metric in original["metrics"])
  709. restarted = NotifyLiveActivityService(factory, api)
  710. await restarted.tick()
  711. clock[0] += timedelta(minutes=2)
  712. await restarted.tick()
  713. content = api.update_activity.call_args.args[2]
  714. assert content["status"] == "Printer offline"
  715. assert content["endsIn"] is None
  716. assert content["trailing"] is None
  717. assert content["metrics"] is None
  718. assert content["progress"] == 42
  719. @pytest.mark.asyncio
  720. async def test_scheduled_cleanup_does_not_wait_for_create_and_keeps_late_handle(setup):
  721. import asyncio
  722. service, api, factory, clock = setup
  723. entered, release = asyncio.Event(), asyncio.Event()
  724. async def create(*args):
  725. entered.set()
  726. await release.wait()
  727. return {"activityId": "LA123456"}
  728. api.start_activity.side_effect = create
  729. service.observe(1, state())
  730. tick = asyncio.create_task(service.tick())
  731. await entered.wait()
  732. await asyncio.wait_for(service.schedule_cleanup(1, {"device_id": "ABC12345", "token": "secret"}), 0.2)
  733. async with factory() as db:
  734. await db.delete(await db.get(NotificationProvider, 1))
  735. await db.commit()
  736. release.set()
  737. await tick
  738. try:
  739. for _ in range(100):
  740. if api.end_activity.await_count:
  741. break
  742. await asyncio.sleep(0.005)
  743. api.end_activity.assert_awaited_once_with("LA123456", "secret")
  744. assert await rows(factory) == []
  745. finally:
  746. await service.close()
  747. @pytest.mark.asyncio
  748. async def test_boot_cleans_disabled_activity_without_printer_queries_or_pruning(setup):
  749. import asyncio
  750. from sqlalchemy import event
  751. service, api, factory, clock = await start(setup)
  752. async with factory() as db:
  753. (await db.get(NotificationProvider, 1)).enabled = False
  754. await db.commit()
  755. statements = []
  756. engine = factory.kw["bind"]
  757. def record(connection, cursor, statement, parameters, context, many):
  758. statements.append(statement.lower())
  759. event.listen(engine.sync_engine, "before_cursor_execute", record)
  760. restarted = NotifyLiveActivityService(factory, api)
  761. restarted.start()
  762. try:
  763. for _ in range(100):
  764. if (await rows(factory))[0].state == "ended":
  765. break
  766. await asyncio.sleep(0.005)
  767. assert (await rows(factory))[0].state == "ended"
  768. assert not any("from printers" in statement for statement in statements)
  769. assert not any(statement.startswith("delete from notification_live_activities") for statement in statements)
  770. finally:
  771. await restarted.close()
  772. event.remove(engine.sync_engine, "before_cursor_execute", record)