test_camera_api.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598
  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. # ========================================================================
  119. # Camera Test Endpoint
  120. # ========================================================================
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_camera_test_printer_not_found(self, async_client: AsyncClient):
  124. """Verify 404 when testing camera for non-existent printer."""
  125. response = await async_client.get("/api/v1/printers/99999/camera/test")
  126. assert response.status_code == 404
  127. assert "not found" in response.json()["detail"].lower()
  128. @pytest.mark.asyncio
  129. @pytest.mark.integration
  130. async def test_camera_test_success(self, async_client: AsyncClient, printer_factory):
  131. """Verify camera test returns success when camera is accessible."""
  132. printer = await printer_factory()
  133. with patch("backend.app.api.routes.camera.test_camera_connection", new_callable=AsyncMock) as mock_test:
  134. mock_test.return_value = {"success": True, "message": "Camera connected"}
  135. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/test")
  136. assert response.status_code == 200
  137. result = response.json()
  138. assert result["success"] is True
  139. @pytest.mark.asyncio
  140. @pytest.mark.integration
  141. async def test_camera_test_failure(self, async_client: AsyncClient, printer_factory):
  142. """Verify camera test returns failure when camera is not accessible."""
  143. printer = await printer_factory()
  144. with patch("backend.app.api.routes.camera.test_camera_connection", new_callable=AsyncMock) as mock_test:
  145. mock_test.return_value = {"success": False, "message": "Connection timeout"}
  146. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/test")
  147. assert response.status_code == 200
  148. result = response.json()
  149. assert result["success"] is False
  150. # ========================================================================
  151. # Camera Snapshot Endpoint
  152. # ========================================================================
  153. @pytest.mark.asyncio
  154. @pytest.mark.integration
  155. async def test_camera_snapshot_printer_not_found(self, async_client: AsyncClient):
  156. """Verify 404 when capturing snapshot for non-existent printer."""
  157. response = await async_client.get("/api/v1/printers/99999/camera/snapshot")
  158. assert response.status_code == 404
  159. @pytest.mark.asyncio
  160. @pytest.mark.integration
  161. async def test_camera_snapshot_success(self, async_client: AsyncClient, printer_factory):
  162. """Verify snapshot returns JPEG image when successful."""
  163. printer = await printer_factory()
  164. # Create a fake JPEG (starts with FFD8)
  165. fake_jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
  166. with patch("backend.app.api.routes.camera.capture_camera_frame", new_callable=AsyncMock) as mock_capture:
  167. mock_capture.return_value = True
  168. # Mock the file read
  169. with patch("builtins.open", create=True) as mock_open:
  170. mock_open.return_value.__enter__.return_value.read.return_value = fake_jpeg
  171. with patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.unlink"):
  172. _response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  173. # Note: The actual test might fail due to file operations, but this tests the endpoint structure
  174. # In production tests, we'd mock more comprehensively
  175. @pytest.mark.asyncio
  176. @pytest.mark.integration
  177. async def test_camera_snapshot_failure(self, async_client: AsyncClient, printer_factory):
  178. """Verify 503 when camera capture fails."""
  179. printer = await printer_factory()
  180. with patch("backend.app.api.routes.camera.capture_camera_frame", new_callable=AsyncMock) as mock_capture:
  181. mock_capture.return_value = False
  182. with patch("pathlib.Path.exists", return_value=False), patch("pathlib.Path.unlink"):
  183. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  184. assert response.status_code == 503
  185. assert "Failed to capture" in response.json()["detail"]
  186. @pytest.mark.asyncio
  187. @pytest.mark.integration
  188. async def test_camera_snapshot_reuses_buffered_frame_when_stream_active(
  189. self, async_client: AsyncClient, printer_factory
  190. ):
  191. """#1271: /camera/snapshot must reuse the broadcaster's buffered frame
  192. when a live stream is running, instead of opening a second concurrent
  193. RTSP socket. On printers with strict single-connection enforcement (e.g.
  194. X2D firmware 01.01.00.00) opening a second socket kicks the live stream.
  195. """
  196. printer = await printer_factory()
  197. fake_jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
  198. # Simulate a running broadcaster: one active stream entry + buffered frame.
  199. active_streams = {f"{printer.id}-fanout": MagicMock()}
  200. last_frames = {printer.id: fake_jpeg}
  201. with (
  202. patch("backend.app.api.routes.camera._active_streams", active_streams),
  203. patch("backend.app.api.routes.camera._last_frames", last_frames),
  204. patch("backend.app.api.routes.camera.capture_camera_frame", new_callable=AsyncMock) as mock_capture,
  205. ):
  206. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  207. assert response.status_code == 200
  208. assert response.content == fake_jpeg
  209. # The fresh-capture path must NOT have been taken — that's the whole point.
  210. mock_capture.assert_not_called()
  211. @pytest.mark.asyncio
  212. @pytest.mark.integration
  213. async def test_camera_snapshot_external_camera_success(self, async_client: AsyncClient, printer_factory):
  214. """Verify snapshot uses external camera when configured."""
  215. printer = await printer_factory(
  216. external_camera_enabled=True,
  217. external_camera_url="http://192.168.1.50/mjpeg",
  218. external_camera_type="mjpeg",
  219. )
  220. fake_jpeg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
  221. with patch(
  222. "backend.app.services.external_camera.capture_frame",
  223. new_callable=AsyncMock,
  224. return_value=fake_jpeg,
  225. ):
  226. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  227. assert response.status_code == 200
  228. assert response.headers["content-type"] == "image/jpeg"
  229. assert response.content == fake_jpeg
  230. @pytest.mark.asyncio
  231. @pytest.mark.integration
  232. async def test_camera_snapshot_external_camera_failure(self, async_client: AsyncClient, printer_factory):
  233. """Verify 503 when external camera capture fails."""
  234. printer = await printer_factory(
  235. external_camera_enabled=True,
  236. external_camera_url="http://192.168.1.50/mjpeg",
  237. external_camera_type="mjpeg",
  238. )
  239. with patch(
  240. "backend.app.services.external_camera.capture_frame",
  241. new_callable=AsyncMock,
  242. return_value=None,
  243. ):
  244. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/snapshot")
  245. assert response.status_code == 503
  246. assert "external camera" in response.json()["detail"].lower()
  247. # ========================================================================
  248. # Camera Stream Endpoint
  249. # ========================================================================
  250. @pytest.mark.asyncio
  251. @pytest.mark.integration
  252. async def test_camera_stream_printer_not_found(self, async_client: AsyncClient):
  253. """Verify 404 when streaming camera for non-existent printer."""
  254. response = await async_client.get("/api/v1/printers/99999/camera/stream")
  255. assert response.status_code == 404
  256. @pytest.mark.asyncio
  257. @pytest.mark.integration
  258. async def test_camera_stream_fps_validation(self, async_client: AsyncClient, printer_factory):
  259. """Verify FPS parameter is validated and clamped."""
  260. printer = await printer_factory()
  261. # FPS should be clamped between 1 and 30
  262. # Testing that the endpoint accepts various FPS values without error
  263. # (actual streaming would require mocking ffmpeg)
  264. with patch("backend.app.api.routes.camera.get_ffmpeg_path", return_value=None):
  265. # With no ffmpeg, stream should return error message but not crash
  266. response = await async_client.get(
  267. f"/api/v1/printers/{printer.id}/camera/stream",
  268. params={"fps": 100}, # Should be clamped to 30
  269. )
  270. # Response will be a streaming response with error
  271. assert response.status_code == 200
  272. # ========================================================================
  273. # Plate Detection Endpoints
  274. # ========================================================================
  275. @pytest.mark.asyncio
  276. @pytest.mark.integration
  277. async def test_plate_detection_status_printer_not_found(self, async_client: AsyncClient):
  278. """Verify 404 when checking plate detection status for non-existent printer."""
  279. response = await async_client.get("/api/v1/printers/99999/camera/plate-detection/status")
  280. assert response.status_code == 404
  281. @pytest.mark.asyncio
  282. @pytest.mark.integration
  283. async def test_plate_detection_status_opencv_not_available(self, async_client: AsyncClient, printer_factory):
  284. """Verify plate detection status returns unavailable when OpenCV not installed."""
  285. printer = await printer_factory()
  286. with patch("backend.app.services.plate_detection.OPENCV_AVAILABLE", False):
  287. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/status")
  288. assert response.status_code == 200
  289. result = response.json()
  290. assert result["available"] is False
  291. assert result["calibrated"] is False
  292. @pytest.mark.asyncio
  293. @pytest.mark.integration
  294. async def test_plate_detection_status_success(self, async_client: AsyncClient, printer_factory):
  295. """Verify plate detection status returns correctly when OpenCV available."""
  296. printer = await printer_factory()
  297. # OpenCV is available in test environment, just check the response structure
  298. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/status")
  299. assert response.status_code == 200
  300. result = response.json()
  301. assert "available" in result
  302. assert "calibrated" in result
  303. @pytest.mark.asyncio
  304. @pytest.mark.integration
  305. async def test_check_plate_empty_printer_not_found(self, async_client: AsyncClient):
  306. """Verify 404 when checking plate for non-existent printer."""
  307. response = await async_client.get("/api/v1/printers/99999/camera/check-plate")
  308. assert response.status_code == 404
  309. @pytest.mark.asyncio
  310. @pytest.mark.integration
  311. async def test_check_plate_empty_success_structure(self, async_client: AsyncClient, printer_factory):
  312. """Verify check plate returns proper structure when OpenCV available."""
  313. printer = await printer_factory()
  314. # Mock PlateDetectionResult to avoid camera timeout
  315. mock_result = MagicMock()
  316. mock_result.is_empty = True
  317. mock_result.confidence = 0.95
  318. mock_result.difference_percent = 0.5
  319. mock_result.message = "Plate appears empty"
  320. mock_result.needs_calibration = False
  321. mock_result.debug_image = None
  322. mock_result.to_dict.return_value = {
  323. "is_empty": True,
  324. "confidence": 0.95,
  325. "difference_percent": 0.5,
  326. "message": "Plate appears empty",
  327. "has_debug_image": False,
  328. "needs_calibration": False,
  329. }
  330. # Mock PlateDetector for reference count
  331. mock_detector = MagicMock()
  332. mock_detector.get_calibration_count.return_value = 0
  333. mock_detector.MAX_REFERENCES = 5
  334. with (
  335. patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
  336. patch("backend.app.services.plate_detection.check_plate_empty", new_callable=AsyncMock) as mock_check,
  337. patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
  338. ):
  339. mock_check.return_value = mock_result
  340. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/check-plate")
  341. assert response.status_code == 200
  342. result = response.json()
  343. assert "is_empty" in result
  344. assert "confidence" in result
  345. assert "message" in result
  346. @pytest.mark.asyncio
  347. @pytest.mark.integration
  348. async def test_calibrate_plate_printer_not_found(self, async_client: AsyncClient):
  349. """Verify 404 when calibrating plate for non-existent printer."""
  350. response = await async_client.post("/api/v1/printers/99999/camera/plate-detection/calibrate")
  351. assert response.status_code == 404
  352. @pytest.mark.asyncio
  353. @pytest.mark.integration
  354. async def test_calibrate_plate_success_structure(self, async_client: AsyncClient, printer_factory):
  355. """Verify calibrate endpoint responds with proper structure."""
  356. printer = await printer_factory()
  357. # Mock calibrate_plate at the source module to avoid camera timeout
  358. with (
  359. patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
  360. patch("backend.app.services.plate_detection.calibrate_plate", new_callable=AsyncMock) as mock_calibrate,
  361. ):
  362. mock_calibrate.return_value = (True, "Calibration saved (1/5 references)", 0)
  363. response = await async_client.post(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate")
  364. assert response.status_code == 200
  365. result = response.json()
  366. assert result["success"] is True
  367. assert "index" in result
  368. @pytest.mark.asyncio
  369. @pytest.mark.integration
  370. async def test_delete_calibration_printer_not_found(self, async_client: AsyncClient):
  371. """Verify 404 when deleting calibration for non-existent printer."""
  372. response = await async_client.delete("/api/v1/printers/99999/camera/plate-detection/calibrate")
  373. assert response.status_code == 404
  374. @pytest.mark.asyncio
  375. @pytest.mark.integration
  376. async def test_delete_calibration_success(self, async_client: AsyncClient, printer_factory):
  377. """Verify delete calibration returns proper structure."""
  378. printer = await printer_factory()
  379. with patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True):
  380. response = await async_client.delete(f"/api/v1/printers/{printer.id}/camera/plate-detection/calibrate")
  381. assert response.status_code == 200
  382. result = response.json()
  383. assert "success" in result
  384. assert "message" in result
  385. @pytest.mark.asyncio
  386. @pytest.mark.integration
  387. async def test_get_references_printer_not_found(self, async_client: AsyncClient):
  388. """Verify 404 when getting references for non-existent printer."""
  389. response = await async_client.get("/api/v1/printers/99999/camera/plate-detection/references")
  390. assert response.status_code == 404
  391. @pytest.mark.asyncio
  392. @pytest.mark.integration
  393. async def test_get_references_opencv_not_available(self, async_client: AsyncClient, printer_factory):
  394. """Verify get references returns unavailable when OpenCV not installed."""
  395. printer = await printer_factory()
  396. with patch("backend.app.services.plate_detection.OPENCV_AVAILABLE", False):
  397. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/references")
  398. assert response.status_code == 503
  399. @pytest.mark.asyncio
  400. @pytest.mark.integration
  401. async def test_get_references_success(self, async_client: AsyncClient, printer_factory):
  402. """Verify get references returns proper structure."""
  403. printer = await printer_factory()
  404. # Mock OpenCV availability and PlateDetector
  405. mock_detector = MagicMock()
  406. mock_detector.get_references.return_value = []
  407. mock_detector.MAX_REFERENCES = 5
  408. with (
  409. patch("backend.app.services.plate_detection.is_plate_detection_available", return_value=True),
  410. patch("backend.app.services.plate_detection.PlateDetector", return_value=mock_detector),
  411. ):
  412. response = await async_client.get(f"/api/v1/printers/{printer.id}/camera/plate-detection/references")
  413. assert response.status_code == 200
  414. result = response.json()
  415. assert "references" in result
  416. assert "max_references" in result
  417. assert isinstance(result["references"], list)
  418. @pytest.mark.asyncio
  419. @pytest.mark.integration
  420. async def test_update_reference_label_printer_not_found(self, async_client: AsyncClient):
  421. """Verify 404 when updating reference label for non-existent printer."""
  422. response = await async_client.put(
  423. "/api/v1/printers/99999/camera/plate-detection/references/0", params={"label": "New Label"}
  424. )
  425. assert response.status_code == 404
  426. @pytest.mark.asyncio
  427. @pytest.mark.integration
  428. async def test_delete_reference_printer_not_found(self, async_client: AsyncClient):
  429. """Verify 404 when deleting reference for non-existent printer."""
  430. response = await async_client.delete("/api/v1/printers/99999/camera/plate-detection/references/0")
  431. assert response.status_code == 404
  432. @pytest.mark.asyncio
  433. @pytest.mark.integration
  434. async def test_get_reference_thumbnail_printer_not_found(self, async_client: AsyncClient):
  435. """Verify 404 when getting reference thumbnail for non-existent printer."""
  436. response = await async_client.get("/api/v1/printers/99999/camera/plate-detection/references/0/thumbnail")
  437. assert response.status_code == 404
  438. # ========================================================================
  439. # USB Camera Endpoint
  440. # ========================================================================
  441. @pytest.mark.asyncio
  442. @pytest.mark.integration
  443. async def test_list_usb_cameras_returns_list(self, async_client: AsyncClient):
  444. """Verify USB cameras endpoint returns a list of cameras."""
  445. response = await async_client.get("/api/v1/printers/usb-cameras")
  446. assert response.status_code == 200
  447. result = response.json()
  448. assert "cameras" in result
  449. assert isinstance(result["cameras"], list)
  450. @pytest.mark.asyncio
  451. @pytest.mark.integration
  452. async def test_list_usb_cameras_structure(self, async_client: AsyncClient):
  453. """Verify USB cameras endpoint returns proper structure for each camera."""
  454. with patch("backend.app.services.external_camera.list_usb_cameras") as mock_list:
  455. mock_list.return_value = [
  456. {"device": "/dev/video0", "name": "Logitech Webcam C920", "index": 0},
  457. {"device": "/dev/video2", "name": "USB Camera", "index": 2},
  458. ]
  459. response = await async_client.get("/api/v1/printers/usb-cameras")
  460. assert response.status_code == 200
  461. result = response.json()
  462. assert len(result["cameras"]) == 2
  463. assert result["cameras"][0]["device"] == "/dev/video0"
  464. assert result["cameras"][0]["name"] == "Logitech Webcam C920"
  465. assert result["cameras"][1]["device"] == "/dev/video2"
  466. @pytest.mark.asyncio
  467. @pytest.mark.integration
  468. async def test_list_usb_cameras_empty_on_non_linux(self, async_client: AsyncClient):
  469. """Verify USB cameras endpoint returns empty list on non-Linux systems."""
  470. with patch("backend.app.services.external_camera.list_usb_cameras") as mock_list:
  471. # Simulate non-Linux system (no /dev/video* devices)
  472. mock_list.return_value = []
  473. response = await async_client.get("/api/v1/printers/usb-cameras")
  474. assert response.status_code == 200
  475. result = response.json()
  476. assert result["cameras"] == []