test_camera_api.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839
  1. """Integration tests for Camera API endpoints.
  2. Tests the full request/response cycle for /api/v1/printers/{id}/camera/ endpoints.
  3. """
  4. from unittest.mock import AsyncMock, MagicMock, patch
  5. import pytest
  6. from httpx import AsyncClient
  7. class TestCameraAPI:
  8. """Integration tests for /api/v1/printers/{id}/camera/ endpoints."""
  9. # ========================================================================
  10. # Camera Stop Endpoint
  11. # ========================================================================
  12. @pytest.mark.asyncio
  13. @pytest.mark.integration
  14. async def test_stop_camera_stream_get(self, async_client: AsyncClient, printer_factory):
  15. """Verify camera stop endpoint works with GET method."""
  16. printer = await printer_factory()
  17. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/stop")
  18. assert response.status_code == 200
  19. result = response.json()
  20. assert "stopped" in result
  21. assert isinstance(result["stopped"], int)
  22. @pytest.mark.asyncio
  23. @pytest.mark.integration
  24. async def test_stop_camera_stream_post(self, async_client: AsyncClient, printer_factory):
  25. """Verify camera stop endpoint works with POST method (sendBeacon compatibility)."""
  26. printer = await printer_factory()
  27. response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
  28. assert response.status_code == 200
  29. result = response.json()
  30. assert "stopped" in result
  31. assert isinstance(result["stopped"], int)
  32. @pytest.mark.asyncio
  33. @pytest.mark.integration
  34. async def test_stop_camera_stream_no_active_streams(self, async_client: AsyncClient, printer_factory):
  35. """Verify stop returns 0 when no active streams exist."""
  36. printer = await printer_factory()
  37. response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
  38. assert response.status_code == 200
  39. assert response.json()["stopped"] == 0
  40. @pytest.mark.asyncio
  41. @pytest.mark.integration
  42. async def test_stop_camera_stream_with_active_stream(self, async_client: AsyncClient, printer_factory):
  43. """Verify stop terminates active streams for the printer."""
  44. printer = await printer_factory()
  45. # Mock an active stream — wait() must be AsyncMock since it's awaited
  46. mock_process = MagicMock()
  47. mock_process.returncode = None
  48. mock_process.pid = 99999
  49. mock_process.terminate = MagicMock()
  50. mock_process.wait = AsyncMock()
  51. with patch("backend.app.api.routes.camera._active_streams", {f"{printer.id}-abc123": mock_process}):
  52. response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
  53. assert response.status_code == 200
  54. assert response.json()["stopped"] == 1
  55. mock_process.terminate.assert_called_once()
  56. @pytest.mark.asyncio
  57. @pytest.mark.integration
  58. async def test_stop_camera_stream_only_stops_matching_printer(self, async_client: AsyncClient, printer_factory):
  59. """Verify stop only terminates streams for the specified printer."""
  60. printer1 = await printer_factory(name="Printer 1")
  61. printer2 = await printer_factory(name="Printer 2")
  62. # Mock active streams for both printers — wait() must be AsyncMock since it's awaited
  63. mock_process1 = MagicMock()
  64. mock_process1.returncode = None
  65. mock_process1.pid = 99998
  66. mock_process1.terminate = MagicMock()
  67. mock_process1.wait = AsyncMock()
  68. mock_process2 = MagicMock()
  69. mock_process2.returncode = None
  70. mock_process2.pid = 99997
  71. mock_process2.terminate = MagicMock()
  72. mock_process2.wait = AsyncMock()
  73. active_streams = {
  74. f"{printer1.id}-abc123": mock_process1,
  75. f"{printer2.id}-def456": mock_process2,
  76. }
  77. with patch("backend.app.api.routes.camera._active_streams", active_streams):
  78. response = await async_client.post(f"/api/v1/printers/{printer1.id}/camera/stop")
  79. assert response.status_code == 200
  80. assert response.json()["stopped"] == 1
  81. mock_process1.terminate.assert_called_once()
  82. mock_process2.terminate.assert_not_called()
  83. @pytest.mark.asyncio
  84. @pytest.mark.integration
  85. async def test_stop_camera_stream_handles_fanout_stream_id(self, async_client: AsyncClient, printer_factory):
  86. """Stop must terminate streams keyed with the deterministic
  87. ``{printer_id}-fanout`` id used by the fan-out broadcaster (#1089).
  88. Regression guard against the prefix-match drifting away from the
  89. broadcaster's stream-id convention.
  90. """
  91. printer = await printer_factory()
  92. mock_process = MagicMock()
  93. mock_process.returncode = None
  94. mock_process.pid = 99996
  95. mock_process.terminate = MagicMock()
  96. mock_process.wait = AsyncMock()
  97. with patch(
  98. "backend.app.api.routes.camera._active_streams",
  99. {f"{printer.id}-fanout": mock_process},
  100. ):
  101. response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
  102. assert response.status_code == 200
  103. assert response.json()["stopped"] == 1
  104. mock_process.terminate.assert_called_once()
  105. @pytest.mark.asyncio
  106. @pytest.mark.integration
  107. async def test_stop_camera_stream_invokes_broadcaster_shutdown(self, async_client: AsyncClient, printer_factory):
  108. """Stop must call ``shutdown_broadcaster`` so subscribers wake up via
  109. the upstream-gone sentinel rather than stalling on the queue (#1089)."""
  110. printer = await printer_factory()
  111. with patch(
  112. "backend.app.api.routes.camera.shutdown_broadcaster",
  113. AsyncMock(return_value=False),
  114. ) as mock_shutdown:
  115. response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
  116. assert response.status_code == 200
  117. mock_shutdown.assert_awaited_once_with(f"printer-{printer.id}")
  118. @pytest.mark.asyncio
  119. @pytest.mark.integration
  120. async def test_stop_camera_stream_skips_shutdown_when_subscribers_remain(
  121. self, async_client: AsyncClient, printer_factory
  122. ):
  123. """Reference-count guard: when other viewers are still subscribed to the
  124. broadcaster, /camera/stop must NOT force-shutdown — otherwise closing
  125. the embedded viewer kills the cam-wall tile of the same printer.
  126. Natural cleanup tears it down when the last HTTP connection closes.
  127. """
  128. printer = await printer_factory()
  129. mock_shutdown = AsyncMock(return_value=True)
  130. mock_process = MagicMock()
  131. mock_process.returncode = None
  132. mock_process.pid = 88888
  133. mock_process.terminate = MagicMock()
  134. mock_process.wait = AsyncMock()
  135. with (
  136. patch("backend.app.api.routes.camera.get_subscriber_count", return_value=2),
  137. patch("backend.app.api.routes.camera.shutdown_broadcaster", mock_shutdown),
  138. patch("backend.app.api.routes.camera._active_streams", {f"{printer.id}-abc": mock_process}),
  139. ):
  140. response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/stop")
  141. assert response.status_code == 200
  142. result = response.json()
  143. assert result["stopped"] == 0
  144. assert result.get("skipped") is True
  145. mock_shutdown.assert_not_awaited()
  146. mock_process.terminate.assert_not_called()
  147. # ========================================================================
  148. # Camera Test Endpoint
  149. # ========================================================================
  150. @pytest.mark.asyncio
  151. @pytest.mark.integration
  152. async def test_camera_test_printer_not_found(self, async_client: AsyncClient):
  153. """Verify 404 when testing camera for non-existent printer."""
  154. response = await async_client.get("/api/v1/printers/99999/camera/test")
  155. assert response.status_code == 404
  156. assert "not found" in response.json()["detail"].lower()
  157. @pytest.mark.asyncio
  158. @pytest.mark.integration
  159. async def test_camera_test_success(self, async_client: AsyncClient, printer_factory):
  160. """Verify camera test returns success when camera is accessible."""
  161. printer = await printer_factory()
  162. with patch("backend.app.api.routes.camera.test_camera_connection", new_callable=AsyncMock) as mock_test:
  163. mock_test.return_value = {"success": True, "message": "Camera connected"}
  164. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/test")
  165. assert response.status_code == 200
  166. result = response.json()
  167. assert result["success"] is True
  168. @pytest.mark.asyncio
  169. @pytest.mark.integration
  170. async def test_camera_test_failure(self, async_client: AsyncClient, printer_factory):
  171. """Verify camera test returns failure when camera is not accessible."""
  172. printer = await printer_factory()
  173. with patch("backend.app.api.routes.camera.test_camera_connection", new_callable=AsyncMock) as mock_test:
  174. mock_test.return_value = {"success": False, "message": "Connection timeout"}
  175. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/test")
  176. assert response.status_code == 200
  177. result = response.json()
  178. assert result["success"] is False
  179. # ========================================================================
  180. # Camera Diagnose Endpoint (#1395 follow-up)
  181. # ========================================================================
  182. @pytest.mark.asyncio
  183. @pytest.mark.integration
  184. async def test_camera_diagnose_printer_not_found(self, async_client: AsyncClient):
  185. response = await async_client.post("/api/v1/printers/99999/camera/diagnose")
  186. assert response.status_code == 404
  187. @pytest.mark.asyncio
  188. @pytest.mark.integration
  189. async def test_camera_diagnose_returns_structured_result(self, async_client: AsyncClient, printer_factory):
  190. """Endpoint returns the per-stage shape the frontend modal renders."""
  191. from backend.app.services.camera_diagnose import (
  192. CameraDiagnoseResult,
  193. CameraDiagnoseStage,
  194. )
  195. printer = await printer_factory()
  196. fake = CameraDiagnoseResult(
  197. printer_id=printer.id,
  198. protocol="rtsp",
  199. port=322,
  200. profile="P2S",
  201. overall_status="failed",
  202. stages=[
  203. CameraDiagnoseStage(name="tcp_reachable", status="ok", duration_ms=12),
  204. CameraDiagnoseStage(name="first_frame", status="failed", duration_ms=15123, code="no_frame"),
  205. ],
  206. summary_code="no_frame",
  207. )
  208. with patch(
  209. "backend.app.services.camera_diagnose.diagnose_camera",
  210. new_callable=AsyncMock,
  211. return_value=fake,
  212. ):
  213. response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/diagnose")
  214. assert response.status_code == 200
  215. body = response.json()
  216. assert body["printer_id"] == printer.id
  217. assert body["protocol"] == "rtsp"
  218. assert body["profile"] == "P2S"
  219. assert body["overall_status"] == "failed"
  220. assert body["summary_code"] == "no_frame"
  221. assert [s["name"] for s in body["stages"]] == ["tcp_reachable", "first_frame"]
  222. assert body["stages"][1]["code"] == "no_frame"
  223. # ========================================================================
  224. # Camera Snapshot Endpoint
  225. # ========================================================================
  226. @pytest.mark.asyncio
  227. @pytest.mark.integration
  228. async def test_camera_snapshot_printer_not_found(self, async_client: AsyncClient):
  229. """Verify 404 when capturing snapshot for non-existent printer."""
  230. response = await async_client.get("/api/v1/printers/99999/camera/snapshot")
  231. assert response.status_code == 404
  232. @pytest.mark.asyncio
  233. @pytest.mark.integration
  234. async def test_camera_snapshot_success(self, async_client: AsyncClient, printer_factory):
  235. """Verify snapshot returns JPEG image when successful."""
  236. printer = await printer_factory()
  237. # Create a fake JPEG (starts with FFD8)
  238. fake_jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
  239. with patch("backend.app.api.routes.camera.capture_camera_frame", new_callable=AsyncMock) as mock_capture:
  240. mock_capture.return_value = True
  241. # Mock the file read
  242. with patch("builtins.open", create=True) as mock_open:
  243. mock_open.return_value.__enter__.return_value.read.return_value = fake_jpeg
  244. with patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.unlink"):
  245. _response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  246. # Note: The actual test might fail due to file operations, but this tests the endpoint structure
  247. # In production tests, we'd mock more comprehensively
  248. @pytest.mark.asyncio
  249. @pytest.mark.integration
  250. async def test_camera_snapshot_failure(self, async_client: AsyncClient, printer_factory):
  251. """Verify 503 when camera capture fails."""
  252. printer = await printer_factory()
  253. with patch("backend.app.api.routes.camera.capture_camera_frame", new_callable=AsyncMock) as mock_capture:
  254. mock_capture.return_value = False
  255. with patch("pathlib.Path.exists", return_value=False), patch("pathlib.Path.unlink"):
  256. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  257. assert response.status_code == 503
  258. assert "Failed to capture" in response.json()["detail"]
  259. @pytest.mark.asyncio
  260. @pytest.mark.integration
  261. async def test_camera_snapshot_reuses_buffered_frame_when_stream_active(
  262. self, async_client: AsyncClient, printer_factory
  263. ):
  264. """#1271: /camera/snapshot must reuse the broadcaster's buffered frame
  265. when a live stream is running, instead of opening a second concurrent
  266. RTSP socket. On printers with strict single-connection enforcement (e.g.
  267. X2D firmware 01.01.00.00) opening a second socket kicks the live stream.
  268. """
  269. printer = await printer_factory()
  270. fake_jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
  271. # Simulate a running broadcaster: one active stream entry + buffered frame.
  272. active_streams = {f"{printer.id}-fanout": MagicMock()}
  273. last_frames = {printer.id: fake_jpeg}
  274. with (
  275. patch("backend.app.api.routes.camera._active_streams", active_streams),
  276. patch("backend.app.api.routes.camera._last_frames", last_frames),
  277. patch("backend.app.api.routes.camera.capture_camera_frame", new_callable=AsyncMock) as mock_capture,
  278. ):
  279. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  280. assert response.status_code == 200
  281. assert response.content == fake_jpeg
  282. # The fresh-capture path must NOT have been taken — that's the whole point.
  283. mock_capture.assert_not_called()
  284. @pytest.mark.asyncio
  285. @pytest.mark.integration
  286. async def test_camera_snapshot_external_camera_success(self, async_client: AsyncClient, printer_factory):
  287. """Verify snapshot uses external camera when configured."""
  288. printer = await printer_factory(
  289. external_camera_enabled=True,
  290. external_camera_url="http://192.168.1.50/mjpeg",
  291. external_camera_type="mjpeg",
  292. )
  293. fake_jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
  294. with patch(
  295. "backend.app.services.external_camera.capture_frame",
  296. new_callable=AsyncMock,
  297. return_value=fake_jpeg,
  298. ):
  299. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  300. assert response.status_code == 200
  301. assert response.headers["content-type"] == "image/jpeg"
  302. assert response.content == fake_jpeg
  303. @pytest.mark.asyncio
  304. @pytest.mark.integration
  305. async def test_camera_snapshot_external_camera_failure(self, async_client: AsyncClient, printer_factory):
  306. """Verify 503 when external camera capture fails."""
  307. printer = await printer_factory(
  308. external_camera_enabled=True,
  309. external_camera_url="http://192.168.1.50/mjpeg",
  310. external_camera_type="mjpeg",
  311. )
  312. with patch(
  313. "backend.app.services.external_camera.capture_frame",
  314. new_callable=AsyncMock,
  315. return_value=None,
  316. ):
  317. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  318. assert response.status_code == 503
  319. assert "external camera" in response.json()["detail"].lower()
  320. # ========================================================================
  321. # Camera Stream Endpoint
  322. # ========================================================================
  323. @pytest.mark.asyncio
  324. @pytest.mark.integration
  325. async def test_camera_stream_printer_not_found(self, async_client: AsyncClient):
  326. """Verify 404 when streaming camera for non-existent printer."""
  327. response = await async_client.get("/api/v1/printers/99999/camera/stream")
  328. assert response.status_code == 404
  329. @pytest.mark.asyncio
  330. @pytest.mark.integration
  331. async def test_camera_stream_fps_validation(self, async_client: AsyncClient, printer_factory):
  332. """Verify FPS parameter is validated and clamped."""
  333. printer = await printer_factory()
  334. # FPS should be clamped between 1 and 30
  335. # Testing that the endpoint accepts various FPS values without error
  336. # (actual streaming would require mocking ffmpeg)
  337. with patch("backend.app.api.routes.camera.get_ffmpeg_path", return_value=None):
  338. # With no ffmpeg, stream should return error message but not crash
  339. response = await async_client.get(
  340. f"/api/v1/printers/{printer.id}/camera/stream",
  341. params={"fps": 100}, # Should be clamped to 30
  342. )
  343. # Response will be a streaming response with error
  344. assert response.status_code == 200
  345. # ========================================================================
  346. # Plate Detection Endpoints
  347. # ========================================================================
  348. @pytest.mark.asyncio
  349. @pytest.mark.integration
  350. async def test_plate_detection_status_printer_not_found(self, async_client: AsyncClient):
  351. """Verify 404 when checking plate detection status for non-existent printer."""
  352. response = await async_client.get("/api/v1/printers/99999/camera/plate-detection/status")
  353. assert response.status_code == 404
  354. @pytest.mark.asyncio
  355. @pytest.mark.integration
  356. async def test_plate_detection_status_opencv_not_available(self, async_client: AsyncClient, printer_factory):
  357. """Verify plate detection status returns unavailable when OpenCV not installed."""
  358. printer = await printer_factory()
  359. with patch("backend.app.services.plate_detection.OPENCV_AVAILABLE", False):
  360. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/status")
  361. assert response.status_code == 200
  362. result = response.json()
  363. assert result["available"] is False
  364. assert result["calibrated"] is False
  365. @pytest.mark.asyncio
  366. @pytest.mark.integration
  367. async def test_plate_detection_status_success(self, async_client: AsyncClient, printer_factory):
  368. """Verify plate detection status returns correctly when OpenCV available."""
  369. printer = await printer_factory()
  370. # OpenCV is available in test environment, just check the response structure
  371. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/status")
  372. assert response.status_code == 200
  373. result = response.json()
  374. assert "available" in result
  375. assert "calibrated" in result
  376. @pytest.mark.asyncio
  377. @pytest.mark.integration
  378. async def test_check_plate_empty_printer_not_found(self, async_client: AsyncClient):
  379. """Verify 404 when checking plate for non-existent printer."""
  380. response = await async_client.get("/api/v1/printers/99999/camera/check-plate")
  381. assert response.status_code == 404
  382. @pytest.mark.asyncio
  383. @pytest.mark.integration
  384. async def test_check_plate_empty_success_structure(self, async_client: AsyncClient, printer_factory):
  385. """Verify check plate returns proper structure when OpenCV available."""
  386. printer = await printer_factory()
  387. # Mock PlateDetectionResult to avoid camera timeout
  388. mock_result = MagicMock()
  389. mock_result.is_empty = True
  390. mock_result.confidence = 0.95
  391. mock_result.difference_percent = 0.5
  392. mock_result.message = "Plate appears empty"
  393. mock_result.needs_calibration = False
  394. mock_result.debug_image = None
  395. mock_result.to_dict.return_value = {
  396. "is_empty": True,
  397. "confidence": 0.95,
  398. "difference_percent": 0.5,
  399. "message": "Plate appears empty",
  400. "has_debug_image": False,
  401. "needs_calibration": False,
  402. }
  403. # Mock PlateDetector for reference count
  404. mock_detector = MagicMock()
  405. mock_detector.get_calibration_count.return_value = 0
  406. mock_detector.MAX_REFERENCES = 5
  407. with (
  408. patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
  409. patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check,
  410. patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
  411. ):
  412. mock_check.return_value = mock_result
  413. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate")
  414. assert response.status_code == 200
  415. result = response.json()
  416. assert "is_empty" in result
  417. assert "confidence" in result
  418. assert "message" in result
  419. @pytest.mark.asyncio
  420. @pytest.mark.integration
  421. async def test_calibrate_plate_printer_not_found(self, async_client: AsyncClient):
  422. """Verify 404 when calibrating plate for non-existent printer."""
  423. response = await async_client.post("/api/v1/printers/99999/camera/plate-detection/calibrate")
  424. assert response.status_code == 404
  425. @pytest.mark.asyncio
  426. @pytest.mark.integration
  427. async def test_calibrate_plate_success_structure(self, async_client: AsyncClient, printer_factory):
  428. """Verify calibrate endpoint responds with proper structure."""
  429. printer = await printer_factory()
  430. # Mock calibrate_plate at the source module to avoid camera timeout
  431. with (
  432. patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
  433. patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate,
  434. ):
  435. mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0)
  436. response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate")
  437. assert response.status_code == 200
  438. result = response.json()
  439. assert result["success"] is True
  440. assert "index" in result
  441. # ------------------------------------------------------------------
  442. # Regression: #1359 — the manual UI check/calibrate routes must derive
  443. # use_external from the printer's external_camera_enabled setting when
  444. # the caller omits the flag. Otherwise the UI calibrates against the
  445. # built-in camera while the runtime auto-check at print start uses the
  446. # external one, producing a permanent "build plate not empty".
  447. # ------------------------------------------------------------------
  448. @pytest.mark.asyncio
  449. @pytest.mark.integration
  450. async def test_check_plate_defaults_use_external_when_external_camera_enabled(
  451. self, async_client: AsyncClient, printer_factory
  452. ):
  453. """Omitting use_external on a printer with external camera enabled
  454. must call the service with use_external=True."""
  455. printer = await printer_factory(
  456. external_camera_enabled=True,
  457. external_camera_url="http://192.168.1.50/mjpeg",
  458. external_camera_type="mjpeg",
  459. )
  460. mock_result = MagicMock()
  461. mock_result.to_dict.return_value = {
  462. "is_empty": True,
  463. "confidence": 0.95,
  464. "difference_percent": 0.5,
  465. "message": "Plate appears empty",
  466. "has_debug_image": False,
  467. "needs_calibration": False,
  468. }
  469. mock_result.debug_image = None
  470. mock_detector = MagicMock()
  471. mock_detector.get_calibration_count.return_value = 0
  472. mock_detector.MAX_REFERENCES = 5
  473. with (
  474. patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
  475. patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check,
  476. patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
  477. ):
  478. mock_check.return_value = mock_result
  479. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate")
  480. assert response.status_code == 200
  481. assert mock_check.await_args.kwargs["use_external"] is True
  482. @pytest.mark.asyncio
  483. @pytest.mark.integration
  484. async def test_check_plate_defaults_use_external_false_when_external_camera_disabled(
  485. self, async_client: AsyncClient, printer_factory
  486. ):
  487. """Omitting use_external on a printer without an external camera
  488. must call the service with use_external=False (built-in)."""
  489. printer = await printer_factory() # external_camera_enabled defaults to False
  490. mock_result = MagicMock()
  491. mock_result.to_dict.return_value = {
  492. "is_empty": True,
  493. "confidence": 0.95,
  494. "difference_percent": 0.5,
  495. "message": "Plate appears empty",
  496. "has_debug_image": False,
  497. "needs_calibration": False,
  498. }
  499. mock_result.debug_image = None
  500. mock_detector = MagicMock()
  501. mock_detector.get_calibration_count.return_value = 0
  502. mock_detector.MAX_REFERENCES = 5
  503. with (
  504. patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
  505. patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check,
  506. patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
  507. ):
  508. mock_check.return_value = mock_result
  509. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate")
  510. assert response.status_code == 200
  511. assert mock_check.await_args.kwargs["use_external"] is False
  512. @pytest.mark.asyncio
  513. @pytest.mark.integration
  514. async def test_calibrate_plate_defaults_use_external_when_external_camera_enabled(
  515. self, async_client: AsyncClient, printer_factory
  516. ):
  517. """Calibrating with use_external omitted on an external-camera-enabled
  518. printer captures the reference from the external camera — matching
  519. what the runtime check at print start will compare against (#1359)."""
  520. printer = await printer_factory(
  521. external_camera_enabled=True,
  522. external_camera_url="http://192.168.1.50/mjpeg",
  523. external_camera_type="mjpeg",
  524. )
  525. with (
  526. patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
  527. patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate,
  528. ):
  529. mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0)
  530. response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate")
  531. assert response.status_code == 200
  532. assert mock_calibrate.await_args.kwargs["use_external"] is True
  533. @pytest.mark.asyncio
  534. @pytest.mark.integration
  535. async def test_calibrate_plate_explicit_use_external_false_overrides_default(
  536. self, async_client: AsyncClient, printer_factory
  537. ):
  538. """An explicit use_external=false from the caller still wins even
  539. when the printer has an external camera configured, so power users
  540. can force a built-in-camera reference if they ever need to."""
  541. printer = await printer_factory(
  542. external_camera_enabled=True,
  543. external_camera_url="http://192.168.1.50/mjpeg",
  544. external_camera_type="mjpeg",
  545. )
  546. with (
  547. patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
  548. patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate,
  549. ):
  550. mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0)
  551. response = await async_client.post(
  552. f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate?use_external=false"
  553. )
  554. assert response.status_code == 200
  555. assert mock_calibrate.await_args.kwargs["use_external"] is False
  556. @pytest.mark.asyncio
  557. @pytest.mark.integration
  558. async def test_delete_calibration_printer_not_found(self, async_client: AsyncClient):
  559. """Verify 404 when deleting calibration for non-existent printer."""
  560. response = await async_client.delete("/api/v1/printers/99999/camera/plate-detection/calibrate")
  561. assert response.status_code == 404
  562. @pytest.mark.asyncio
  563. @pytest.mark.integration
  564. async def test_delete_calibration_success(self, async_client: AsyncClient, printer_factory):
  565. """Verify delete calibration returns proper structure."""
  566. printer = await printer_factory()
  567. with patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True):
  568. response = await async_client.delete(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate")
  569. assert response.status_code == 200
  570. result = response.json()
  571. assert "success" in result
  572. assert "message" in result
  573. @pytest.mark.asyncio
  574. @pytest.mark.integration
  575. async def test_get_references_printer_not_found(self, async_client: AsyncClient):
  576. """Verify 404 when getting references for non-existent printer."""
  577. response = await async_client.get("/api/v1/printers/99999/camera/plate-detection/references")
  578. assert response.status_code == 404
  579. @pytest.mark.asyncio
  580. @pytest.mark.integration
  581. async def test_get_references_opencv_not_available(self, async_client: AsyncClient, printer_factory):
  582. """Verify get references returns unavailable when OpenCV not installed."""
  583. printer = await printer_factory()
  584. with patch("backend.app.services.plate_detection.OPENCV_AVAILABLE", False):
  585. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/references")
  586. assert response.status_code == 503
  587. @pytest.mark.asyncio
  588. @pytest.mark.integration
  589. async def test_get_references_success(self, async_client: AsyncClient, printer_factory):
  590. """Verify get references returns proper structure."""
  591. printer = await printer_factory()
  592. # Mock OpenCV availability and PlateDetector
  593. mock_detector = MagicMock()
  594. mock_detector.get_references.return_value = []
  595. mock_detector.MAX_REFERENCES = 5
  596. with (
  597. patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
  598. patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
  599. ):
  600. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/references")
  601. assert response.status_code == 200
  602. result = response.json()
  603. assert "references" in result
  604. assert "max_references" in result
  605. assert isinstance(result["references"], list)
  606. @pytest.mark.asyncio
  607. @pytest.mark.integration
  608. async def test_update_reference_label_printer_not_found(self, async_client: AsyncClient):
  609. """Verify 404 when updating reference label for non-existent printer."""
  610. response = await async_client.put(
  611. "/api/v1/printers/99999/camera/plate-detection/references/0", params={"label": "New Label"}
  612. )
  613. assert response.status_code == 404
  614. @pytest.mark.asyncio
  615. @pytest.mark.integration
  616. async def test_delete_reference_printer_not_found(self, async_client: AsyncClient):
  617. """Verify 404 when deleting reference for non-existent printer."""
  618. response = await async_client.delete("/api/v1/printers/99999/camera/plate-detection/references/0")
  619. assert response.status_code == 404
  620. @pytest.mark.asyncio
  621. @pytest.mark.integration
  622. async def test_get_reference_thumbnail_printer_not_found(self, async_client: AsyncClient):
  623. """Verify 404 when getting reference thumbnail for non-existent printer."""
  624. response = await async_client.get("/api/v1/printers/99999/camera/plate-detection/references/0/thumbnail")
  625. assert response.status_code == 404
  626. # ========================================================================
  627. # USB Camera Endpoint
  628. # ========================================================================
  629. @pytest.mark.asyncio
  630. @pytest.mark.integration
  631. async def test_list_usb_cameras_returns_list(self, async_client: AsyncClient):
  632. """Verify USB cameras endpoint returns a list of cameras."""
  633. response = await async_client.get("/api/v1/printers/usb-cameras")
  634. assert response.status_code == 200
  635. result = response.json()
  636. assert "cameras" in result
  637. assert isinstance(result["cameras"], list)
  638. @pytest.mark.asyncio
  639. @pytest.mark.integration
  640. async def test_list_usb_cameras_structure(self, async_client: AsyncClient):
  641. """Verify USB cameras endpoint returns proper structure for each camera."""
  642. with patch("backend.app.services.external_camera.list_usb_cameras") as mock_list:
  643. mock_list.return_value = [
  644. {"device": "/dev/video0", "name": "Logitech Webcam C920", "index": 0},
  645. {"device": "/dev/video2", "name": "USB Camera", "index": 2},
  646. ]
  647. response = await async_client.get("/api/v1/printers/usb-cameras")
  648. assert response.status_code == 200
  649. result = response.json()
  650. assert len(result["cameras"]) == 2
  651. assert result["cameras"][0]["device"] == "/dev/video0"
  652. assert result["cameras"][0]["name"] == "Logitech Webcam C920"
  653. assert result["cameras"][1]["device"] == "/dev/video2"
  654. @pytest.mark.asyncio
  655. @pytest.mark.integration
  656. async def test_list_usb_cameras_empty_on_non_linux(self, async_client: AsyncClient):
  657. """Verify USB cameras endpoint returns empty list on non-Linux systems."""
  658. with patch("backend.app.services.external_camera.list_usb_cameras") as mock_list:
  659. # Simulate non-Linux system (no /dev/video* devices)
  660. mock_list.return_value = []
  661. response = await async_client.get("/api/v1/printers/usb-cameras")
  662. assert response.status_code == 200
  663. result = response.json()
  664. assert result["cameras"] == []
  665. class TestCameraStreamPoolHygiene:
  666. """Regression guard for the camera-stream DB-connection leak (issue #2572)."""
  667. def test_camera_stream_does_not_hold_a_get_db_session(self):
  668. """The MJPEG stream endpoint must NOT take a ``Depends(get_db)`` session.
  669. ``get_db`` is a ``yield`` dependency, so its session stays open until the
  670. response body is fully consumed — for a live MJPEG stream that is the
  671. whole time the browser tab is open (hours), pinning one pooled DB
  672. connection per open camera tab per printer. The endpoint fetches the
  673. printer in a short-lived ``async with async_session()`` instead and
  674. releases the connection before streaming. If someone re-adds a
  675. ``Depends(get_db)`` param, this fails.
  676. """
  677. import inspect
  678. from backend.app.api.routes.camera import camera_stream, get_db
  679. for name, param in inspect.signature(camera_stream).parameters.items():
  680. dependency = getattr(param.default, "dependency", None)
  681. assert dependency is not get_db, (
  682. f"camera_stream re-introduced a get_db-held session via parameter {name!r} — "
  683. "it would stay open for the entire stream (issue #2572)"
  684. )