test_obico_detection.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599
  1. """Unit tests for Obico detection service (#172)."""
  2. from unittest.mock import AsyncMock, MagicMock, patch
  3. import pytest
  4. from backend.app.schemas.settings import AppSettingsUpdate
  5. from backend.app.services.obico_detection import (
  6. FRAME_CACHE_TTL,
  7. ObicoDetectionService,
  8. _frame_cache,
  9. pop_frame,
  10. stash_frame,
  11. )
  12. from backend.app.services.obico_smoothing import WARMUP_FRAMES
  13. FAKE_JPEG = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xff\xd9"
  14. class TestSettingsSchemaValidators:
  15. """Guard rails on the new obico_* AppSettings fields."""
  16. def test_sensitivity_accepts_valid_values(self):
  17. for value in ("low", "medium", "high"):
  18. u = AppSettingsUpdate(obico_sensitivity=value)
  19. assert u.obico_sensitivity == value
  20. def test_sensitivity_rejects_garbage(self):
  21. with pytest.raises(ValueError, match="obico_sensitivity"):
  22. AppSettingsUpdate(obico_sensitivity="extreme")
  23. def test_action_accepts_valid_values(self):
  24. for value in ("notify", "pause", "pause_and_off"):
  25. assert AppSettingsUpdate(obico_action=value).obico_action == value
  26. def test_action_rejects_garbage(self):
  27. with pytest.raises(ValueError, match="obico_action"):
  28. AppSettingsUpdate(obico_action="explode")
  29. def test_enabled_printers_accepts_empty(self):
  30. assert AppSettingsUpdate(obico_enabled_printers="").obico_enabled_printers == ""
  31. assert AppSettingsUpdate(obico_enabled_printers=None).obico_enabled_printers is None
  32. def test_enabled_printers_accepts_int_array(self):
  33. u = AppSettingsUpdate(obico_enabled_printers="[1, 2, 3]")
  34. assert u.obico_enabled_printers == "[1, 2, 3]"
  35. def test_enabled_printers_rejects_non_json(self):
  36. with pytest.raises(ValueError, match="valid JSON"):
  37. AppSettingsUpdate(obico_enabled_printers="1,2,3")
  38. def test_enabled_printers_rejects_non_list(self):
  39. with pytest.raises(ValueError, match="JSON array"):
  40. AppSettingsUpdate(obico_enabled_printers='{"1": true}')
  41. def test_enabled_printers_rejects_non_int_elements(self):
  42. with pytest.raises(ValueError, match="JSON array"):
  43. AppSettingsUpdate(obico_enabled_printers='[1, "two"]')
  44. def test_poll_interval_bounds(self):
  45. with pytest.raises(ValueError):
  46. AppSettingsUpdate(obico_poll_interval=4)
  47. with pytest.raises(ValueError):
  48. AppSettingsUpdate(obico_poll_interval=121)
  49. assert AppSettingsUpdate(obico_poll_interval=10).obico_poll_interval == 10
  50. class TestGetStatus:
  51. def test_empty_initial_status(self):
  52. svc = ObicoDetectionService()
  53. s = svc.get_status()
  54. assert s["is_running"] is False
  55. assert s["per_printer"] == {}
  56. assert s["history"] == []
  57. assert "low" in s["thresholds"] and "high" in s["thresholds"]
  58. def test_thresholds_reflect_configured_sensitivity(self):
  59. """#1469 — get_status() reports the thresholds for the passed
  60. sensitivity, not a hardcoded 'medium'. Each level must be distinct so
  61. the Status panel changes when the user changes the setting."""
  62. svc = ObicoDetectionService()
  63. low = svc.get_status("low")["thresholds"]
  64. medium = svc.get_status("medium")["thresholds"]
  65. high = svc.get_status("high")["thresholds"]
  66. # Higher sensitivity → lower thresholds (easier to trigger).
  67. assert low["low"] > medium["low"] > high["low"]
  68. assert low["high"] > medium["high"] > high["high"]
  69. # Default and unknown values fall back to medium.
  70. assert svc.get_status()["thresholds"] == medium
  71. assert svc.get_status("bogus")["thresholds"] == medium
  72. class TestTestConnection:
  73. @pytest.mark.asyncio
  74. async def test_empty_url_via_route(self):
  75. """Service does not special-case empty URL — the route does."""
  76. svc = ObicoDetectionService()
  77. # This will fail DNS/connect, but should return ok=False
  78. result = await svc.test_connection("http://nonexistent-obico-host-xyz.invalid:3333")
  79. assert result["ok"] is False
  80. assert result["error"] is not None
  81. @pytest.mark.asyncio
  82. async def test_healthy_response_is_ok(self):
  83. svc = ObicoDetectionService()
  84. mock_response = MagicMock(status_code=200, text="ok")
  85. mock_client = MagicMock()
  86. mock_client.get = AsyncMock(return_value=mock_response)
  87. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  88. mock_client.__aexit__ = AsyncMock(return_value=False)
  89. with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
  90. result = await svc.test_connection("http://obico:3333")
  91. assert result["ok"] is True
  92. assert result["status_code"] == 200
  93. assert result["body"] == "ok"
  94. assert result["error"] is None
  95. @pytest.mark.asyncio
  96. async def test_non_ok_body_is_not_ok(self):
  97. svc = ObicoDetectionService()
  98. mock_response = MagicMock(status_code=200, text="something else")
  99. mock_client = MagicMock()
  100. mock_client.get = AsyncMock(return_value=mock_response)
  101. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  102. mock_client.__aexit__ = AsyncMock(return_value=False)
  103. with patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client):
  104. result = await svc.test_connection("http://obico:3333/")
  105. assert result["ok"] is False
  106. assert result["body"] == "something else"
  107. class TestPollOneStateLifecycle:
  108. """Confirms per-printer state is reset when a new print starts."""
  109. @pytest.mark.asyncio
  110. async def test_new_task_name_resets_state(self):
  111. svc = ObicoDetectionService()
  112. # Seed a state that has been running for a while
  113. from backend.app.services.obico_smoothing import PrintState
  114. seeded = PrintState()
  115. for _ in range(WARMUP_FRAMES + 5):
  116. seeded.update(0.5)
  117. svc._states[1] = seeded
  118. svc._state_keys[1] = "old_task"
  119. svc._action_fired[1] = True
  120. settings = {
  121. "enabled": True,
  122. "ml_url": "http://obico:3333",
  123. "sensitivity": "medium",
  124. "action": "notify",
  125. "poll_interval": 10,
  126. "enabled_printers": None,
  127. "external_url": "http://bambuddy:8000",
  128. }
  129. status = MagicMock(state="RUNNING", task_name="new_task", subtask_name="")
  130. mock_response = MagicMock()
  131. mock_response.json.return_value = {"detections": []}
  132. mock_response.raise_for_status = MagicMock()
  133. mock_client = MagicMock()
  134. mock_client.get = AsyncMock(return_value=mock_response)
  135. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  136. mock_client.__aexit__ = AsyncMock(return_value=False)
  137. with (
  138. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  139. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  140. ):
  141. await svc._check_printer(1, status, settings)
  142. # State was reset (frame_count is 1 after the single update, not 36)
  143. assert svc._states[1].frame_count == 1
  144. assert svc._state_keys[1] == "new_task"
  145. assert svc._action_fired[1] is False
  146. @pytest.mark.asyncio
  147. async def test_ml_api_error_does_not_crash(self):
  148. svc = ObicoDetectionService()
  149. settings = {
  150. "enabled": True,
  151. "ml_url": "http://obico:3333",
  152. "sensitivity": "medium",
  153. "action": "notify",
  154. "poll_interval": 10,
  155. "enabled_printers": None,
  156. "external_url": "http://bambuddy:8000",
  157. }
  158. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  159. mock_client = MagicMock()
  160. mock_client.get = AsyncMock(side_effect=RuntimeError("connection refused"))
  161. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  162. mock_client.__aexit__ = AsyncMock(return_value=False)
  163. with (
  164. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  165. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  166. ):
  167. await svc._check_printer(1, status, settings)
  168. assert svc._last_error is not None
  169. assert "connection refused" in svc._last_error
  170. @pytest.mark.asyncio
  171. async def test_ml_api_empty_exception_message_falls_back_to_type(self):
  172. """If str(exc) is empty, log the exception class name instead of a blank suffix."""
  173. svc = ObicoDetectionService()
  174. settings = {
  175. "enabled": True,
  176. "ml_url": "http://obico:3333",
  177. "sensitivity": "medium",
  178. "action": "notify",
  179. "poll_interval": 10,
  180. "enabled_printers": None,
  181. "external_url": "http://bambuddy:8000",
  182. }
  183. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  184. class _SilentError(Exception):
  185. def __str__(self) -> str:
  186. return ""
  187. mock_client = MagicMock()
  188. mock_client.get = AsyncMock(side_effect=_SilentError())
  189. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  190. mock_client.__aexit__ = AsyncMock(return_value=False)
  191. with (
  192. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  193. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  194. ):
  195. await svc._check_printer(1, status, settings)
  196. assert svc._last_error is not None
  197. assert "_SilentError" in svc._last_error
  198. # The suffix is never blank
  199. assert not svc._last_error.rstrip().endswith(":")
  200. @pytest.mark.asyncio
  201. async def test_failure_fires_action_only_once(self):
  202. """Once a failure has fired for a print, subsequent failures should not re-fire."""
  203. svc = ObicoDetectionService()
  204. settings = {
  205. "enabled": True,
  206. "ml_url": "http://obico:3333",
  207. "sensitivity": "medium",
  208. "action": "notify",
  209. "poll_interval": 10,
  210. "enabled_printers": None,
  211. "external_url": "http://bambuddy:8000",
  212. }
  213. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  214. # Seed state so the next frame crosses HIGH immediately
  215. from backend.app.services.obico_smoothing import PrintState
  216. seeded = PrintState()
  217. for _ in range(WARMUP_FRAMES + 500):
  218. seeded.update(1.0)
  219. svc._states[1] = seeded
  220. svc._state_keys[1] = "job"
  221. svc._action_fired[1] = False
  222. mock_response = MagicMock()
  223. mock_response.json.return_value = {"detections": [["failure", 0.9, [0, 0, 1, 1]]]}
  224. mock_response.raise_for_status = MagicMock()
  225. mock_client = MagicMock()
  226. mock_client.get = AsyncMock(return_value=mock_response)
  227. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  228. mock_client.__aexit__ = AsyncMock(return_value=False)
  229. with (
  230. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  231. patch("backend.app.services.obico_actions.execute_action", new=AsyncMock()) as mock_action,
  232. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  233. ):
  234. await svc._check_printer(1, status, settings)
  235. assert mock_action.call_count == 1
  236. await svc._check_printer(1, status, settings)
  237. # Second call must not dispatch again
  238. assert mock_action.call_count == 1
  239. class TestCaptureFrameSharesBroadcasterUpstream:
  240. """#1271: Obico's per-poll snapshot must reuse the live-stream broadcaster's
  241. buffered frame when a viewer is watching, instead of opening a second RTSP
  242. socket. On X2D firmware 01.01.00.00 the second socket kicks the live stream.
  243. """
  244. @pytest.mark.asyncio
  245. async def test_returns_buffered_frame_when_stream_active(self):
  246. printer = MagicMock(
  247. external_camera_enabled=False,
  248. external_camera_url=None,
  249. ip_address="192.168.1.10",
  250. access_code="12345678",
  251. model="N6",
  252. )
  253. mock_session = MagicMock()
  254. mock_session.get = AsyncMock(return_value=printer)
  255. mock_ctx = MagicMock()
  256. mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
  257. mock_ctx.__aexit__ = AsyncMock(return_value=None)
  258. svc = ObicoDetectionService()
  259. with (
  260. patch("backend.app.services.obico_detection.async_session", return_value=mock_ctx),
  261. patch(
  262. "backend.app.api.routes.camera.is_stream_active",
  263. return_value=True,
  264. ),
  265. patch(
  266. "backend.app.api.routes.camera.try_get_active_buffered_frame",
  267. return_value=FAKE_JPEG,
  268. ),
  269. patch(
  270. "backend.app.services.camera.capture_camera_frame_bytes",
  271. new=AsyncMock(return_value=b"FRESH-CAPTURE-SHOULD-NOT-BE-USED"),
  272. ) as mock_fresh,
  273. ):
  274. result = await svc._capture_frame(printer_id=1)
  275. assert result == FAKE_JPEG
  276. mock_fresh.assert_not_called()
  277. @pytest.mark.asyncio
  278. async def test_skips_poll_when_stream_active_but_buffer_empty(self):
  279. """#1348: viewer attached + buffer empty must NOT open a competing socket."""
  280. printer = MagicMock(
  281. external_camera_enabled=False,
  282. external_camera_url=None,
  283. ip_address="192.168.1.10",
  284. access_code="12345678",
  285. model="X1C",
  286. )
  287. mock_session = MagicMock()
  288. mock_session.get = AsyncMock(return_value=printer)
  289. mock_ctx = MagicMock()
  290. mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
  291. mock_ctx.__aexit__ = AsyncMock(return_value=None)
  292. svc = ObicoDetectionService()
  293. with (
  294. patch("backend.app.services.obico_detection.async_session", return_value=mock_ctx),
  295. patch(
  296. "backend.app.api.routes.camera.is_stream_active",
  297. return_value=True,
  298. ),
  299. patch(
  300. "backend.app.api.routes.camera.try_get_active_buffered_frame",
  301. return_value=None, # Stream active, but first frame not buffered yet
  302. ),
  303. patch(
  304. "backend.app.services.camera.capture_camera_frame_bytes",
  305. new=AsyncMock(return_value=b"FRESH-CAPTURE-WOULD-KICK-VIEWER"),
  306. ) as mock_fresh,
  307. ):
  308. result = await svc._capture_frame(printer_id=1)
  309. assert result is None, "must skip this poll cycle, not open a competing socket"
  310. mock_fresh.assert_not_called()
  311. @pytest.mark.asyncio
  312. async def test_falls_back_to_fresh_capture_when_no_stream(self):
  313. printer = MagicMock(
  314. external_camera_enabled=False,
  315. external_camera_url=None,
  316. ip_address="192.168.1.10",
  317. access_code="12345678",
  318. model="N6",
  319. )
  320. mock_session = MagicMock()
  321. mock_session.get = AsyncMock(return_value=printer)
  322. mock_ctx = MagicMock()
  323. mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
  324. mock_ctx.__aexit__ = AsyncMock(return_value=None)
  325. svc = ObicoDetectionService()
  326. with (
  327. patch("backend.app.services.obico_detection.async_session", return_value=mock_ctx),
  328. patch(
  329. "backend.app.api.routes.camera.is_stream_active",
  330. return_value=False,
  331. ),
  332. patch(
  333. "backend.app.services.camera.capture_camera_frame_bytes",
  334. new=AsyncMock(return_value=FAKE_JPEG),
  335. ) as mock_fresh,
  336. ):
  337. result = await svc._capture_frame(printer_id=1)
  338. assert result == FAKE_JPEG
  339. mock_fresh.assert_called_once()
  340. class TestFrameCache:
  341. """One-shot JPEG cache that lets us sidestep Obico's 5s read timeout.
  342. Obico's ML API fetches snapshots via `GET /p/?img=URL` with `timeout=(0.1, 5)`.
  343. Our /camera/snapshot can exceed that on cold calls (RTSP keyframe wait). So the
  344. detection loop captures locally, stashes the JPEG bytes under a nonce, then hands
  345. Obico a URL that returns those bytes instantly. The cache is single-use + TTLed
  346. so a leaked nonce can't be replayed.
  347. """
  348. def setup_method(self):
  349. _frame_cache.clear()
  350. @pytest.mark.asyncio
  351. async def test_stash_and_pop_roundtrip(self):
  352. nonce = await stash_frame(FAKE_JPEG)
  353. assert nonce # non-empty URL-safe token
  354. data = await pop_frame(nonce)
  355. assert data == FAKE_JPEG
  356. @pytest.mark.asyncio
  357. async def test_nonce_is_single_use(self):
  358. nonce = await stash_frame(FAKE_JPEG)
  359. assert await pop_frame(nonce) == FAKE_JPEG
  360. # Second pop returns None — caches replay protection
  361. assert await pop_frame(nonce) is None
  362. @pytest.mark.asyncio
  363. async def test_unknown_nonce_returns_none(self):
  364. assert await pop_frame("not-a-real-nonce") is None
  365. @pytest.mark.asyncio
  366. async def test_stash_produces_unique_nonces(self):
  367. nonces = {await stash_frame(FAKE_JPEG) for _ in range(10)}
  368. assert len(nonces) == 10
  369. @pytest.mark.asyncio
  370. async def test_expired_entries_are_pruned_on_stash(self):
  371. """New entries trigger pruning of TTL-expired ones — prevents unbounded growth."""
  372. # Manually seed an entry with a stale timestamp
  373. import time as time_module
  374. _frame_cache["stale-nonce"] = (FAKE_JPEG, time_module.monotonic() - FRAME_CACHE_TTL - 1)
  375. await stash_frame(FAKE_JPEG)
  376. # Stale entry was pruned
  377. assert "stale-nonce" not in _frame_cache
  378. @pytest.mark.asyncio
  379. async def test_pop_rejects_expired_nonce(self):
  380. """Even if the entry is still in the dict, an expired TTL returns None."""
  381. import time as time_module
  382. _frame_cache["aging-nonce"] = (FAKE_JPEG, time_module.monotonic() - FRAME_CACHE_TTL - 1)
  383. assert await pop_frame("aging-nonce") is None
  384. class TestCheckPrinterUsesCachedFrameUrl:
  385. """The URL sent to Obico must point at our nonce endpoint, not /camera/snapshot."""
  386. def setup_method(self):
  387. _frame_cache.clear()
  388. @pytest.mark.asyncio
  389. async def test_ml_api_called_with_cached_frame_url(self):
  390. svc = ObicoDetectionService()
  391. settings = {
  392. "enabled": True,
  393. "ml_url": "http://obico:3333",
  394. "sensitivity": "medium",
  395. "action": "notify",
  396. "poll_interval": 10,
  397. "enabled_printers": None,
  398. "external_url": "http://bambuddy:8000",
  399. }
  400. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  401. mock_response = MagicMock()
  402. mock_response.json.return_value = {"detections": []}
  403. mock_response.raise_for_status = MagicMock()
  404. mock_client = MagicMock()
  405. mock_client.get = AsyncMock(return_value=mock_response)
  406. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  407. mock_client.__aexit__ = AsyncMock(return_value=False)
  408. with (
  409. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  410. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  411. ):
  412. await svc._check_printer(1, status, settings)
  413. # ML API was called via GET (Obico's /p/ is GET-only)
  414. mock_client.get.assert_called_once()
  415. _args, kwargs = mock_client.get.call_args
  416. assert _args[0] == "http://obico:3333/p/"
  417. img_url = kwargs["params"]["img"]
  418. assert img_url.startswith("http://bambuddy:8000/api/v1/obico/cached-frame/")
  419. # The path segment after /cached-frame/ is the nonce itself — that nonce must
  420. # resolve back to our stashed frame (single-use guarantees freshness).
  421. nonce = img_url.rsplit("/", 1)[-1]
  422. assert await pop_frame(nonce) == FAKE_JPEG
  423. @pytest.mark.asyncio
  424. async def test_capture_failure_skips_ml_call(self):
  425. """If we can't capture a frame, don't bother the ML API."""
  426. svc = ObicoDetectionService()
  427. settings = {
  428. "enabled": True,
  429. "ml_url": "http://obico:3333",
  430. "sensitivity": "medium",
  431. "action": "notify",
  432. "poll_interval": 10,
  433. "enabled_printers": None,
  434. "external_url": "http://bambuddy:8000",
  435. }
  436. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  437. mock_client = MagicMock()
  438. mock_client.get = AsyncMock()
  439. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  440. mock_client.__aexit__ = AsyncMock(return_value=False)
  441. with (
  442. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  443. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=None)),
  444. ):
  445. await svc._check_printer(1, status, settings)
  446. mock_client.get.assert_not_called()
  447. assert svc._last_error is not None
  448. assert "Failed to capture snapshot" in svc._last_error
  449. @pytest.mark.asyncio
  450. async def test_missing_external_url_skips_ml_call(self):
  451. """Without external_url, Obico can't reach our cached-frame endpoint."""
  452. svc = ObicoDetectionService()
  453. settings = {
  454. "enabled": True,
  455. "ml_url": "http://obico:3333",
  456. "sensitivity": "medium",
  457. "action": "notify",
  458. "poll_interval": 10,
  459. "enabled_printers": None,
  460. "external_url": "",
  461. }
  462. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  463. mock_client = MagicMock()
  464. mock_client.get = AsyncMock()
  465. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  466. mock_client.__aexit__ = AsyncMock(return_value=False)
  467. with (
  468. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  469. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  470. ):
  471. await svc._check_printer(1, status, settings)
  472. mock_client.get.assert_not_called()
  473. assert svc._last_error is not None
  474. assert "external_url" in svc._last_error
  475. @pytest.mark.asyncio
  476. async def test_successful_cycle_clears_previous_error(self):
  477. """A cold-start RTSP timeout sets _last_error; the next successful poll must clear it.
  478. Regression for #172: the Status card banner ("Failed to capture snapshot for
  479. printer 1") stuck around after a one-off cold-start failure even though every
  480. subsequent poll captured + detected successfully.
  481. """
  482. svc = ObicoDetectionService()
  483. settings = {
  484. "enabled": True,
  485. "ml_url": "http://obico:3333",
  486. "sensitivity": "medium",
  487. "action": "notify",
  488. "poll_interval": 10,
  489. "enabled_printers": None,
  490. "external_url": "http://bambuddy:8000",
  491. }
  492. status = MagicMock(state="RUNNING", task_name="job", subtask_name="")
  493. # Seed a prior transient error, as would be left by a cold-start capture timeout.
  494. svc._last_error = "Failed to capture snapshot for printer 1"
  495. mock_response = MagicMock()
  496. mock_response.json.return_value = {"detections": []}
  497. mock_response.raise_for_status = MagicMock()
  498. mock_client = MagicMock()
  499. mock_client.get = AsyncMock(return_value=mock_response)
  500. mock_client.__aenter__ = AsyncMock(return_value=mock_client)
  501. mock_client.__aexit__ = AsyncMock(return_value=False)
  502. with (
  503. patch("backend.app.services.obico_detection.httpx.AsyncClient", return_value=mock_client),
  504. patch.object(svc, "_capture_frame", new=AsyncMock(return_value=FAKE_JPEG)),
  505. ):
  506. await svc._check_printer(1, status, settings)
  507. assert svc._last_error is None