test_camera_api.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813
  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"] == []