test_virtual_printer_api.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386
  1. """Integration tests for Virtual Printer API endpoints.
  2. Tests the full request/response cycle for /api/v1/settings/virtual-printer endpoints.
  3. """
  4. from unittest.mock import AsyncMock, MagicMock, patch
  5. import pytest
  6. from httpx import AsyncClient
  7. class TestVirtualPrinterSettingsAPI:
  8. """Integration tests for /api/v1/settings/virtual-printer endpoints."""
  9. # ========================================================================
  10. # Get settings
  11. # ========================================================================
  12. @pytest.mark.asyncio
  13. @pytest.mark.integration
  14. async def test_get_virtual_printer_settings(self, async_client: AsyncClient):
  15. """Verify virtual printer settings can be retrieved."""
  16. response = await async_client.get("/api/v1/settings/virtual-printer")
  17. assert response.status_code == 200
  18. result = response.json()
  19. assert "enabled" in result
  20. assert "access_code_set" in result
  21. assert "mode" in result
  22. assert "status" in result
  23. @pytest.mark.asyncio
  24. @pytest.mark.integration
  25. async def test_get_settings_has_status(self, async_client: AsyncClient):
  26. """Verify settings include status details."""
  27. response = await async_client.get("/api/v1/settings/virtual-printer")
  28. assert response.status_code == 200
  29. result = response.json()
  30. status = result["status"]
  31. assert "enabled" in status
  32. assert "running" in status
  33. assert "mode" in status
  34. assert "name" in status
  35. assert "serial" in status
  36. assert "pending_files" in status
  37. # ========================================================================
  38. # Update settings
  39. # ========================================================================
  40. @pytest.mark.asyncio
  41. @pytest.mark.integration
  42. async def test_update_mode(self, async_client: AsyncClient):
  43. """Verify mode can be updated."""
  44. response = await async_client.put("/api/v1/settings/virtual-printer?mode=review")
  45. assert response.status_code == 200
  46. result = response.json()
  47. assert result["mode"] == "review"
  48. @pytest.mark.asyncio
  49. @pytest.mark.integration
  50. async def test_update_mode_to_print_queue(self, async_client: AsyncClient):
  51. """Verify mode can be set to print_queue."""
  52. response = await async_client.put("/api/v1/settings/virtual-printer?mode=print_queue")
  53. assert response.status_code == 200
  54. result = response.json()
  55. assert result["mode"] == "print_queue"
  56. @pytest.mark.asyncio
  57. @pytest.mark.integration
  58. async def test_update_mode_legacy_queue_maps_to_review(self, async_client: AsyncClient):
  59. """Verify legacy 'queue' mode is normalized to 'review'."""
  60. response = await async_client.put("/api/v1/settings/virtual-printer?mode=queue")
  61. assert response.status_code == 200
  62. result = response.json()
  63. assert result["mode"] == "review" # Legacy queue maps to review
  64. @pytest.mark.asyncio
  65. @pytest.mark.integration
  66. async def test_update_mode_to_immediate(self, async_client: AsyncClient):
  67. """Verify mode can be set to immediate."""
  68. response = await async_client.put("/api/v1/settings/virtual-printer?mode=immediate")
  69. assert response.status_code == 200
  70. result = response.json()
  71. assert result["mode"] == "immediate"
  72. @pytest.mark.asyncio
  73. @pytest.mark.integration
  74. async def test_update_access_code(self, async_client: AsyncClient):
  75. """Verify access code can be set."""
  76. response = await async_client.put("/api/v1/settings/virtual-printer?access_code=12345678")
  77. assert response.status_code == 200
  78. result = response.json()
  79. assert result["access_code_set"] is True
  80. @pytest.mark.asyncio
  81. @pytest.mark.integration
  82. async def test_update_access_code_wrong_length(self, async_client: AsyncClient):
  83. """Verify access code validation for length."""
  84. response = await async_client.put("/api/v1/settings/virtual-printer?access_code=123")
  85. # Should fail validation
  86. assert response.status_code == 400
  87. @pytest.mark.asyncio
  88. @pytest.mark.integration
  89. async def test_enable_without_access_code(self, async_client: AsyncClient):
  90. """Verify enabling fails without access code set."""
  91. # First ensure no access code is set by checking current state
  92. # Then try to enable
  93. response = await async_client.put("/api/v1/settings/virtual-printer?enabled=true")
  94. # If access code wasn't set, this should fail
  95. # If it was already set, it will succeed
  96. # Both are valid test outcomes
  97. assert response.status_code in [200, 400]
  98. @pytest.mark.asyncio
  99. @pytest.mark.integration
  100. async def test_enable_with_access_code(self, async_client: AsyncClient):
  101. """Verify enabling succeeds when access code is set."""
  102. # First set access code
  103. await async_client.put("/api/v1/settings/virtual-printer?access_code=12345678")
  104. # Then enable (this will start the servers which may fail in test env)
  105. # We mock the manager to avoid actually starting servers
  106. with patch("backend.app.services.virtual_printer.virtual_printer_manager") as mock_manager:
  107. mock_manager.configure = AsyncMock()
  108. mock_manager.get_status = MagicMock(
  109. return_value={
  110. "enabled": True,
  111. "running": True,
  112. "mode": "immediate",
  113. "name": "Bambuddy",
  114. "serial": "00M09A391800001",
  115. "pending_files": 0,
  116. }
  117. )
  118. response = await async_client.put("/api/v1/settings/virtual-printer?enabled=true")
  119. assert response.status_code == 200
  120. @pytest.mark.asyncio
  121. @pytest.mark.integration
  122. async def test_disable_virtual_printer(self, async_client: AsyncClient):
  123. """Verify virtual printer can be disabled."""
  124. with patch("backend.app.services.virtual_printer.virtual_printer_manager") as mock_manager:
  125. mock_manager.configure = AsyncMock()
  126. mock_manager.get_status = MagicMock(
  127. return_value={
  128. "enabled": False,
  129. "running": False,
  130. "mode": "immediate",
  131. "name": "Bambuddy",
  132. "serial": "00M09A391800001",
  133. "pending_files": 0,
  134. }
  135. )
  136. response = await async_client.put("/api/v1/settings/virtual-printer?enabled=false")
  137. assert response.status_code == 200
  138. result = response.json()
  139. assert result["enabled"] is False
  140. class TestPendingUploadsAPI:
  141. """Integration tests for /api/v1/pending-uploads/ endpoints."""
  142. @pytest.fixture
  143. def mock_pending_uploads(self, db_session):
  144. """Create mock pending uploads in database."""
  145. async def _create_pending(filename: str = "test.3mf"):
  146. from datetime import datetime
  147. from backend.app.models.pending_upload import PendingUpload
  148. upload = PendingUpload(
  149. filename=filename,
  150. file_path=f"/tmp/{filename}",
  151. file_size=1024,
  152. source_ip="192.168.1.100",
  153. status="pending",
  154. )
  155. db_session.add(upload)
  156. await db_session.commit()
  157. await db_session.refresh(upload)
  158. return upload
  159. return _create_pending
  160. # ========================================================================
  161. # List pending uploads
  162. # ========================================================================
  163. @pytest.mark.asyncio
  164. @pytest.mark.integration
  165. async def test_list_pending_uploads_empty(self, async_client: AsyncClient):
  166. """Verify empty list is returned when no pending uploads."""
  167. response = await async_client.get("/api/v1/pending-uploads/")
  168. assert response.status_code == 200
  169. result = response.json()
  170. assert isinstance(result, list)
  171. @pytest.mark.asyncio
  172. @pytest.mark.integration
  173. async def test_get_pending_uploads_count(self, async_client: AsyncClient):
  174. """Verify count endpoint returns correct count."""
  175. response = await async_client.get("/api/v1/pending-uploads/count")
  176. assert response.status_code == 200
  177. result = response.json()
  178. assert "count" in result
  179. assert isinstance(result["count"], int)
  180. # ========================================================================
  181. # Archive pending upload
  182. # ========================================================================
  183. @pytest.mark.asyncio
  184. @pytest.mark.integration
  185. async def test_archive_nonexistent_upload(self, async_client: AsyncClient):
  186. """Verify archiving non-existent upload returns 404."""
  187. response = await async_client.post("/api/v1/pending-uploads/99999/archive")
  188. assert response.status_code == 404
  189. # ========================================================================
  190. # Discard pending upload
  191. # ========================================================================
  192. @pytest.mark.asyncio
  193. @pytest.mark.integration
  194. async def test_discard_nonexistent_upload(self, async_client: AsyncClient):
  195. """Verify discarding non-existent upload returns 404."""
  196. response = await async_client.delete("/api/v1/pending-uploads/99999")
  197. assert response.status_code == 404
  198. # ========================================================================
  199. # Bulk operations
  200. # ========================================================================
  201. @pytest.mark.asyncio
  202. @pytest.mark.integration
  203. async def test_archive_all_empty(self, async_client: AsyncClient):
  204. """Verify archive all with no pending uploads."""
  205. response = await async_client.post("/api/v1/pending-uploads/archive-all")
  206. assert response.status_code == 200
  207. result = response.json()
  208. assert "archived" in result
  209. assert "failed" in result
  210. @pytest.mark.asyncio
  211. @pytest.mark.integration
  212. async def test_discard_all_empty(self, async_client: AsyncClient):
  213. """Verify discard all with no pending uploads."""
  214. response = await async_client.delete("/api/v1/pending-uploads/discard-all")
  215. assert response.status_code == 200
  216. result = response.json()
  217. assert "discarded" in result
  218. class TestVirtualPrinterAutoDispatchAPI:
  219. """Integration tests for auto_dispatch on /api/v1/virtual-printers endpoints."""
  220. @pytest.mark.asyncio
  221. @pytest.mark.integration
  222. async def test_create_virtual_printer_auto_dispatch_default(self, async_client: AsyncClient):
  223. """Verify creating a VP without auto_dispatch defaults to true."""
  224. response = await async_client.post(
  225. "/api/v1/virtual-printers",
  226. json={
  227. "name": "TestDefaultDispatch",
  228. "mode": "print_queue",
  229. "access_code": "12345678",
  230. },
  231. )
  232. assert response.status_code == 200
  233. result = response.json()
  234. assert result["auto_dispatch"] is True
  235. @pytest.mark.asyncio
  236. @pytest.mark.integration
  237. async def test_create_virtual_printer_auto_dispatch_false(self, async_client: AsyncClient):
  238. """Verify creating a VP with auto_dispatch=false persists correctly."""
  239. response = await async_client.post(
  240. "/api/v1/virtual-printers",
  241. json={
  242. "name": "TestManualDispatch",
  243. "mode": "print_queue",
  244. "access_code": "12345678",
  245. "auto_dispatch": False,
  246. },
  247. )
  248. assert response.status_code == 200
  249. result = response.json()
  250. assert result["auto_dispatch"] is False
  251. @pytest.mark.asyncio
  252. @pytest.mark.integration
  253. async def test_update_virtual_printer_auto_dispatch(self, async_client: AsyncClient):
  254. """Verify auto_dispatch can be toggled via PUT and persists."""
  255. # Create with auto_dispatch=True (default)
  256. create_resp = await async_client.post(
  257. "/api/v1/virtual-printers",
  258. json={
  259. "name": "TestToggleDispatch",
  260. "mode": "print_queue",
  261. "access_code": "12345678",
  262. },
  263. )
  264. assert create_resp.status_code == 200
  265. vp_id = create_resp.json()["id"]
  266. # Update to auto_dispatch=False
  267. update_resp = await async_client.put(
  268. f"/api/v1/virtual-printers/{vp_id}",
  269. json={"auto_dispatch": False},
  270. )
  271. assert update_resp.status_code == 200
  272. assert update_resp.json()["auto_dispatch"] is False
  273. # Verify it persists by fetching
  274. get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
  275. assert get_resp.status_code == 200
  276. assert get_resp.json()["auto_dispatch"] is False
  277. class TestVirtualPrinterTailscaleToggleAPI:
  278. """The Tailscale toggle is informational — toggling either way always succeeds.
  279. There used to be a 409 guard rejecting "enable" when the daemon was unreachable,
  280. back when the toggle controlled LE cert provisioning. That path was removed:
  281. the slicer's printer-MQTT trust validates against its bundled BBL CA, not the
  282. system trust store, so even an LE cert wouldn't be accepted. The toggle now
  283. only surfaces the host's Tailscale IP/FQDN on the VP card; daemon presence is
  284. irrelevant to whether the toggle can be flipped.
  285. """
  286. @pytest.mark.asyncio
  287. @pytest.mark.integration
  288. async def test_toggle_does_not_consult_tailscale_daemon(self, async_client: AsyncClient):
  289. """PUT tailscale_disabled never calls tailscale_service.get_status — always succeeds."""
  290. create_resp = await async_client.post(
  291. "/api/v1/virtual-printers",
  292. json={
  293. "name": "TestTailscaleToggle",
  294. "mode": "immediate",
  295. "access_code": "12345678",
  296. },
  297. )
  298. assert create_resp.status_code == 200
  299. vp_id = create_resp.json()["id"]
  300. assert create_resp.json()["tailscale_disabled"] is True
  301. with patch(
  302. "backend.app.services.virtual_printer.tailscale.tailscale_service.get_status",
  303. new=AsyncMock(side_effect=AssertionError("get_status must not be called for toggle")),
  304. ):
  305. enable_resp = await async_client.put(
  306. f"/api/v1/virtual-printers/{vp_id}",
  307. json={"tailscale_disabled": False},
  308. )
  309. disable_resp = await async_client.put(
  310. f"/api/v1/virtual-printers/{vp_id}",
  311. json={"tailscale_disabled": True},
  312. )
  313. assert enable_resp.status_code == 200
  314. assert enable_resp.json()["tailscale_disabled"] is False
  315. assert disable_resp.status_code == 200
  316. assert disable_resp.json()["tailscale_disabled"] is True