test_virtual_printer_api.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649
  1. """Integration tests for Virtual Printer API endpoints.
  2. Tests the full request/response cycle for /api/v1/settings/virtual-printer endpoints.
  3. """
  4. from unittest.mock import AsyncMock, MagicMock, patch
  5. import pytest
  6. from httpx import AsyncClient
  7. class TestVirtualPrinterSettingsAPI:
  8. """Integration tests for /api/v1/settings/virtual-printer endpoints."""
  9. # ========================================================================
  10. # Get settings
  11. # ========================================================================
  12. @pytest.mark.asyncio
  13. @pytest.mark.integration
  14. async def test_get_virtual_printer_settings(self, async_client: AsyncClient):
  15. """Verify virtual printer settings can be retrieved."""
  16. response = await async_client.get("/api/v1/settings/virtual-printer")
  17. assert response.status_code == 200
  18. result = response.json()
  19. assert "enabled" in result
  20. assert "access_code_set" in result
  21. assert "mode" in result
  22. assert "status" in result
  23. @pytest.mark.asyncio
  24. @pytest.mark.integration
  25. async def test_get_settings_has_status(self, async_client: AsyncClient):
  26. """Verify settings include status details."""
  27. response = await async_client.get("/api/v1/settings/virtual-printer")
  28. assert response.status_code == 200
  29. result = response.json()
  30. status = result["status"]
  31. assert "enabled" in status
  32. assert "running" in status
  33. assert "mode" in status
  34. assert "name" in status
  35. assert "serial" in status
  36. assert "pending_files" in status
  37. # ========================================================================
  38. # Update settings
  39. # ========================================================================
  40. @pytest.mark.asyncio
  41. @pytest.mark.integration
  42. @pytest.mark.parametrize(
  43. "query",
  44. [
  45. "enabled=true",
  46. "enabled=false",
  47. "access_code=12345678",
  48. "mode=review",
  49. "mode=print_queue",
  50. "model=BL-P001",
  51. "target_printer_id=1",
  52. "remote_interface_ip=10.0.0.5",
  53. ],
  54. )
  55. async def test_settings_the_virtual_printers_never_read_are_refused(self, async_client: AsyncClient, query):
  56. """vp-3: these were saved to settings keys nothing reads any more -- each
  57. virtual printer keeps its own -- and answered 200 as if applied."""
  58. response = await async_client.put(f"/api/v1/settings/virtual-printer?{query}")
  59. assert response.status_code == 400
  60. assert "/api/v1/virtual-printers/" in response.json()["detail"]
  61. @pytest.mark.asyncio
  62. @pytest.mark.integration
  63. async def test_stale_legacy_settings_do_not_block_the_archive_name_source(
  64. self, async_client: AsyncClient, db_session
  65. ):
  66. """An old single-VP install left mode "proxy", enabled, aimed at a
  67. printer since deleted: the route checked those before saving, and
  68. refused the one setting it still serves."""
  69. from backend.app.models.settings import Settings
  70. db_session.add_all(
  71. [
  72. Settings(key="virtual_printer_mode", value="proxy"),
  73. Settings(key="virtual_printer_enabled", value="true"),
  74. Settings(key="virtual_printer_target_printer_id", value="999"),
  75. ]
  76. )
  77. await db_session.commit()
  78. response = await async_client.put("/api/v1/settings/virtual-printer?archive_name_source=filename")
  79. assert response.status_code == 200, response.text
  80. @pytest.mark.asyncio
  81. @pytest.mark.integration
  82. async def test_the_archive_name_source_is_still_saved(self, async_client: AsyncClient):
  83. response = await async_client.put("/api/v1/settings/virtual-printer?archive_name_source=filename")
  84. assert response.status_code == 200
  85. assert response.json()["archive_name_source"] == "filename"
  86. class TestPendingUploadsAPI:
  87. """Integration tests for /api/v1/pending-uploads/ endpoints."""
  88. @pytest.fixture
  89. def mock_pending_uploads(self, db_session):
  90. """Create mock pending uploads in database."""
  91. async def _create_pending(filename: str = "test.3mf"):
  92. from datetime import datetime
  93. from backend.app.models.pending_upload import PendingUpload
  94. upload = PendingUpload(
  95. filename=filename,
  96. file_path=f"/tmp/{filename}",
  97. file_size=1024,
  98. source_ip="192.168.1.100",
  99. status="pending",
  100. )
  101. db_session.add(upload)
  102. await db_session.commit()
  103. await db_session.refresh(upload)
  104. return upload
  105. return _create_pending
  106. # ========================================================================
  107. # List pending uploads
  108. # ========================================================================
  109. @pytest.mark.asyncio
  110. @pytest.mark.integration
  111. async def test_list_pending_uploads_empty(self, async_client: AsyncClient):
  112. """Verify empty list is returned when no pending uploads."""
  113. response = await async_client.get("/api/v1/pending-uploads/")
  114. assert response.status_code == 200
  115. result = response.json()
  116. assert isinstance(result, list)
  117. @pytest.mark.asyncio
  118. @pytest.mark.integration
  119. async def test_get_pending_uploads_count(self, async_client: AsyncClient):
  120. """Verify count endpoint returns correct count."""
  121. response = await async_client.get("/api/v1/pending-uploads/count")
  122. assert response.status_code == 200
  123. result = response.json()
  124. assert "count" in result
  125. assert isinstance(result["count"], int)
  126. # ========================================================================
  127. # Archive pending upload
  128. # ========================================================================
  129. @pytest.mark.asyncio
  130. @pytest.mark.integration
  131. async def test_archive_nonexistent_upload(self, async_client: AsyncClient):
  132. """Verify archiving non-existent upload returns 404."""
  133. response = await async_client.post("/api/v1/pending-uploads/99999/archive")
  134. assert response.status_code == 404
  135. # ========================================================================
  136. # Discard pending upload
  137. # ========================================================================
  138. @pytest.mark.asyncio
  139. @pytest.mark.integration
  140. async def test_discard_nonexistent_upload(self, async_client: AsyncClient):
  141. """Verify discarding non-existent upload returns 404."""
  142. response = await async_client.delete("/api/v1/pending-uploads/99999")
  143. assert response.status_code == 404
  144. # ========================================================================
  145. # Bulk operations
  146. # ========================================================================
  147. @pytest.mark.asyncio
  148. @pytest.mark.integration
  149. async def test_archive_all_empty(self, async_client: AsyncClient):
  150. """Verify archive all with no pending uploads."""
  151. response = await async_client.post("/api/v1/pending-uploads/archive-all")
  152. assert response.status_code == 200
  153. result = response.json()
  154. assert "archived" in result
  155. assert "failed" in result
  156. @pytest.mark.asyncio
  157. @pytest.mark.integration
  158. async def test_discard_all_empty(self, async_client: AsyncClient):
  159. """Verify discard all with no pending uploads."""
  160. response = await async_client.delete("/api/v1/pending-uploads/discard-all")
  161. assert response.status_code == 200
  162. result = response.json()
  163. assert "discarded" in result
  164. class TestVirtualPrinterAutoDispatchAPI:
  165. """Integration tests for auto_dispatch on /api/v1/virtual-printers endpoints."""
  166. @pytest.mark.asyncio
  167. @pytest.mark.integration
  168. async def test_create_virtual_printer_auto_dispatch_default(self, async_client: AsyncClient):
  169. """Verify creating a VP without auto_dispatch defaults to true."""
  170. response = await async_client.post(
  171. "/api/v1/virtual-printers",
  172. json={
  173. "name": "TestDefaultDispatch",
  174. "mode": "queue",
  175. "access_code": "12345678",
  176. },
  177. )
  178. assert response.status_code == 200
  179. result = response.json()
  180. assert result["auto_dispatch"] is True
  181. @pytest.mark.asyncio
  182. @pytest.mark.integration
  183. async def test_create_virtual_printer_auto_dispatch_false(self, async_client: AsyncClient):
  184. """Verify creating a VP with auto_dispatch=false persists correctly."""
  185. response = await async_client.post(
  186. "/api/v1/virtual-printers",
  187. json={
  188. "name": "TestManualDispatch",
  189. "mode": "queue",
  190. "access_code": "12345678",
  191. "auto_dispatch": False,
  192. },
  193. )
  194. assert response.status_code == 200
  195. result = response.json()
  196. assert result["auto_dispatch"] is False
  197. @pytest.mark.asyncio
  198. @pytest.mark.integration
  199. async def test_update_virtual_printer_auto_dispatch(self, async_client: AsyncClient):
  200. """Verify auto_dispatch can be toggled via PUT and persists."""
  201. # Create with auto_dispatch=True (default)
  202. create_resp = await async_client.post(
  203. "/api/v1/virtual-printers",
  204. json={
  205. "name": "TestToggleDispatch",
  206. "mode": "queue",
  207. "access_code": "12345678",
  208. },
  209. )
  210. assert create_resp.status_code == 200
  211. vp_id = create_resp.json()["id"]
  212. # Update to auto_dispatch=False
  213. update_resp = await async_client.put(
  214. f"/api/v1/virtual-printers/{vp_id}",
  215. json={"auto_dispatch": False},
  216. )
  217. assert update_resp.status_code == 200
  218. assert update_resp.json()["auto_dispatch"] is False
  219. # Verify it persists by fetching
  220. get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
  221. assert get_resp.status_code == 200
  222. assert get_resp.json()["auto_dispatch"] is False
  223. class TestVirtualPrinterGcodeInjectionAPI:
  224. """Integration tests for gcode_injection (#1516) on /api/v1/virtual-printers endpoints."""
  225. @pytest.mark.asyncio
  226. @pytest.mark.integration
  227. async def test_create_virtual_printer_gcode_injection_default_off(self, async_client: AsyncClient):
  228. """Verify creating a VP without gcode_injection defaults to false (opt-in)."""
  229. response = await async_client.post(
  230. "/api/v1/virtual-printers",
  231. json={
  232. "name": "TestDefaultInjection",
  233. "mode": "queue",
  234. "access_code": "12345678",
  235. },
  236. )
  237. assert response.status_code == 200
  238. assert response.json()["gcode_injection"] is False
  239. @pytest.mark.asyncio
  240. @pytest.mark.integration
  241. async def test_update_virtual_printer_gcode_injection(self, async_client: AsyncClient):
  242. """Verify gcode_injection can be toggled via PUT and persists."""
  243. create_resp = await async_client.post(
  244. "/api/v1/virtual-printers",
  245. json={
  246. "name": "TestToggleInjection",
  247. "mode": "queue",
  248. "access_code": "12345678",
  249. },
  250. )
  251. assert create_resp.status_code == 200
  252. vp_id = create_resp.json()["id"]
  253. assert create_resp.json()["gcode_injection"] is False
  254. update_resp = await async_client.put(
  255. f"/api/v1/virtual-printers/{vp_id}",
  256. json={"gcode_injection": True},
  257. )
  258. assert update_resp.status_code == 200
  259. assert update_resp.json()["gcode_injection"] is True
  260. get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
  261. assert get_resp.status_code == 200
  262. assert get_resp.json()["gcode_injection"] is True
  263. class TestVirtualPrinterTailscaleToggleAPI:
  264. """The Tailscale toggle is informational — toggling either way always succeeds.
  265. There used to be a 409 guard rejecting "enable" when the daemon was unreachable,
  266. back when the toggle controlled LE cert provisioning. That path was removed:
  267. the slicer's printer-MQTT trust validates against its bundled BBL CA, not the
  268. system trust store, so even an LE cert wouldn't be accepted. The toggle now
  269. only surfaces the host's Tailscale IP/FQDN on the VP card; daemon presence is
  270. irrelevant to whether the toggle can be flipped.
  271. """
  272. @pytest.mark.asyncio
  273. @pytest.mark.integration
  274. async def test_toggle_does_not_consult_tailscale_daemon(self, async_client: AsyncClient):
  275. """PUT tailscale_disabled never calls tailscale_service.get_status — always succeeds."""
  276. create_resp = await async_client.post(
  277. "/api/v1/virtual-printers",
  278. json={
  279. "name": "TestTailscaleToggle",
  280. "mode": "archive",
  281. "access_code": "12345678",
  282. },
  283. )
  284. assert create_resp.status_code == 200
  285. vp_id = create_resp.json()["id"]
  286. assert create_resp.json()["tailscale_disabled"] is True
  287. with patch(
  288. "backend.app.services.virtual_printer.tailscale.tailscale_service.get_status",
  289. new=AsyncMock(side_effect=AssertionError("get_status must not be called for toggle")),
  290. ):
  291. enable_resp = await async_client.put(
  292. f"/api/v1/virtual-printers/{vp_id}",
  293. json={"tailscale_disabled": False},
  294. )
  295. disable_resp = await async_client.put(
  296. f"/api/v1/virtual-printers/{vp_id}",
  297. json={"tailscale_disabled": True},
  298. )
  299. assert enable_resp.status_code == 200
  300. assert enable_resp.json()["tailscale_disabled"] is False
  301. assert disable_resp.status_code == 200
  302. assert disable_resp.json()["tailscale_disabled"] is True
  303. class TestVirtualPrinterCaCertificateAPI:
  304. """Integration tests for GET /api/v1/virtual-printers/ca-certificate."""
  305. @pytest.mark.asyncio
  306. @pytest.mark.integration
  307. async def test_get_ca_certificate_returns_pem(self, async_client: AsyncClient):
  308. """The shared CA certificate is returned as PEM with identifying metadata."""
  309. response = await async_client.get("/api/v1/virtual-printers/ca-certificate")
  310. assert response.status_code == 200
  311. result = response.json()
  312. assert result["pem"].startswith("-----BEGIN CERTIFICATE-----")
  313. assert "PRIVATE KEY" not in result["pem"] # never expose the CA key
  314. assert len(result["fingerprint_sha256"].split(":")) == 32
  315. assert result["not_valid_after"]
  316. @pytest.mark.asyncio
  317. @pytest.mark.integration
  318. async def test_ca_certificate_route_precedes_vp_id_route(self, async_client: AsyncClient):
  319. """'ca-certificate' must not be swallowed by the /{vp_id} int route."""
  320. response = await async_client.get("/api/v1/virtual-printers/ca-certificate")
  321. # A 200 (not 422 from int-parsing "ca-certificate") proves route ordering.
  322. assert response.status_code == 200
  323. class TestVirtualPrinterDiagnosticAPI:
  324. """Integration tests for GET /api/v1/virtual-printers/{vp_id}/diagnostic."""
  325. @pytest.mark.asyncio
  326. @pytest.mark.integration
  327. async def test_diagnose_unknown_vp_returns_404(self, async_client: AsyncClient):
  328. response = await async_client.get("/api/v1/virtual-printers/999999/diagnostic")
  329. assert response.status_code == 404
  330. @pytest.mark.asyncio
  331. @pytest.mark.integration
  332. async def test_diagnose_disabled_vp_reports_problems(self, async_client: AsyncClient):
  333. """A freshly created (disabled) VP fails the 'enabled' check."""
  334. create_resp = await async_client.post(
  335. "/api/v1/virtual-printers",
  336. json={"name": "TestDiagVP", "mode": "archive", "access_code": "12345678"},
  337. )
  338. assert create_resp.status_code == 200
  339. vp_id = create_resp.json()["id"]
  340. response = await async_client.get(f"/api/v1/virtual-printers/{vp_id}/diagnostic")
  341. assert response.status_code == 200
  342. result = response.json()
  343. assert result["vp_id"] == vp_id
  344. assert result["overall"] == "problems"
  345. by_id = {c["id"]: c["status"] for c in result["checks"]}
  346. assert by_id["enabled"] == "fail"
  347. assert by_id["running"] == "skip"
  348. class TestVirtualPrinterAccessCodeInheritance:
  349. """Non-proxy VPs with a target printer must inherit the target's access
  350. code at write time.
  351. The live-mirror bridge forwards the slicer's MQTT/RTSPS auth bytes to
  352. the real printer — if the codes diverge the slicer binds the VP but the
  353. bridge fails at the second hop. The route layer force-derives the code
  354. on every create / update so a non-UI client can't introduce a divergence
  355. either.
  356. """
  357. @pytest.mark.asyncio
  358. @pytest.mark.integration
  359. async def test_create_with_target_ignores_submitted_access_code(
  360. self, async_client: AsyncClient, printer_factory, db_session
  361. ):
  362. from sqlalchemy import select
  363. from backend.app.models.virtual_printer import VirtualPrinter
  364. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  365. response = await async_client.post(
  366. "/api/v1/virtual-printers",
  367. json={
  368. "name": "QueueVP",
  369. "mode": "queue",
  370. "access_code": "WRONGAAA",
  371. "target_printer_id": target.id,
  372. },
  373. )
  374. assert response.status_code == 200
  375. vp_id = response.json()["id"]
  376. assert response.json()["access_code_set"] is True
  377. vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
  378. assert vp.access_code == "REALCODE"
  379. @pytest.mark.asyncio
  380. @pytest.mark.integration
  381. async def test_create_with_target_and_no_access_code_still_enables(
  382. self, async_client: AsyncClient, printer_factory
  383. ):
  384. """A non-proxy VP with a target set can be enabled without supplying
  385. access_code separately — the inheritance makes the explicit field
  386. redundant, and the validator now knows this."""
  387. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  388. response = await async_client.post(
  389. "/api/v1/virtual-printers",
  390. json={
  391. "name": "QueueVP",
  392. "mode": "queue",
  393. "target_printer_id": target.id,
  394. "bind_ip": "192.168.1.50",
  395. "enabled": True,
  396. },
  397. )
  398. assert response.status_code == 200
  399. assert response.json()["access_code_set"] is True
  400. @pytest.mark.asyncio
  401. @pytest.mark.integration
  402. async def test_create_without_target_still_requires_access_code_on_enable(self, async_client: AsyncClient):
  403. """The relaxation only kicks in when a target is set. A standalone
  404. non-proxy VP still needs its own access code."""
  405. response = await async_client.post(
  406. "/api/v1/virtual-printers",
  407. json={
  408. "name": "StandaloneVP",
  409. "mode": "archive",
  410. "bind_ip": "192.168.1.51",
  411. "enabled": True,
  412. },
  413. )
  414. assert response.status_code == 400
  415. assert "access code" in response.json()["detail"].lower()
  416. @pytest.mark.asyncio
  417. @pytest.mark.integration
  418. async def test_update_target_resyncs_access_code(self, async_client: AsyncClient, printer_factory, db_session):
  419. from sqlalchemy import select
  420. from backend.app.models.virtual_printer import VirtualPrinter
  421. first = await printer_factory(name="Printer A", access_code="AAAAAAAA")
  422. second = await printer_factory(name="Printer B", access_code="BBBBBBBB")
  423. create_resp = await async_client.post(
  424. "/api/v1/virtual-printers",
  425. json={
  426. "name": "MovingTarget",
  427. "mode": "queue",
  428. "target_printer_id": first.id,
  429. },
  430. )
  431. assert create_resp.status_code == 200
  432. vp_id = create_resp.json()["id"]
  433. vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
  434. assert vp.access_code == "AAAAAAAA"
  435. # Repoint to the second printer — access code should follow.
  436. update_resp = await async_client.put(
  437. f"/api/v1/virtual-printers/{vp_id}",
  438. json={"target_printer_id": second.id},
  439. )
  440. assert update_resp.status_code == 200
  441. await db_session.refresh(vp)
  442. assert vp.access_code == "BBBBBBBB"
  443. @pytest.mark.asyncio
  444. @pytest.mark.integration
  445. async def test_update_explicit_access_code_with_target_is_overridden(
  446. self, async_client: AsyncClient, printer_factory, db_session
  447. ):
  448. """An update that submits both an explicit access_code AND keeps a
  449. target_printer_id silently uses the target's code — belt-and-braces
  450. for non-UI clients that might try to set a divergent value."""
  451. from sqlalchemy import select
  452. from backend.app.models.virtual_printer import VirtualPrinter
  453. target = await printer_factory(name="Real X1C", access_code="REALCODE")
  454. create_resp = await async_client.post(
  455. "/api/v1/virtual-printers",
  456. json={
  457. "name": "BeltBraces",
  458. "mode": "queue",
  459. "target_printer_id": target.id,
  460. },
  461. )
  462. assert create_resp.status_code == 200
  463. vp_id = create_resp.json()["id"]
  464. update_resp = await async_client.put(
  465. f"/api/v1/virtual-printers/{vp_id}",
  466. json={"access_code": "FORGEDCD"},
  467. )
  468. assert update_resp.status_code == 200
  469. vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
  470. assert vp.access_code == "REALCODE"
  471. class TestVirtualPrinterSerialSurface:
  472. """Proxy-mode VPs must surface the target printer's serial in API responses.
  473. The bridge advertises the target's serial over SSDP and forwards the
  474. target's identity to the slicer; the VP-settings card should show the
  475. same serial so the user sees one consistent identity per VP. Archive /
  476. queue / review VPs keep the self-generated suffix-based serial since
  477. those modes never speak the target's identity.
  478. """
  479. @pytest.mark.asyncio
  480. @pytest.mark.integration
  481. async def test_proxy_vp_response_uses_target_printer_serial(self, async_client: AsyncClient, printer_factory):
  482. target = await printer_factory(name="Real X1C", access_code="REALCODE", serial_number="00M09A123456789")
  483. create_resp = await async_client.post(
  484. "/api/v1/virtual-printers",
  485. json={
  486. "name": "ProxyVP",
  487. "mode": "proxy",
  488. "target_printer_id": target.id,
  489. },
  490. )
  491. assert create_resp.status_code == 200
  492. vp_id = create_resp.json()["id"]
  493. assert create_resp.json()["serial"] == "00M09A123456789"
  494. get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp_id}")
  495. assert get_resp.status_code == 200
  496. assert get_resp.json()["serial"] == "00M09A123456789"
  497. list_resp = await async_client.get("/api/v1/virtual-printers")
  498. assert list_resp.status_code == 200
  499. listed = next(p for p in list_resp.json()["printers"] if p["id"] == vp_id)
  500. assert listed["serial"] == "00M09A123456789"
  501. @pytest.mark.asyncio
  502. @pytest.mark.integration
  503. async def test_non_proxy_vp_keeps_self_generated_serial(self, async_client: AsyncClient, printer_factory):
  504. # Even with a target printer set (#1429 access-code inheritance flow),
  505. # archive / queue / review VPs are NOT bridging the target's identity
  506. # to the slicer — they synthesise their own. Self-generated serial.
  507. target = await printer_factory(name="Real X1C", access_code="REALCODE", serial_number="00M09A123456789")
  508. create_resp = await async_client.post(
  509. "/api/v1/virtual-printers",
  510. json={
  511. "name": "QueueVP",
  512. "mode": "queue",
  513. "target_printer_id": target.id,
  514. },
  515. )
  516. assert create_resp.status_code == 200
  517. assert create_resp.json()["serial"] != "00M09A123456789"
  518. # The synthesised serial follows _get_serial_for_model's `<prefix><suffix>`
  519. # shape — model-specific prefix + 8-char hex suffix from `vp.serial_suffix`.
  520. assert len(create_resp.json()["serial"]) >= 8
  521. @pytest.mark.sqlite_only # a dangling reference needs foreign keys off
  522. @pytest.mark.asyncio
  523. @pytest.mark.integration
  524. async def test_proxy_vp_falls_back_to_self_generated_when_target_missing(
  525. self, async_client: AsyncClient, db_session
  526. ):
  527. # Defensive fallback: a proxy VP whose target_printer_id points at a
  528. # row that no longer exists (printer deleted mid-config, manual SQL
  529. # tweak, race) must not 500 — it returns the self-generated serial
  530. # so the card still renders and the user can fix the target.
  531. from backend.app.models.virtual_printer import VirtualPrinter
  532. vp = VirtualPrinter(name="OrphanProxy", mode="proxy", target_printer_id=99999, enabled=False)
  533. db_session.add(vp)
  534. await db_session.commit()
  535. get_resp = await async_client.get(f"/api/v1/virtual-printers/{vp.id}")
  536. assert get_resp.status_code == 200
  537. body = get_resp.json()
  538. assert body["serial"] # non-empty
  539. assert len(body["serial"]) >= 8