test_virtual_printer_api.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667
  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}",
  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 "archived" 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. class TestVirtualPrinterDiagnosticAPI:
  338. """Integration tests for GET /api/v1/virtual-printers/{vp_id}/diagnostic."""
  339. @pytest.mark.asyncio
  340. @pytest.mark.integration
  341. async def test_diagnose_unknown_vp_returns_404(self, async_client: AsyncClient):
  342. response = await async_client.get("/api/v1/virtual-printers/999999/diagnostic")
  343. assert response.status_code == 404
  344. @pytest.mark.asyncio
  345. @pytest.mark.integration
  346. async def test_diagnose_disabled_vp_reports_problems(self, async_client: AsyncClient):
  347. """A freshly created (disabled) VP fails the 'enabled' check."""
  348. create_resp = await async_client.post(
  349. "/api/v1/virtual-printers",
  350. json={"name": "TestDiagVP", "mode": "archive", "access_code": "12345678"},
  351. )
  352. assert create_resp.status_code == 200
  353. vp_id = create_resp.json()["id"]
  354. response = await async_client.get(f"/api/v1/virtual-printers/{vp_id}/diagnostic")
  355. assert response.status_code == 200
  356. result = response.json()
  357. assert result["vp_id"] == vp_id
  358. assert result["overall"] == "problems"
  359. by_id = {c["id"]: c["status"] for c in result["checks"]}
  360. assert by_id["enabled"] == "fail"
  361. assert by_id["running"] == "skip"
  362. class TestVirtualPrinterAccessCodeInheritance:
  363. """Non-proxy VPs with a target printer must inherit the target's access
  364. code at write time.
  365. The live-mirror bridge forwards the slicer's MQTT/RTSPS auth bytes to
  366. the real printer — if the codes diverge the slicer binds the VP but the
  367. bridge fails at the second hop. The route layer force-derives the code
  368. on every create / update so a non-UI client can't introduce a divergence
  369. either.
  370. """
  371. @pytest.mark.asyncio
  372. @pytest.mark.integration
  373. async def test_create_with_target_ignores_submitted_access_code(
  374. self, async_client: AsyncClient, printer_factory, db_session
  375. ):
  376. from sqlalchemy import select
  377. from backend.app.models.virtual_printer import VirtualPrinter
  378. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  379. response = await async_client.post(
  380. "/api/v1/virtual-printers",
  381. json={
  382. "name": "QueueVP",
  383. "mode": "queue",
  384. "access_code": "WRONGAAA",
  385. "target_printer_id": target.id,
  386. },
  387. )
  388. assert response.status_code == 200
  389. vp_id = response.json()["id"]
  390. assert response.json()["access_code_set"] is True
  391. vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
  392. assert vp.access_code == "REALCODE"
  393. @pytest.mark.asyncio
  394. @pytest.mark.integration
  395. async def test_create_with_target_and_no_access_code_still_enables(
  396. self, async_client: AsyncClient, printer_factory
  397. ):
  398. """A non-proxy VP with a target set can be enabled without supplying
  399. access_code separately — the inheritance makes the explicit field
  400. redundant, and the validator now knows this."""
  401. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  402. response = await async_client.post(
  403. "/api/v1/virtual-printers",
  404. json={
  405. "name": "QueueVP",
  406. "mode": "queue",
  407. "target_printer_id": target.id,
  408. "bind_ip": "192.168.1.50",
  409. "enabled": True,
  410. },
  411. )
  412. assert response.status_code == 200
  413. assert response.json()["access_code_set"] is True
  414. @pytest.mark.asyncio
  415. @pytest.mark.integration
  416. async def test_create_without_target_still_requires_access_code_on_enable(self, async_client: AsyncClient):
  417. """The relaxation only kicks in when a target is set. A standalone
  418. non-proxy VP still needs its own access code."""
  419. response = await async_client.post(
  420. "/api/v1/virtual-printers",
  421. json={
  422. "name": "StandaloneVP",
  423. "mode": "archive",
  424. "bind_ip": "192.168.1.51",
  425. "enabled": True,
  426. },
  427. )
  428. assert response.status_code == 400
  429. assert "access code" in response.json()["detail"].lower()
  430. @pytest.mark.asyncio
  431. @pytest.mark.integration
  432. async def test_update_target_resyncs_access_code(self, async_client: AsyncClient, printer_factory, db_session):
  433. from sqlalchemy import select
  434. from backend.app.models.virtual_printer import VirtualPrinter
  435. first = await printer_factory(name="Printer A", access_code="AAAAAAAA")
  436. second = await printer_factory(name="Printer B", access_code="BBBBBBBB")
  437. create_resp = await async_client.post(
  438. "/api/v1/virtual-printers",
  439. json={
  440. "name": "MovingTarget",
  441. "mode": "queue",
  442. "target_printer_id": first.id,
  443. },
  444. )
  445. assert create_resp.status_code == 200
  446. vp_id = create_resp.json()["id"]
  447. vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
  448. assert vp.access_code == "AAAAAAAA"
  449. # Repoint to the second printer — access code should follow.
  450. update_resp = await async_client.put(
  451. f"/api/v1/virtual-printers/{vp_id}",
  452. json={"target_printer_id": second.id},
  453. )
  454. assert update_resp.status_code == 200
  455. await db_session.refresh(vp)
  456. assert vp.access_code == "BBBBBBBB"
  457. @pytest.mark.asyncio
  458. @pytest.mark.integration
  459. async def test_update_explicit_access_code_with_target_is_overridden(
  460. self, async_client: AsyncClient, printer_factory, db_session
  461. ):
  462. """An update that submits both an explicit access_code AND keeps a
  463. target_printer_id silently uses the target's code — belt-and-braces
  464. for non-UI clients that might try to set a divergent value."""
  465. from sqlalchemy import select
  466. from backend.app.models.virtual_printer import VirtualPrinter
  467. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  468. create_resp = await async_client.post(
  469. "/api/v1/virtual-printers",
  470. json={
  471. "name": "BeltBraces",
  472. "mode": "queue",
  473. "target_printer_id": target.id,
  474. },
  475. )
  476. assert create_resp.status_code == 200
  477. vp_id = create_resp.json()["id"]
  478. update_resp = await async_client.put(
  479. f"/api/v1/virtual-printers/{vp_id}",
  480. json={"access_code": "FORGEDCD"},
  481. )
  482. assert update_resp.status_code == 200
  483. vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
  484. assert vp.access_code == "REALCODE"
  485. class TestVirtualPrinterSerialSurface:
  486. """Proxy-mode VPs must surface the target printer's serial in API responses.
  487. The bridge advertises the target's serial over SSDP and forwards the
  488. target's identity to the slicer; the VP-settings card should show the
  489. same serial so the user sees one consistent identity per VP. Archive /
  490. queue / review VPs keep the self-generated suffix-based serial since
  491. those modes never speak the target's identity.
  492. """
  493. @pytest.mark.asyncio
  494. @pytest.mark.integration
  495. async def test_proxy_vp_response_uses_target_printer_serial(self, async_client: AsyncClient, printer_factory):
  496. target = await printer_factory(name="Real X1C", access_code="REALCODE", serial_number="00M09A123456789")
  497. create_resp = await async_client.post(
  498. "/api/v1/virtual-printers",
  499. json={
  500. "name": "ProxyVP",
  501. "mode": "proxy",
  502. "target_printer_id": target.id,
  503. },
  504. )
  505. assert create_resp.status_code == 200
  506. vp_id = create_resp.json()["id"]
  507. assert create_resp.json()["serial"] == "00M09A123456789"
  508. get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
  509. assert get_resp.status_code == 200
  510. assert get_resp.json()["serial"] == "00M09A123456789"
  511. list_resp = await async_client.get("/api/v1/virtual-printers")
  512. assert list_resp.status_code == 200
  513. listed = next(p for p in list_resp.json()["printers"] if p["id"] == vp_id)
  514. assert listed["serial"] == "00M09A123456789"
  515. @pytest.mark.asyncio
  516. @pytest.mark.integration
  517. async def test_non_proxy_vp_keeps_self_generated_serial(self, async_client: AsyncClient, printer_factory):
  518. # Even with a target printer set (#1429 access-code inheritance flow),
  519. # archive / queue / review VPs are NOT bridging the target's identity
  520. # to the slicer — they synthesise their own. Self-generated serial.
  521. target = await printer_factory(name="Real X1C", access_code="REALCODE", serial_number="00M09A123456789")
  522. create_resp = await async_client.post(
  523. "/api/v1/virtual-printers",
  524. json={
  525. "name": "QueueVP",
  526. "mode": "queue",
  527. "target_printer_id": target.id,
  528. },
  529. )
  530. assert create_resp.status_code == 200
  531. assert create_resp.json()["serial"] != "00M09A123456789"
  532. # The synthesised serial follows _get_serial_for_model's `<prefix><suffix>`
  533. # shape — model-specific prefix + 8-char hex suffix from `vp.serial_suffix`.
  534. assert len(create_resp.json()["serial"]) >= 8
  535. @pytest.mark.sqlite_only # a dangling reference needs foreign keys off
  536. @pytest.mark.asyncio
  537. @pytest.mark.integration
  538. async def test_proxy_vp_falls_back_to_self_generated_when_target_missing(
  539. self, async_client: AsyncClient, db_session
  540. ):
  541. # Defensive fallback: a proxy VP whose target_printer_id points at a
  542. # row that no longer exists (printer deleted mid-config, manual SQL
  543. # tweak, race) must not 500 — it returns the self-generated serial
  544. # so the card still renders and the user can fix the target.
  545. from backend.app.models.virtual_printer import VirtualPrinter
  546. vp = VirtualPrinter(name="OrphanProxy", mode="proxy", target_printer_id=99999, enabled=False)
  547. db_session.add(vp)
  548. await db_session.commit()
  549. get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp.id}")
  550. assert get_resp.status_code == 200
  551. body = get_resp.json()
  552. assert body["serial"] # non-empty
  553. assert len(body["serial"]) >= 8