test_virtual_printer_api.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837
  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 set(result) == {"tailscale_disabled", "archive_name_source", "status"}
  20. @pytest.mark.asyncio
  21. @pytest.mark.integration
  22. async def test_get_settings_has_status(self, async_client: AsyncClient):
  23. """Verify settings include status details."""
  24. response = await async_client.get("/api/v1/settings/virtual-printer")
  25. assert response.status_code == 200
  26. result = response.json()
  27. status = result["status"]
  28. assert "enabled" in status
  29. assert "running" in status
  30. assert "mode" in status
  31. assert "name" in status
  32. assert "serial" in status
  33. assert "pending_files" in status
  34. # ========================================================================
  35. # Update settings
  36. # ========================================================================
  37. @pytest.mark.asyncio
  38. @pytest.mark.integration
  39. @pytest.mark.parametrize(
  40. "query",
  41. [
  42. "enabled=true",
  43. "enabled=false",
  44. "access_code=12345678",
  45. "mode=review",
  46. "mode=print_queue",
  47. "model=BL-P001",
  48. "target_printer_id=1",
  49. "remote_interface_ip=10.0.0.5",
  50. ],
  51. )
  52. async def test_settings_the_virtual_printers_never_read_are_refused(self, async_client: AsyncClient, query):
  53. """vp-3: these were saved to settings keys nothing reads any more -- each
  54. virtual printer keeps its own -- and answered 200 as if applied."""
  55. response = await async_client.put(f"/api/v1/settings/virtual-printer?{query}")
  56. assert response.status_code == 400
  57. assert "/api/v1/virtual-printers/" in response.json()["detail"]
  58. @pytest.mark.asyncio
  59. @pytest.mark.integration
  60. async def test_stale_legacy_settings_do_not_block_the_archive_name_source(
  61. self, async_client: AsyncClient, db_session
  62. ):
  63. """An old single-VP install left mode "proxy", enabled, aimed at a
  64. printer since deleted: the route checked those before saving, and
  65. refused the one setting it still serves."""
  66. from backend.app.models.settings import Settings
  67. db_session.add_all(
  68. [
  69. Settings(key="virtual_printer_mode", value="proxy"),
  70. Settings(key="virtual_printer_enabled", value="true"),
  71. Settings(key="virtual_printer_target_printer_id", value="999"),
  72. ]
  73. )
  74. await db_session.commit()
  75. response = await async_client.put("/api/v1/settings/virtual-printer?archive_name_source=filename")
  76. assert response.status_code == 200, response.text
  77. @pytest.mark.asyncio
  78. @pytest.mark.integration
  79. async def test_the_archive_name_source_is_still_saved(self, async_client: AsyncClient):
  80. response = await async_client.put("/api/v1/settings/virtual-printer?archive_name_source=filename")
  81. assert response.status_code == 200
  82. assert response.json()["archive_name_source"] == "filename"
  83. @pytest.mark.asyncio
  84. @pytest.mark.integration
  85. async def test_the_tailscale_switch_reaches_the_first_virtual_printer(self, async_client: AsyncClient, db_session):
  86. """It only changes what that printer's card shows; nothing is restarted."""
  87. from sqlalchemy import select
  88. from backend.app.models.virtual_printer import VirtualPrinter
  89. with patch("backend.app.services.virtual_printer.virtual_printer_manager.sync_from_db", AsyncMock()) as sync:
  90. created = await async_client.post("/api/v1/virtual-printers", json={"name": "first", "mode": "archive"})
  91. assert created.status_code in (200, 201), created.text
  92. sync.reset_mock()
  93. response = await async_client.put("/api/v1/settings/virtual-printer?tailscale_disabled=false")
  94. assert response.status_code == 200, response.text
  95. assert response.json()["tailscale_disabled"] is False
  96. db_session.expire_all()
  97. first = (await db_session.execute(select(VirtualPrinter))).scalar_one()
  98. assert first.tailscale_disabled is False
  99. sync.assert_not_called()
  100. class TestPendingUploadsAPI:
  101. """Integration tests for /api/v1/pending-uploads/ endpoints."""
  102. @pytest.fixture
  103. def mock_pending_uploads(self, db_session):
  104. """Create mock pending uploads in database."""
  105. async def _create_pending(filename: str = "test.3mf"):
  106. from datetime import datetime
  107. from backend.app.models.pending_upload import PendingUpload
  108. upload = PendingUpload(
  109. filename=filename,
  110. file_path=f"/tmp/{filename}", # nosec B108 -- a stored path, never opened
  111. file_size=1024,
  112. source_ip="192.168.1.100",
  113. status="pending",
  114. )
  115. db_session.add(upload)
  116. await db_session.commit()
  117. await db_session.refresh(upload)
  118. return upload
  119. return _create_pending
  120. # ========================================================================
  121. # List pending uploads
  122. # ========================================================================
  123. @pytest.mark.asyncio
  124. @pytest.mark.integration
  125. async def test_list_pending_uploads_empty(self, async_client: AsyncClient):
  126. """Verify empty list is returned when no pending uploads."""
  127. response = await async_client.get("/api/v1/pending-uploads/")
  128. assert response.status_code == 200
  129. result = response.json()
  130. assert isinstance(result, list)
  131. @pytest.mark.asyncio
  132. @pytest.mark.integration
  133. async def test_get_pending_uploads_count(self, async_client: AsyncClient):
  134. """Verify count endpoint returns correct count."""
  135. response = await async_client.get("/api/v1/pending-uploads/count")
  136. assert response.status_code == 200
  137. result = response.json()
  138. assert "count" in result
  139. assert isinstance(result["count"], int)
  140. # ========================================================================
  141. # Archive pending upload
  142. # ========================================================================
  143. @pytest.mark.asyncio
  144. @pytest.mark.integration
  145. async def test_archive_nonexistent_upload(self, async_client: AsyncClient):
  146. """Verify archiving non-existent upload returns 404."""
  147. response = await async_client.post("/api/v1/pending-uploads/99999/archive")
  148. assert response.status_code == 404
  149. # ========================================================================
  150. # Discard pending upload
  151. # ========================================================================
  152. @pytest.mark.asyncio
  153. @pytest.mark.integration
  154. async def test_discard_nonexistent_upload(self, async_client: AsyncClient):
  155. """Verify discarding non-existent upload returns 404."""
  156. response = await async_client.delete("/api/v1/pending-uploads/99999")
  157. assert response.status_code == 404
  158. # ========================================================================
  159. # Bulk operations
  160. # ========================================================================
  161. @pytest.mark.asyncio
  162. @pytest.mark.integration
  163. async def test_archive_all_empty(self, async_client: AsyncClient):
  164. """Verify archive all with no pending uploads."""
  165. response = await async_client.post("/api/v1/pending-uploads/archive-all")
  166. assert response.status_code == 200
  167. result = response.json()
  168. assert "stored" in result
  169. assert "failed" in result
  170. @pytest.mark.asyncio
  171. @pytest.mark.integration
  172. async def test_discard_all_empty(self, async_client: AsyncClient):
  173. """Verify discard all with no pending uploads."""
  174. response = await async_client.delete("/api/v1/pending-uploads/discard-all")
  175. assert response.status_code == 200
  176. result = response.json()
  177. assert "discarded" in result
  178. class TestVirtualPrinterAutoDispatchAPI:
  179. """Integration tests for auto_dispatch on /api/v1/virtual-printers endpoints."""
  180. @pytest.mark.asyncio
  181. @pytest.mark.integration
  182. async def test_create_virtual_printer_auto_dispatch_default(self, async_client: AsyncClient):
  183. """Verify creating a VP without auto_dispatch defaults to true."""
  184. response = await async_client.post(
  185. "/api/v1/virtual-printers",
  186. json={
  187. "name": "TestDefaultDispatch",
  188. "mode": "queue",
  189. "access_code": "12345678",
  190. },
  191. )
  192. assert response.status_code == 200
  193. result = response.json()
  194. assert result["auto_dispatch"] is True
  195. @pytest.mark.asyncio
  196. @pytest.mark.integration
  197. async def test_create_virtual_printer_auto_dispatch_false(self, async_client: AsyncClient):
  198. """Verify creating a VP with auto_dispatch=false persists correctly."""
  199. response = await async_client.post(
  200. "/api/v1/virtual-printers",
  201. json={
  202. "name": "TestManualDispatch",
  203. "mode": "queue",
  204. "access_code": "12345678",
  205. "auto_dispatch": False,
  206. },
  207. )
  208. assert response.status_code == 200
  209. result = response.json()
  210. assert result["auto_dispatch"] is False
  211. @pytest.mark.asyncio
  212. @pytest.mark.integration
  213. async def test_update_virtual_printer_auto_dispatch(self, async_client: AsyncClient):
  214. """Verify auto_dispatch can be toggled via PUT and persists."""
  215. # Create with auto_dispatch=True (default)
  216. create_resp = await async_client.post(
  217. "/api/v1/virtual-printers",
  218. json={
  219. "name": "TestToggleDispatch",
  220. "mode": "queue",
  221. "access_code": "12345678",
  222. },
  223. )
  224. assert create_resp.status_code == 200
  225. vp_id = create_resp.json()["id"]
  226. # Update to auto_dispatch=False
  227. update_resp = await async_client.put(
  228. f"/api/v1/virtual-printers/{vp_id}",
  229. json={"auto_dispatch": False},
  230. )
  231. assert update_resp.status_code == 200
  232. assert update_resp.json()["auto_dispatch"] is False
  233. # Verify it persists by fetching
  234. get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
  235. assert get_resp.status_code == 200
  236. assert get_resp.json()["auto_dispatch"] is False
  237. class TestVirtualPrinterGcodeInjectionAPI:
  238. """Integration tests for gcode_injection (#1516) on /api/v1/virtual-printers endpoints."""
  239. @pytest.mark.asyncio
  240. @pytest.mark.integration
  241. async def test_create_virtual_printer_gcode_injection_default_off(self, async_client: AsyncClient):
  242. """Verify creating a VP without gcode_injection defaults to false (opt-in)."""
  243. response = await async_client.post(
  244. "/api/v1/virtual-printers",
  245. json={
  246. "name": "TestDefaultInjection",
  247. "mode": "queue",
  248. "access_code": "12345678",
  249. },
  250. )
  251. assert response.status_code == 200
  252. assert response.json()["gcode_injection"] is False
  253. @pytest.mark.asyncio
  254. @pytest.mark.integration
  255. async def test_update_virtual_printer_gcode_injection(self, async_client: AsyncClient):
  256. """Verify gcode_injection can be toggled via PUT and persists."""
  257. create_resp = await async_client.post(
  258. "/api/v1/virtual-printers",
  259. json={
  260. "name": "TestToggleInjection",
  261. "mode": "queue",
  262. "access_code": "12345678",
  263. },
  264. )
  265. assert create_resp.status_code == 200
  266. vp_id = create_resp.json()["id"]
  267. assert create_resp.json()["gcode_injection"] is False
  268. update_resp = await async_client.put(
  269. f"/api/v1/virtual-printers/{vp_id}",
  270. json={"gcode_injection": True},
  271. )
  272. assert update_resp.status_code == 200
  273. assert update_resp.json()["gcode_injection"] is True
  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()["gcode_injection"] is True
  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": "archive",
  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
  317. class TestVirtualPrinterCaCertificateAPI:
  318. """Integration tests for GET /api/v1/virtual-printers/ca-certificate."""
  319. @pytest.mark.asyncio
  320. @pytest.mark.integration
  321. async def test_get_ca_certificate_returns_pem(self, async_client: AsyncClient):
  322. """The shared CA certificate is returned as PEM with identifying metadata."""
  323. response = await async_client.get("/api/v1/virtual-printers/ca-certificate")
  324. assert response.status_code == 200
  325. result = response.json()
  326. assert result["pem"].startswith("-----BEGIN CERTIFICATE-----")
  327. assert "PRIVATE KEY" not in result["pem"] # never expose the CA key
  328. assert len(result["fingerprint_sha256"].split(":")) == 32
  329. assert result["not_valid_after"]
  330. @pytest.mark.asyncio
  331. @pytest.mark.integration
  332. async def test_ca_certificate_route_precedes_vp_id_route(self, async_client: AsyncClient):
  333. """'ca-certificate' must not be swallowed by the /{vp_id} int route."""
  334. response = await async_client.get("/api/v1/virtual-printers/ca-certificate")
  335. # A 200 (not 422 from int-parsing "ca-certificate") proves route ordering.
  336. assert response.status_code == 200
  337. @pytest.mark.asyncio
  338. @pytest.mark.integration
  339. @pytest.mark.parametrize(("platform", "filename"), [("windows", ".cmd"), ("unix", ".sh")])
  340. async def test_installer_carries_this_instance_ca(self, async_client: AsyncClient, platform, filename):
  341. ca = (await async_client.get("/api/v1/virtual-printers/ca-certificate")).json()
  342. response = await async_client.get(f"/api/v1/virtual-printers/ca-certificate/installer/{platform}")
  343. assert response.status_code == 200
  344. result = response.json()
  345. assert result["filename"].endswith(filename)
  346. body = ca["pem"].strip().splitlines()[1]
  347. assert body in result["content"]
  348. assert ca["fingerprint_sha256"] in result["content"]
  349. assert "__BAMBUDDY_" not in result["content"]
  350. assert "PRIVATE KEY" not in result["content"]
  351. @pytest.mark.asyncio
  352. @pytest.mark.integration
  353. async def test_installer_for_an_unknown_platform_is_404(self, async_client: AsyncClient):
  354. response = await async_client.get("/api/v1/virtual-printers/ca-certificate/installer/amiga")
  355. assert response.status_code == 404
  356. class TestVirtualPrinterDiagnosticAPI:
  357. """Integration tests for GET /api/v1/virtual-printers/{vp_id}/diagnostic."""
  358. @pytest.mark.asyncio
  359. @pytest.mark.integration
  360. async def test_diagnose_unknown_vp_returns_404(self, async_client: AsyncClient):
  361. response = await async_client.get("/api/v1/virtual-printers/999999/diagnostic")
  362. assert response.status_code == 404
  363. @pytest.mark.asyncio
  364. @pytest.mark.integration
  365. async def test_diagnose_disabled_vp_reports_problems(self, async_client: AsyncClient):
  366. """A freshly created (disabled) VP fails the 'enabled' check."""
  367. create_resp = await async_client.post(
  368. "/api/v1/virtual-printers",
  369. json={"name": "TestDiagVP", "mode": "archive", "access_code": "12345678"},
  370. )
  371. assert create_resp.status_code == 200
  372. vp_id = create_resp.json()["id"]
  373. response = await async_client.get(f"/api/v1/virtual-printers/{vp_id}/diagnostic")
  374. assert response.status_code == 200
  375. result = response.json()
  376. assert result["vp_id"] == vp_id
  377. assert result["overall"] == "problems"
  378. by_id = {c["id"]: c["status"] for c in result["checks"]}
  379. assert by_id["enabled"] == "fail"
  380. assert by_id["running"] == "skip"
  381. class TestVirtualPrinterAccessCodeInheritance:
  382. """Non-proxy VPs with a target printer must inherit the target's access
  383. code at write time.
  384. The live-mirror bridge forwards the slicer's MQTT/RTSPS auth bytes to
  385. the real printer — if the codes diverge the slicer binds the VP but the
  386. bridge fails at the second hop. The route layer force-derives the code
  387. on every create / update so a non-UI client can't introduce a divergence
  388. either.
  389. """
  390. @pytest.mark.asyncio
  391. @pytest.mark.integration
  392. async def test_create_with_target_ignores_submitted_access_code(
  393. self, async_client: AsyncClient, printer_factory, db_session
  394. ):
  395. from sqlalchemy import select
  396. from backend.app.models.virtual_printer import VirtualPrinter
  397. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  398. response = await async_client.post(
  399. "/api/v1/virtual-printers",
  400. json={
  401. "name": "QueueVP",
  402. "mode": "queue",
  403. "access_code": "WRONGAAA",
  404. "target_printer_id": target.id,
  405. },
  406. )
  407. assert response.status_code == 200
  408. vp_id = response.json()["id"]
  409. assert response.json()["access_code_set"] is True
  410. vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
  411. assert vp.access_code == "REALCODE"
  412. @pytest.mark.asyncio
  413. @pytest.mark.integration
  414. async def test_create_with_target_and_no_access_code_still_enables(
  415. self, async_client: AsyncClient, printer_factory
  416. ):
  417. """A non-proxy VP with a target set can be enabled without supplying
  418. access_code separately — the inheritance makes the explicit field
  419. redundant, and the validator now knows this."""
  420. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  421. response = await async_client.post(
  422. "/api/v1/virtual-printers",
  423. json={
  424. "name": "QueueVP",
  425. "mode": "queue",
  426. "target_printer_id": target.id,
  427. "bind_ip": "192.168.1.50",
  428. "enabled": True,
  429. },
  430. )
  431. assert response.status_code == 200
  432. assert response.json()["access_code_set"] is True
  433. @pytest.mark.asyncio
  434. @pytest.mark.integration
  435. async def test_create_without_target_still_requires_access_code_on_enable(self, async_client: AsyncClient):
  436. """The relaxation only kicks in when a target is set. A standalone
  437. non-proxy VP still needs its own access code."""
  438. response = await async_client.post(
  439. "/api/v1/virtual-printers",
  440. json={
  441. "name": "StandaloneVP",
  442. "mode": "archive",
  443. "bind_ip": "192.168.1.51",
  444. "enabled": True,
  445. },
  446. )
  447. assert response.status_code == 400
  448. assert "access code" in response.json()["detail"].lower()
  449. @pytest.mark.asyncio
  450. @pytest.mark.integration
  451. async def test_update_target_resyncs_access_code(self, async_client: AsyncClient, printer_factory, db_session):
  452. from sqlalchemy import select
  453. from backend.app.models.virtual_printer import VirtualPrinter
  454. first = await printer_factory(name="Printer A", access_code="AAAAAAAA")
  455. second = await printer_factory(name="Printer B", access_code="BBBBBBBB")
  456. create_resp = await async_client.post(
  457. "/api/v1/virtual-printers",
  458. json={
  459. "name": "MovingTarget",
  460. "mode": "queue",
  461. "target_printer_id": first.id,
  462. },
  463. )
  464. assert create_resp.status_code == 200
  465. vp_id = create_resp.json()["id"]
  466. vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
  467. assert vp.access_code == "AAAAAAAA"
  468. # Repoint to the second printer — access code should follow.
  469. update_resp = await async_client.put(
  470. f"/api/v1/virtual-printers/{vp_id}",
  471. json={"target_printer_id": second.id},
  472. )
  473. assert update_resp.status_code == 200
  474. await db_session.refresh(vp)
  475. assert vp.access_code == "BBBBBBBB"
  476. @pytest.mark.asyncio
  477. @pytest.mark.integration
  478. async def test_update_explicit_access_code_with_target_is_overridden(
  479. self, async_client: AsyncClient, printer_factory, db_session
  480. ):
  481. """An update that submits both an explicit access_code AND keeps a
  482. target_printer_id silently uses the target's code — belt-and-braces
  483. for non-UI clients that might try to set a divergent value."""
  484. from sqlalchemy import select
  485. from backend.app.models.virtual_printer import VirtualPrinter
  486. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  487. create_resp = await async_client.post(
  488. "/api/v1/virtual-printers",
  489. json={
  490. "name": "BeltBraces",
  491. "mode": "queue",
  492. "target_printer_id": target.id,
  493. },
  494. )
  495. assert create_resp.status_code == 200
  496. vp_id = create_resp.json()["id"]
  497. update_resp = await async_client.put(
  498. f"/api/v1/virtual-printers/{vp_id}",
  499. json={"access_code": "FORGEDCD"},
  500. )
  501. assert update_resp.status_code == 200
  502. vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
  503. assert vp.access_code == "REALCODE"
  504. class TestVirtualPrinterClearTarget:
  505. """A null target_printer_id clears the target (queue VP: any printer of
  506. its model); leaving the field out keeps it."""
  507. @staticmethod
  508. async def _queue_vp(async_client: AsyncClient, target_id: int, **extra) -> int:
  509. response = await async_client.post(
  510. "/api/v1/virtual-printers",
  511. json={"name": "QueueVP", "mode": "queue", "target_printer_id": target_id, **extra},
  512. )
  513. assert response.status_code == 200
  514. return response.json()["id"]
  515. @pytest.mark.asyncio
  516. @pytest.mark.integration
  517. async def test_null_clears_the_target_and_keeps_the_code(self, async_client: AsyncClient, printer_factory):
  518. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  519. vp_id = await self._queue_vp(async_client, target.id)
  520. response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"target_printer_id": None})
  521. assert response.status_code == 200
  522. assert response.json()["target_printer_id"] is None
  523. assert response.json()["access_code_set"] is True
  524. @pytest.mark.asyncio
  525. @pytest.mark.integration
  526. async def test_leaving_the_field_out_keeps_the_target(self, async_client: AsyncClient, printer_factory):
  527. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  528. vp_id = await self._queue_vp(async_client, target.id)
  529. response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"auto_dispatch": False})
  530. assert response.status_code == 200
  531. assert response.json()["target_printer_id"] == target.id
  532. @pytest.mark.asyncio
  533. @pytest.mark.integration
  534. async def test_clearing_a_proxy_target_turns_the_proxy_off(self, async_client: AsyncClient, printer_factory):
  535. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  536. response = await async_client.post(
  537. "/api/v1/virtual-printers",
  538. json={
  539. "name": "ProxyVP",
  540. "mode": "proxy",
  541. "target_printer_id": target.id,
  542. "bind_ip": "192.168.1.52",
  543. "enabled": True,
  544. },
  545. )
  546. assert response.status_code == 200
  547. vp_id = response.json()["id"]
  548. with patch("backend.app.services.virtual_printer.virtual_printer_manager.sync_from_db", AsyncMock()):
  549. response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"target_printer_id": None})
  550. assert response.status_code == 200
  551. assert response.json()["target_printer_id"] is None
  552. assert response.json()["enabled"] is False
  553. class TestVirtualPrinterSerialSurface:
  554. """Proxy-mode VPs must surface the target printer's serial in API responses.
  555. The bridge advertises the target's serial over SSDP and forwards the
  556. target's identity to the slicer; the VP-settings card should show the
  557. same serial so the user sees one consistent identity per VP. Archive /
  558. queue / review VPs keep the self-generated suffix-based serial since
  559. those modes never speak the target's identity.
  560. """
  561. @pytest.mark.asyncio
  562. @pytest.mark.integration
  563. async def test_proxy_vp_response_uses_target_printer_serial(self, async_client: AsyncClient, printer_factory):
  564. target = await printer_factory(name="Real X1C", access_code="REALCODE", serial_number="00M09A123456789")
  565. create_resp = await async_client.post(
  566. "/api/v1/virtual-printers",
  567. json={
  568. "name": "ProxyVP",
  569. "mode": "proxy",
  570. "target_printer_id": target.id,
  571. },
  572. )
  573. assert create_resp.status_code == 200
  574. vp_id = create_resp.json()["id"]
  575. assert create_resp.json()["serial"] == "00M09A123456789"
  576. get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
  577. assert get_resp.status_code == 200
  578. assert get_resp.json()["serial"] == "00M09A123456789"
  579. list_resp = await async_client.get("/api/v1/virtual-printers")
  580. assert list_resp.status_code == 200
  581. listed = next(p for p in list_resp.json()["printers"] if p["id"] == vp_id)
  582. assert listed["serial"] == "00M09A123456789"
  583. @pytest.mark.asyncio
  584. @pytest.mark.integration
  585. @pytest.mark.parametrize(("serial", "code"), [("31B8BP610600650", "O1C2"), ("0948BB540200427", "O1C")])
  586. async def test_a_proxy_for_an_h2c_reports_its_code_by_serial(
  587. self, async_client: AsyncClient, printer_factory, serial, code
  588. ):
  589. """An H2C is an O1C or an O1C2; the slicer only syncs when the VP reports the right one."""
  590. target = await printer_factory(name="Real H2C", model="H2C", serial_number=serial)
  591. create_resp = await async_client.post(
  592. "/api/v1/virtual-printers",
  593. json={"name": "ProxyH2C", "mode": "proxy", "target_printer_id": target.id},
  594. )
  595. assert create_resp.status_code == 200
  596. assert create_resp.json()["model"] == code
  597. @pytest.mark.asyncio
  598. @pytest.mark.integration
  599. async def test_the_list_names_each_models_serial_family(self, async_client: AsyncClient):
  600. families = (await async_client.get("/api/v1/virtual-printers")).json()["model_serial_prefixes"]
  601. assert families["O1C2"] == "31B"
  602. assert families["O1C"] == "094"
  603. @pytest.mark.asyncio
  604. @pytest.mark.integration
  605. async def test_non_proxy_vp_keeps_self_generated_serial(self, async_client: AsyncClient, printer_factory):
  606. # Even with a target printer set (#1429 access-code inheritance flow),
  607. # archive / queue / review VPs are NOT bridging the target's identity
  608. # to the slicer — they synthesise their own. Self-generated serial.
  609. target = await printer_factory(name="Real X1C", access_code="REALCODE", serial_number="00M09A123456789")
  610. create_resp = await async_client.post(
  611. "/api/v1/virtual-printers",
  612. json={
  613. "name": "QueueVP",
  614. "mode": "queue",
  615. "target_printer_id": target.id,
  616. },
  617. )
  618. assert create_resp.status_code == 200
  619. assert create_resp.json()["serial"] != "00M09A123456789"
  620. # The synthesised serial follows _get_serial_for_model's `<prefix><suffix>`
  621. # shape — model-specific prefix + 8-char hex suffix from `vp.serial_suffix`.
  622. assert len(create_resp.json()["serial"]) >= 8
  623. @pytest.mark.sqlite_only # a dangling reference needs foreign keys off
  624. @pytest.mark.asyncio
  625. @pytest.mark.integration
  626. async def test_proxy_vp_falls_back_to_self_generated_when_target_missing(
  627. self, async_client: AsyncClient, db_session
  628. ):
  629. # Defensive fallback: a proxy VP whose target_printer_id points at a
  630. # row that no longer exists (printer deleted mid-config, manual SQL
  631. # tweak, race) must not 500 — it returns the self-generated serial
  632. # so the card still renders and the user can fix the target.
  633. from backend.app.models.virtual_printer import VirtualPrinter
  634. vp = VirtualPrinter(name="OrphanProxy", mode="proxy", target_printer_id=99999, enabled=False)
  635. db_session.add(vp)
  636. await db_session.commit()
  637. get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp.id}")
  638. assert get_resp.status_code == 200
  639. body = get_resp.json()
  640. assert body["serial"] # non-empty
  641. assert len(body["serial"]) >= 8
  642. class TestVirtualPrinterUploadFolder:
  643. """Uploads land in a File Manager folder set per virtual printer."""
  644. @staticmethod
  645. async def _folder(db_session, **fields) -> int:
  646. from backend.app.models.library import LibraryFolder
  647. folder = LibraryFolder(**({"name": "Class 7b", "shared": True} | fields))
  648. db_session.add(folder)
  649. await db_session.commit()
  650. return folder.id
  651. @pytest.mark.asyncio
  652. @pytest.mark.integration
  653. async def test_set_on_create_and_cleared_with_null(self, async_client: AsyncClient, db_session):
  654. folder_id = await self._folder(db_session)
  655. response = await async_client.post(
  656. "/api/v1/virtual-printers",
  657. json={"name": "Lab", "mode": "library", "library_folder_id": folder_id, "queue_remove_after_print": True},
  658. )
  659. assert response.status_code == 200, response.text
  660. assert response.json()["library_folder_id"] == folder_id
  661. assert response.json()["queue_remove_after_print"] is True
  662. vp_id = response.json()["id"]
  663. response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"auto_dispatch": False})
  664. assert response.json()["library_folder_id"] == folder_id
  665. response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"library_folder_id": None})
  666. assert response.status_code == 200
  667. assert response.json()["library_folder_id"] is None
  668. @pytest.mark.asyncio
  669. @pytest.mark.integration
  670. async def test_an_external_folder_is_refused(self, async_client: AsyncClient, db_session, tmp_path):
  671. folder_id = await self._folder(db_session, name="NAS", is_external=True, external_path=str(tmp_path))
  672. vp_id = (await async_client.post("/api/v1/virtual-printers", json={"name": "Lab"})).json()["id"]
  673. response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"library_folder_id": folder_id})
  674. assert response.status_code == 400
  675. assert "external" in response.json()["detail"]
  676. @pytest.mark.asyncio
  677. @pytest.mark.integration
  678. async def test_an_unknown_folder_is_refused(self, async_client: AsyncClient):
  679. response = await async_client.post("/api/v1/virtual-printers", json={"name": "Lab", "library_folder_id": 4242})
  680. assert response.status_code == 404
  681. @pytest.mark.asyncio
  682. @pytest.mark.integration
  683. async def test_the_legacy_archive_mode_is_stored_as_library(self, async_client: AsyncClient):
  684. response = await async_client.post("/api/v1/virtual-printers", json={"name": "Old client", "mode": "archive"})
  685. assert response.status_code == 200
  686. assert response.json()["mode"] == "library"