test_virtual_printer_api.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846
  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.fixture(autouse=True)
  320. def _own_ca_dir(self, monkeypatch, tmp_path):
  321. # The CA lives under the real data dir, which all test workers share.
  322. # On a fresh one (Docker) they each created a CA at once and
  323. # overwrote one another between two requests of the same test.
  324. from backend.app.services.virtual_printer import virtual_printer_manager
  325. monkeypatch.setattr(virtual_printer_manager, "_base_dir", tmp_path)
  326. @pytest.mark.asyncio
  327. @pytest.mark.integration
  328. async def test_get_ca_certificate_returns_pem(self, async_client: AsyncClient):
  329. """The shared CA certificate is returned as PEM with identifying metadata."""
  330. response = await async_client.get("/api/v1/virtual-printers/ca-certificate")
  331. assert response.status_code == 200
  332. result = response.json()
  333. assert result["pem"].startswith("-----BEGIN CERTIFICATE-----")
  334. assert "PRIVATE KEY" not in result["pem"] # never expose the CA key
  335. assert len(result["fingerprint_sha256"].split(":")) == 32
  336. assert result["not_valid_after"]
  337. @pytest.mark.asyncio
  338. @pytest.mark.integration
  339. async def test_ca_certificate_route_precedes_vp_id_route(self, async_client: AsyncClient):
  340. """'ca-certificate' must not be swallowed by the /{vp_id} int route."""
  341. response = await async_client.get("/api/v1/virtual-printers/ca-certificate")
  342. # A 200 (not 422 from int-parsing "ca-certificate") proves route ordering.
  343. assert response.status_code == 200
  344. @pytest.mark.asyncio
  345. @pytest.mark.integration
  346. @pytest.mark.parametrize(("platform", "filename"), [("windows", ".cmd"), ("unix", ".sh")])
  347. async def test_installer_carries_this_instance_ca(self, async_client: AsyncClient, platform, filename):
  348. ca = (await async_client.get("/api/v1/virtual-printers/ca-certificate")).json()
  349. response = await async_client.get(f"/api/v1/virtual-printers/ca-certificate/installer/{platform}")
  350. assert response.status_code == 200
  351. result = response.json()
  352. assert result["filename"].endswith(filename)
  353. body = ca["pem"].strip().splitlines()[1]
  354. assert body in result["content"]
  355. assert ca["fingerprint_sha256"] in result["content"]
  356. assert "__BAMBUDDY_" not in result["content"]
  357. assert "PRIVATE KEY" not in result["content"]
  358. @pytest.mark.asyncio
  359. @pytest.mark.integration
  360. async def test_installer_for_an_unknown_platform_is_404(self, async_client: AsyncClient):
  361. response = await async_client.get("/api/v1/virtual-printers/ca-certificate/installer/amiga")
  362. assert response.status_code == 404
  363. class TestVirtualPrinterDiagnosticAPI:
  364. """Integration tests for GET /api/v1/virtual-printers/{vp_id}/diagnostic."""
  365. @pytest.mark.asyncio
  366. @pytest.mark.integration
  367. async def test_diagnose_unknown_vp_returns_404(self, async_client: AsyncClient):
  368. response = await async_client.get("/api/v1/virtual-printers/999999/diagnostic")
  369. assert response.status_code == 404
  370. @pytest.mark.asyncio
  371. @pytest.mark.integration
  372. async def test_diagnose_disabled_vp_reports_problems(self, async_client: AsyncClient):
  373. """A freshly created (disabled) VP fails the 'enabled' check."""
  374. create_resp = await async_client.post(
  375. "/api/v1/virtual-printers",
  376. json={"name": "TestDiagVP", "mode": "archive", "access_code": "12345678"},
  377. )
  378. assert create_resp.status_code == 200
  379. vp_id = create_resp.json()["id"]
  380. response = await async_client.get(f"/api/v1/virtual-printers/{vp_id}/diagnostic")
  381. assert response.status_code == 200
  382. result = response.json()
  383. assert result["vp_id"] == vp_id
  384. assert result["overall"] == "problems"
  385. by_id = {c["id"]: c["status"] for c in result["checks"]}
  386. assert by_id["enabled"] == "fail"
  387. assert by_id["running"] == "skip"
  388. class TestVirtualPrinterAccessCodeInheritance:
  389. """Non-proxy VPs with a target printer must inherit the target's access
  390. code at write time.
  391. The live-mirror bridge forwards the slicer's MQTT/RTSPS auth bytes to
  392. the real printer — if the codes diverge the slicer binds the VP but the
  393. bridge fails at the second hop. The route layer force-derives the code
  394. on every create / update so a non-UI client can't introduce a divergence
  395. either.
  396. """
  397. @pytest.mark.asyncio
  398. @pytest.mark.integration
  399. async def test_create_with_target_ignores_submitted_access_code(
  400. self, async_client: AsyncClient, printer_factory, db_session
  401. ):
  402. from sqlalchemy import select
  403. from backend.app.models.virtual_printer import VirtualPrinter
  404. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  405. response = await async_client.post(
  406. "/api/v1/virtual-printers",
  407. json={
  408. "name": "QueueVP",
  409. "mode": "queue",
  410. "access_code": "WRONGAAA",
  411. "target_printer_id": target.id,
  412. },
  413. )
  414. assert response.status_code == 200
  415. vp_id = response.json()["id"]
  416. assert response.json()["access_code_set"] is True
  417. vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
  418. assert vp.access_code == "REALCODE"
  419. @pytest.mark.asyncio
  420. @pytest.mark.integration
  421. async def test_create_with_target_and_no_access_code_still_enables(
  422. self, async_client: AsyncClient, printer_factory
  423. ):
  424. """A non-proxy VP with a target set can be enabled without supplying
  425. access_code separately — the inheritance makes the explicit field
  426. redundant, and the validator now knows this."""
  427. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  428. response = await async_client.post(
  429. "/api/v1/virtual-printers",
  430. json={
  431. "name": "QueueVP",
  432. "mode": "queue",
  433. "target_printer_id": target.id,
  434. "bind_ip": "192.168.1.50",
  435. "enabled": True,
  436. },
  437. )
  438. assert response.status_code == 200
  439. assert response.json()["access_code_set"] is True
  440. @pytest.mark.asyncio
  441. @pytest.mark.integration
  442. async def test_create_without_target_still_requires_access_code_on_enable(self, async_client: AsyncClient):
  443. """The relaxation only kicks in when a target is set. A standalone
  444. non-proxy VP still needs its own access code."""
  445. response = await async_client.post(
  446. "/api/v1/virtual-printers",
  447. json={
  448. "name": "StandaloneVP",
  449. "mode": "archive",
  450. "bind_ip": "192.168.1.51",
  451. "enabled": True,
  452. },
  453. )
  454. assert response.status_code == 400
  455. assert "access code" in response.json()["detail"].lower()
  456. @pytest.mark.asyncio
  457. @pytest.mark.integration
  458. async def test_update_target_resyncs_access_code(self, async_client: AsyncClient, printer_factory, db_session):
  459. from sqlalchemy import select
  460. from backend.app.models.virtual_printer import VirtualPrinter
  461. first = await printer_factory(name="Printer A", access_code="AAAAAAAA")
  462. second = await printer_factory(name="Printer B", access_code="BBBBBBBB")
  463. create_resp = await async_client.post(
  464. "/api/v1/virtual-printers",
  465. json={
  466. "name": "MovingTarget",
  467. "mode": "queue",
  468. "target_printer_id": first.id,
  469. },
  470. )
  471. assert create_resp.status_code == 200
  472. vp_id = create_resp.json()["id"]
  473. vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
  474. assert vp.access_code == "AAAAAAAA"
  475. # Repoint to the second printer — access code should follow.
  476. update_resp = await async_client.put(
  477. f"/api/v1/virtual-printers/{vp_id}",
  478. json={"target_printer_id": second.id},
  479. )
  480. assert update_resp.status_code == 200
  481. await db_session.refresh(vp)
  482. assert vp.access_code == "BBBBBBBB"
  483. @pytest.mark.asyncio
  484. @pytest.mark.integration
  485. async def test_update_explicit_access_code_with_target_is_overridden(
  486. self, async_client: AsyncClient, printer_factory, db_session
  487. ):
  488. """An update that submits both an explicit access_code AND keeps a
  489. target_printer_id silently uses the target's code — belt-and-braces
  490. for non-UI clients that might try to set a divergent value."""
  491. from sqlalchemy import select
  492. from backend.app.models.virtual_printer import VirtualPrinter
  493. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  494. create_resp = await async_client.post(
  495. "/api/v1/virtual-printers",
  496. json={
  497. "name": "BeltBraces",
  498. "mode": "queue",
  499. "target_printer_id": target.id,
  500. },
  501. )
  502. assert create_resp.status_code == 200
  503. vp_id = create_resp.json()["id"]
  504. update_resp = await async_client.put(
  505. f"/api/v1/virtual-printers/{vp_id}",
  506. json={"access_code": "FORGEDCD"},
  507. )
  508. assert update_resp.status_code == 200
  509. vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
  510. assert vp.access_code == "REALCODE"
  511. class TestVirtualPrinterClearTarget:
  512. """A null target_printer_id clears the target (queue VP: any printer of
  513. its model); leaving the field out keeps it."""
  514. @staticmethod
  515. async def _queue_vp(async_client: AsyncClient, target_id: int, **extra) -> int:
  516. response = await async_client.post(
  517. "/api/v1/virtual-printers",
  518. json={"name": "QueueVP", "mode": "queue", "target_printer_id": target_id, **extra},
  519. )
  520. assert response.status_code == 200
  521. return response.json()["id"]
  522. @pytest.mark.asyncio
  523. @pytest.mark.integration
  524. async def test_null_clears_the_target_and_keeps_the_code(self, async_client: AsyncClient, printer_factory):
  525. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  526. vp_id = await self._queue_vp(async_client, target.id)
  527. response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"target_printer_id": None})
  528. assert response.status_code == 200
  529. assert response.json()["target_printer_id"] is None
  530. assert response.json()["access_code_set"] is True
  531. @pytest.mark.asyncio
  532. @pytest.mark.integration
  533. async def test_leaving_the_field_out_keeps_the_target(self, async_client: AsyncClient, printer_factory):
  534. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  535. vp_id = await self._queue_vp(async_client, target.id)
  536. response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"auto_dispatch": False})
  537. assert response.status_code == 200
  538. assert response.json()["target_printer_id"] == target.id
  539. @pytest.mark.asyncio
  540. @pytest.mark.integration
  541. async def test_clearing_a_proxy_target_turns_the_proxy_off(self, async_client: AsyncClient, printer_factory):
  542. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  543. response = await async_client.post(
  544. "/api/v1/virtual-printers",
  545. json={
  546. "name": "ProxyVP",
  547. "mode": "proxy",
  548. "target_printer_id": target.id,
  549. "bind_ip": "192.168.1.52",
  550. "enabled": True,
  551. },
  552. )
  553. assert response.status_code == 200
  554. vp_id = response.json()["id"]
  555. with patch("backend.app.services.virtual_printer.virtual_printer_manager.sync_from_db", AsyncMock()):
  556. response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"target_printer_id": None})
  557. assert response.status_code == 200
  558. assert response.json()["target_printer_id"] is None
  559. assert response.json()["enabled"] is False
  560. class TestVirtualPrinterSerialSurface:
  561. """Proxy-mode VPs must surface the target printer's serial in API responses.
  562. The bridge advertises the target's serial over SSDP and forwards the
  563. target's identity to the slicer; the VP-settings card should show the
  564. same serial so the user sees one consistent identity per VP. Archive /
  565. queue / review VPs keep the self-generated suffix-based serial since
  566. those modes never speak the target's identity.
  567. """
  568. @pytest.mark.asyncio
  569. @pytest.mark.integration
  570. async def test_proxy_vp_response_uses_target_printer_serial(self, async_client: AsyncClient, printer_factory):
  571. target = await printer_factory(name="Real X1C", access_code="REALCODE", serial_number="00M09A123456789")
  572. create_resp = await async_client.post(
  573. "/api/v1/virtual-printers",
  574. json={
  575. "name": "ProxyVP",
  576. "mode": "proxy",
  577. "target_printer_id": target.id,
  578. },
  579. )
  580. assert create_resp.status_code == 200
  581. vp_id = create_resp.json()["id"]
  582. assert create_resp.json()["serial"] == "00M09A123456789"
  583. get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
  584. assert get_resp.status_code == 200
  585. assert get_resp.json()["serial"] == "00M09A123456789"
  586. list_resp = await async_client.get("/api/v1/virtual-printers")
  587. assert list_resp.status_code == 200
  588. listed = next(p for p in list_resp.json()["printers"] if p["id"] == vp_id)
  589. assert listed["serial"] == "00M09A123456789"
  590. @pytest.mark.asyncio
  591. @pytest.mark.integration
  592. @pytest.mark.parametrize(("serial", "code"), [("31B8BP610600650", "O1C2"), ("0948BB540200427", "O1C")])
  593. async def test_a_proxy_for_an_h2c_reports_its_code_by_serial(
  594. self, async_client: AsyncClient, printer_factory, serial, code
  595. ):
  596. """An H2C is an O1C or an O1C2; the slicer only syncs when the VP reports the right one."""
  597. target = await printer_factory(name="Real H2C", model="H2C", serial_number=serial)
  598. create_resp = await async_client.post(
  599. "/api/v1/virtual-printers",
  600. json={"name": "ProxyH2C", "mode": "proxy", "target_printer_id": target.id},
  601. )
  602. assert create_resp.status_code == 200
  603. assert create_resp.json()["model"] == code
  604. @pytest.mark.asyncio
  605. @pytest.mark.integration
  606. async def test_the_list_names_each_models_serial_family(self, async_client: AsyncClient):
  607. families = (await async_client.get("/api/v1/virtual-printers")).json()["model_serial_prefixes"]
  608. assert families["O1C2"] == "31B"
  609. assert families["O1C"] == "094"
  610. @pytest.mark.asyncio
  611. @pytest.mark.integration
  612. async def test_non_proxy_vp_keeps_self_generated_serial(self, async_client: AsyncClient, printer_factory):
  613. # Even with a target printer set (#1429 access-code inheritance flow),
  614. # archive / queue / review VPs are NOT bridging the target's identity
  615. # to the slicer — they synthesise their own. Self-generated serial.
  616. target = await printer_factory(name="Real X1C", access_code="REALCODE", serial_number="00M09A123456789")
  617. create_resp = await async_client.post(
  618. "/api/v1/virtual-printers",
  619. json={
  620. "name": "QueueVP",
  621. "mode": "queue",
  622. "target_printer_id": target.id,
  623. },
  624. )
  625. assert create_resp.status_code == 200
  626. assert create_resp.json()["serial"] != "00M09A123456789"
  627. # The synthesised serial follows _get_serial_for_model's `<prefix><suffix>`
  628. # shape — model-specific prefix + 8-char hex suffix from `vp.serial_suffix`.
  629. assert len(create_resp.json()["serial"]) >= 8
  630. @pytest.mark.sqlite_only # a dangling reference needs foreign keys off
  631. @pytest.mark.asyncio
  632. @pytest.mark.integration
  633. async def test_proxy_vp_falls_back_to_self_generated_when_target_missing(
  634. self, async_client: AsyncClient, db_session
  635. ):
  636. # Defensive fallback: a proxy VP whose target_printer_id points at a
  637. # row that no longer exists (printer deleted mid-config, manual SQL
  638. # tweak, race) must not 500 — it returns the self-generated serial
  639. # so the card still renders and the user can fix the target.
  640. from backend.app.models.virtual_printer import VirtualPrinter
  641. vp = VirtualPrinter(name="OrphanProxy", mode="proxy", target_printer_id=99999, enabled=False)
  642. db_session.add(vp)
  643. await db_session.commit()
  644. get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp.id}")
  645. assert get_resp.status_code == 200
  646. body = get_resp.json()
  647. assert body["serial"] # non-empty
  648. assert len(body["serial"]) >= 8
  649. class TestVirtualPrinterUploadFolder:
  650. """Uploads land in a File Manager folder set per virtual printer."""
  651. @staticmethod
  652. async def _folder(db_session, **fields) -> int:
  653. from backend.app.models.library import LibraryFolder
  654. folder = LibraryFolder(**({"name": "Class 7b", "shared": True} | fields))
  655. db_session.add(folder)
  656. await db_session.commit()
  657. return folder.id
  658. @pytest.mark.asyncio
  659. @pytest.mark.integration
  660. async def test_set_on_create_and_cleared_with_null(self, async_client: AsyncClient, db_session):
  661. folder_id = await self._folder(db_session)
  662. response = await async_client.post(
  663. "/api/v1/virtual-printers",
  664. json={"name": "Lab", "mode": "library", "library_folder_id": folder_id, "queue_remove_after_print": True},
  665. )
  666. assert response.status_code == 200, response.text
  667. assert response.json()["library_folder_id"] == folder_id
  668. assert response.json()["queue_remove_after_print"] is True
  669. vp_id = response.json()["id"]
  670. response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"auto_dispatch": False})
  671. assert response.json()["library_folder_id"] == folder_id
  672. response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"library_folder_id": None})
  673. assert response.status_code == 200
  674. assert response.json()["library_folder_id"] is None
  675. @pytest.mark.asyncio
  676. @pytest.mark.integration
  677. async def test_an_external_folder_is_refused(self, async_client: AsyncClient, db_session, tmp_path):
  678. folder_id = await self._folder(db_session, name="NAS", is_external=True, external_path=str(tmp_path))
  679. vp_id = (await async_client.post("/api/v1/virtual-printers", json={"name": "Lab"})).json()["id"]
  680. response = await async_client.put(f"/api/v1/virtual-printers/{vp_id}", json={"library_folder_id": folder_id})
  681. assert response.status_code == 400
  682. assert "external" in response.json()["detail"]
  683. @pytest.mark.asyncio
  684. @pytest.mark.integration
  685. async def test_an_unknown_folder_is_refused(self, async_client: AsyncClient):
  686. response = await async_client.post("/api/v1/virtual-printers", json={"name": "Lab", "library_folder_id": 4242})
  687. assert response.status_code == 404
  688. @pytest.mark.asyncio
  689. @pytest.mark.integration
  690. async def test_the_legacy_archive_mode_is_stored_as_library(self, async_client: AsyncClient):
  691. response = await async_client.post("/api/v1/virtual-printers", json={"name": "Old client", "mode": "archive"})
  692. assert response.status_code == 200
  693. assert response.json()["mode"] == "library"