test_virtual_printer.py 109 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905
  1. """Unit tests for Virtual Printer services.
  2. Tests the virtual printer manager, FTP server, and SSDP server components.
  3. """
  4. import asyncio
  5. import json
  6. import zipfile
  7. from pathlib import Path
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. def _write_3mf_with_filaments(file_path: Path, filaments: list[dict], plate_index: int = 1) -> None:
  11. """Build a minimal 3MF zip with `Metadata/slice_info.config` carrying the
  12. given per-slot filament entries. Each `filaments` dict needs `id`, `type`,
  13. `color`, `used_g`. Used by the #1188 VP queue-mode tests below."""
  14. filament_xml = "".join(
  15. f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}" '
  16. f'used_g="{f["used_g"]}" tray_info_idx="{f.get("tray_info_idx", "")}"/>'
  17. for f in filaments
  18. )
  19. config = (
  20. '<?xml version="1.0" encoding="utf-8"?>'
  21. "<config>"
  22. f'<plate><metadata key="index" value="{plate_index}"/>'
  23. f"{filament_xml}"
  24. "</plate>"
  25. "</config>"
  26. )
  27. with zipfile.ZipFile(file_path, "w") as zf:
  28. zf.writestr("Metadata/slice_info.config", config)
  29. # Plate gcode is referenced for plate-id detection in the VP path —
  30. # presence is enough; contents don't matter.
  31. zf.writestr(f"Metadata/plate_{plate_index}.gcode", "; gcode\n")
  32. class TestVirtualPrinterInstance:
  33. """Tests for VirtualPrinterInstance class."""
  34. @pytest.fixture
  35. def instance(self, tmp_path):
  36. """Create a VirtualPrinterInstance with test defaults."""
  37. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  38. return VirtualPrinterInstance(
  39. vp_id=1,
  40. name="TestPrinter",
  41. mode="immediate",
  42. model="C11",
  43. access_code="12345678",
  44. serial_suffix="391800001",
  45. base_dir=tmp_path,
  46. )
  47. # ========================================================================
  48. # Tests for instance properties
  49. # ========================================================================
  50. def test_instance_stores_parameters(self, instance):
  51. """Verify constructor stores parameters correctly."""
  52. assert instance.id == 1
  53. assert instance.name == "TestPrinter"
  54. assert instance.mode == "immediate"
  55. assert instance.model == "C11"
  56. assert instance.access_code == "12345678"
  57. assert instance.serial_suffix == "391800001"
  58. def test_instance_serial_property(self, instance):
  59. """Verify serial is generated from model prefix + suffix."""
  60. # C11 = P1P, prefix = 01S00A
  61. assert instance.serial == "01S00A391800001"
  62. def test_instance_serial_x1c(self, tmp_path):
  63. """Verify X1C serial uses correct prefix."""
  64. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  65. inst = VirtualPrinterInstance(
  66. vp_id=2,
  67. name="X1C",
  68. mode="immediate",
  69. model="BL-P001",
  70. access_code="12345678",
  71. serial_suffix="391800002",
  72. base_dir=tmp_path,
  73. )
  74. assert inst.serial == "00M00A391800002"
  75. def test_instance_is_proxy_false(self, instance):
  76. """Verify is_proxy is False for non-proxy mode."""
  77. assert instance.is_proxy is False
  78. def test_instance_is_proxy_true(self, tmp_path):
  79. """Verify is_proxy is True for proxy mode."""
  80. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  81. inst = VirtualPrinterInstance(
  82. vp_id=3,
  83. name="Proxy",
  84. mode="proxy",
  85. model="C11",
  86. access_code="",
  87. serial_suffix="391800003",
  88. target_printer_ip="192.168.1.100",
  89. base_dir=tmp_path,
  90. )
  91. assert inst.is_proxy is True
  92. def test_instance_is_running_with_active_tasks(self, instance):
  93. """Verify is_running is True when tasks are active."""
  94. mock_task = MagicMock()
  95. mock_task.done.return_value = False
  96. instance._tasks = [mock_task]
  97. assert instance.is_running is True
  98. def test_instance_is_running_with_no_tasks(self, instance):
  99. """Verify is_running is False when no tasks."""
  100. assert instance.is_running is False
  101. def test_instance_creates_directories(self, instance, tmp_path):
  102. """Verify instance creates upload and cert directories."""
  103. assert (tmp_path / "uploads" / "1").exists()
  104. assert (tmp_path / "uploads" / "1" / "cache").exists()
  105. assert (tmp_path / "certs" / "1").exists()
  106. # ========================================================================
  107. # Tests for status
  108. # ========================================================================
  109. def test_get_status_returns_correct_format(self, instance):
  110. """Verify get_status returns expected fields."""
  111. instance._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")} # nosec B108
  112. mock_task = MagicMock(done=MagicMock(return_value=False))
  113. instance._tasks = [mock_task]
  114. status = instance.get_status()
  115. assert status["running"] is True
  116. assert status["pending_files"] == 1
  117. def test_get_status_not_running(self, instance):
  118. """Verify get_status when no tasks."""
  119. status = instance.get_status()
  120. assert status["running"] is False
  121. assert status["pending_files"] == 0
  122. # ========================================================================
  123. # Tests for file handling
  124. # ========================================================================
  125. @pytest.mark.asyncio
  126. async def test_on_file_received_adds_to_pending(self, instance):
  127. """Verify received file is added to pending list in review mode."""
  128. instance.mode = "review"
  129. file_path = Path("/tmp/test.3mf") # nosec B108
  130. with patch.object(instance, "_queue_file", new_callable=AsyncMock) as mock_queue:
  131. await instance.on_file_received(file_path, "192.168.1.100")
  132. assert "test.3mf" in instance._pending_files
  133. mock_queue.assert_called_once()
  134. @pytest.mark.asyncio
  135. async def test_on_file_received_archives_immediately(self, instance):
  136. """Verify file is archived in immediate mode."""
  137. file_path = Path("/tmp/test.3mf") # nosec B108
  138. with patch.object(instance, "_archive_file", new_callable=AsyncMock) as mock_archive:
  139. await instance.on_file_received(file_path, "192.168.1.100")
  140. mock_archive.assert_called_once_with(file_path, "192.168.1.100")
  141. @pytest.mark.asyncio
  142. async def test_on_file_received_signals_FINISH_to_slicer(self, instance):
  143. """Regression #1280: when a slicer's Print flow uploads to a non-proxy VP,
  144. the VP must transition gcode_state PREPARE → FINISH so the slicer's
  145. in-flight-job lock releases. Going PREPARE → IDLE wedges Orca at
  146. "Downloading...(0%)" and blocks the next dispatch with "busy with
  147. another print job".
  148. Send-flow slicers don't watch the post-upload state, so this is a
  149. no-op behavior change for them.
  150. """
  151. instance.mode = "immediate"
  152. instance._mqtt = MagicMock()
  153. instance._mqtt.set_gcode_state = MagicMock()
  154. file_path = Path("/tmp/test.3mf") # nosec B108
  155. with patch.object(instance, "_archive_file", new_callable=AsyncMock):
  156. await instance.on_file_received(file_path, "192.168.1.100")
  157. instance._mqtt.set_gcode_state.assert_called_once_with("FINISH", filename="test.3mf", prepare_percent="100")
  158. @pytest.mark.asyncio
  159. async def test_on_file_received_non_3mf_does_not_touch_state(self, instance):
  160. """Non-3MF uploads (e.g., a job's auxiliary files) must not transition
  161. the visible state — the slicer is only tracking the .3mf upload."""
  162. instance.mode = "immediate"
  163. instance._mqtt = MagicMock()
  164. instance._mqtt.set_gcode_state = MagicMock()
  165. file_path = Path("/tmp/test.gcode") # nosec B108
  166. with patch.object(instance, "_archive_file", new_callable=AsyncMock):
  167. await instance.on_file_received(file_path, "192.168.1.100")
  168. instance._mqtt.set_gcode_state.assert_not_called()
  169. @pytest.mark.asyncio
  170. async def test_archive_file_skips_non_3mf(self, instance):
  171. """Verify non-3MF files are skipped and cleaned up."""
  172. instance._session_factory = MagicMock()
  173. instance._pending_files["verify_job"] = Path("/tmp/verify_job") # nosec B108
  174. with patch("pathlib.Path.unlink"):
  175. await instance._archive_file(Path("/tmp/verify_job"), "192.168.1.100") # nosec B108
  176. assert "verify_job" not in instance._pending_files
  177. @pytest.mark.asyncio
  178. async def test_archive_file_broadcasts_archive_created(self, tmp_path):
  179. """#1282: VP immediate-mode archives must broadcast archive_created so
  180. the Archives page refreshes without a tab switch. Real-printer prints
  181. get this via main.py's MQTT print_start handler; the VP path used to
  182. skip the broadcast entirely."""
  183. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  184. mock_db = AsyncMock()
  185. mock_db.commit = AsyncMock()
  186. mock_session_factory = MagicMock()
  187. mock_session_ctx = AsyncMock()
  188. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  189. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  190. mock_session_factory.return_value = mock_session_ctx
  191. inst = VirtualPrinterInstance(
  192. vp_id=30,
  193. name="ImmediateBroadcast",
  194. mode="immediate",
  195. model="C12",
  196. access_code="12345678",
  197. serial_suffix="391800030",
  198. base_dir=tmp_path,
  199. session_factory=mock_session_factory,
  200. )
  201. file_path = tmp_path / "test.3mf"
  202. file_path.write_bytes(b"fake3mf")
  203. mock_archive = MagicMock()
  204. mock_archive.id = 99
  205. mock_archive.printer_id = None
  206. mock_archive.filename = "test.3mf"
  207. mock_archive.print_name = "test"
  208. mock_archive.status = "archived"
  209. with (
  210. patch(
  211. "backend.app.api.routes.settings.get_setting",
  212. new_callable=AsyncMock,
  213. return_value=None,
  214. ),
  215. patch(
  216. "backend.app.services.archive.ArchiveService.archive_print",
  217. new_callable=AsyncMock,
  218. return_value=mock_archive,
  219. ),
  220. patch(
  221. "backend.app.core.websocket.ws_manager.send_archive_created",
  222. new_callable=AsyncMock,
  223. ) as mock_broadcast,
  224. ):
  225. await inst._archive_file(file_path, "192.168.1.100")
  226. mock_broadcast.assert_awaited_once()
  227. payload = mock_broadcast.await_args.args[0]
  228. assert payload["id"] == 99
  229. assert payload["filename"] == "test.3mf"
  230. assert payload["status"] == "archived"
  231. # ========================================================================
  232. # Tests for auto_dispatch
  233. # ========================================================================
  234. def test_auto_dispatch_defaults_to_true(self, tmp_path):
  235. """Verify auto_dispatch defaults to True when not specified."""
  236. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  237. inst = VirtualPrinterInstance(
  238. vp_id=10,
  239. name="DefaultDispatch",
  240. mode="print_queue",
  241. model="C11",
  242. access_code="12345678",
  243. serial_suffix="391800010",
  244. base_dir=tmp_path,
  245. )
  246. assert inst.auto_dispatch is True
  247. @pytest.mark.asyncio
  248. async def test_add_to_print_queue_with_auto_dispatch_on(self, tmp_path):
  249. """Verify queue items have manual_start=False when auto_dispatch=True."""
  250. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  251. mock_db = AsyncMock()
  252. added_items = []
  253. def capture_add(item):
  254. added_items.append(item)
  255. mock_db.add = MagicMock(side_effect=capture_add)
  256. mock_db.commit = AsyncMock()
  257. mock_session_factory = MagicMock()
  258. mock_session_ctx = AsyncMock()
  259. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  260. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  261. mock_session_factory.return_value = mock_session_ctx
  262. inst = VirtualPrinterInstance(
  263. vp_id=11,
  264. name="AutoDispatchOn",
  265. mode="print_queue",
  266. model="C11",
  267. access_code="12345678",
  268. serial_suffix="391800011",
  269. auto_dispatch=True,
  270. base_dir=tmp_path,
  271. session_factory=mock_session_factory,
  272. )
  273. # Create a temp 3mf file
  274. file_path = tmp_path / "test.3mf"
  275. file_path.write_bytes(b"fake3mf")
  276. mock_archive = MagicMock()
  277. mock_archive.id = 1
  278. mock_archive.print_name = "test"
  279. with (
  280. patch(
  281. "backend.app.api.routes.settings.get_setting",
  282. new_callable=AsyncMock,
  283. return_value=None,
  284. ),
  285. patch(
  286. "backend.app.services.archive.ArchiveService.archive_print",
  287. new_callable=AsyncMock,
  288. return_value=mock_archive,
  289. ),
  290. ):
  291. await inst._add_to_print_queue(file_path, "192.168.1.100")
  292. assert len(added_items) == 1
  293. queue_item = added_items[0]
  294. assert queue_item.manual_start is False
  295. @pytest.mark.asyncio
  296. async def test_add_to_print_queue_broadcasts_archive_created(self, tmp_path):
  297. """#1282: VP queue-mode uploads must broadcast archive_created so the
  298. Archives page picks up the new entry live. Pre-fix the page only
  299. refreshed when the user manually switched tabs."""
  300. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  301. mock_db = AsyncMock()
  302. mock_db.add = MagicMock()
  303. mock_db.commit = AsyncMock()
  304. mock_session_factory = MagicMock()
  305. mock_session_ctx = AsyncMock()
  306. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  307. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  308. mock_session_factory.return_value = mock_session_ctx
  309. inst = VirtualPrinterInstance(
  310. vp_id=31,
  311. name="QueueBroadcast",
  312. mode="print_queue",
  313. model="C12",
  314. access_code="12345678",
  315. serial_suffix="391800031",
  316. auto_dispatch=True,
  317. base_dir=tmp_path,
  318. session_factory=mock_session_factory,
  319. )
  320. file_path = tmp_path / "test.3mf"
  321. file_path.write_bytes(b"fake3mf")
  322. mock_archive = MagicMock()
  323. mock_archive.id = 77
  324. mock_archive.printer_id = None
  325. mock_archive.filename = "test.3mf"
  326. mock_archive.print_name = "test"
  327. mock_archive.status = "archived"
  328. with (
  329. patch(
  330. "backend.app.api.routes.settings.get_setting",
  331. new_callable=AsyncMock,
  332. return_value=None,
  333. ),
  334. patch(
  335. "backend.app.services.archive.ArchiveService.archive_print",
  336. new_callable=AsyncMock,
  337. return_value=mock_archive,
  338. ),
  339. patch(
  340. "backend.app.core.websocket.ws_manager.send_archive_created",
  341. new_callable=AsyncMock,
  342. ) as mock_broadcast,
  343. ):
  344. await inst._add_to_print_queue(file_path, "192.168.1.100")
  345. mock_broadcast.assert_awaited_once()
  346. payload = mock_broadcast.await_args.args[0]
  347. assert payload["id"] == 77
  348. assert payload["print_name"] == "test"
  349. assert payload["status"] == "archived"
  350. @pytest.mark.asyncio
  351. async def test_add_to_print_queue_with_auto_dispatch_off(self, tmp_path):
  352. """Verify queue items have manual_start=True when auto_dispatch=False."""
  353. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  354. mock_db = AsyncMock()
  355. added_items = []
  356. def capture_add(item):
  357. added_items.append(item)
  358. mock_db.add = MagicMock(side_effect=capture_add)
  359. mock_db.commit = AsyncMock()
  360. mock_session_factory = MagicMock()
  361. mock_session_ctx = AsyncMock()
  362. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  363. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  364. mock_session_factory.return_value = mock_session_ctx
  365. inst = VirtualPrinterInstance(
  366. vp_id=12,
  367. name="AutoDispatchOff",
  368. mode="print_queue",
  369. model="C11",
  370. access_code="12345678",
  371. serial_suffix="391800012",
  372. auto_dispatch=False,
  373. base_dir=tmp_path,
  374. session_factory=mock_session_factory,
  375. )
  376. # Create a temp 3mf file
  377. file_path = tmp_path / "test.3mf"
  378. file_path.write_bytes(b"fake3mf")
  379. mock_archive = MagicMock()
  380. mock_archive.id = 1
  381. mock_archive.print_name = "test"
  382. with (
  383. patch(
  384. "backend.app.api.routes.settings.get_setting",
  385. new_callable=AsyncMock,
  386. return_value=None,
  387. ),
  388. patch(
  389. "backend.app.services.archive.ArchiveService.archive_print",
  390. new_callable=AsyncMock,
  391. return_value=mock_archive,
  392. ),
  393. ):
  394. await inst._add_to_print_queue(file_path, "192.168.1.100")
  395. assert len(added_items) == 1
  396. queue_item = added_items[0]
  397. assert queue_item.manual_start is True
  398. @pytest.mark.asyncio
  399. async def test_add_to_print_queue_uses_workflow_defaults_from_settings(self, tmp_path):
  400. """#1235: VP queue-mode constructed PrintQueueItem without specifying
  401. bed_levelling / flow_cali / vibration_cali / layer_inspect / timelapse,
  402. so SQLAlchemy applied the column-level defaults and ignored the user's
  403. workflow preferences entirely. Every print sent from the slicer to the
  404. VP came through with the OPPOSITE of what the workflow page said,
  405. forcing the user to edit each queue item by hand.
  406. """
  407. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  408. added_items = []
  409. mock_db = AsyncMock()
  410. mock_db.add = MagicMock(side_effect=added_items.append)
  411. mock_db.commit = AsyncMock()
  412. mock_session_factory = MagicMock()
  413. mock_session_ctx = AsyncMock()
  414. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  415. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  416. mock_session_factory.return_value = mock_session_ctx
  417. inst = VirtualPrinterInstance(
  418. vp_id=22,
  419. name="DefaultsTest",
  420. mode="print_queue",
  421. model="C12",
  422. access_code="12345678",
  423. serial_suffix="391800022",
  424. auto_dispatch=True,
  425. base_dir=tmp_path,
  426. session_factory=mock_session_factory,
  427. )
  428. file_path = tmp_path / "test.3mf"
  429. file_path.write_bytes(b"fake3mf")
  430. # The reporter set every workflow default to the OPPOSITE of the model's
  431. # column default. Pre-fix the column defaults won; with the fix the
  432. # settings values must flow through to the queue item exactly as stored.
  433. settings_map = {
  434. "virtual_printer_archive_name_source": None,
  435. "default_bed_levelling": "false", # model default: True
  436. "default_flow_cali": "true", # model default: False
  437. "default_vibration_cali": "false", # model default: True
  438. "default_layer_inspect": "true", # model default: False
  439. "default_timelapse": "true", # model default: False
  440. }
  441. async def fake_get_setting(_db, key):
  442. return settings_map.get(key)
  443. mock_archive = MagicMock()
  444. mock_archive.id = 1
  445. mock_archive.print_name = "test"
  446. with (
  447. patch(
  448. "backend.app.api.routes.settings.get_setting",
  449. new=fake_get_setting,
  450. ),
  451. patch(
  452. "backend.app.services.archive.ArchiveService.archive_print",
  453. new_callable=AsyncMock,
  454. return_value=mock_archive,
  455. ),
  456. ):
  457. await inst._add_to_print_queue(file_path, "192.168.1.100")
  458. assert len(added_items) == 1
  459. queue_item = added_items[0]
  460. assert queue_item.bed_levelling is False, "default_bed_levelling=false must flow through"
  461. assert queue_item.flow_cali is True, "default_flow_cali=true must flow through"
  462. assert queue_item.vibration_cali is False, "default_vibration_cali=false must flow through"
  463. assert queue_item.layer_inspect is True, "default_layer_inspect=true must flow through"
  464. assert queue_item.timelapse is True, "default_timelapse=true must flow through"
  465. @pytest.mark.asyncio
  466. async def test_add_to_print_queue_falls_back_to_schema_defaults_when_unset(self, tmp_path):
  467. """#1235 fallback: when no workflow setting is in the DB, the queue
  468. item should use the AppSettings (Pydantic) defaults — same values
  469. the user sees in the workflow page on a fresh install.
  470. """
  471. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  472. added_items = []
  473. mock_db = AsyncMock()
  474. mock_db.add = MagicMock(side_effect=added_items.append)
  475. mock_db.commit = AsyncMock()
  476. mock_session_factory = MagicMock()
  477. mock_session_ctx = AsyncMock()
  478. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  479. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  480. mock_session_factory.return_value = mock_session_ctx
  481. inst = VirtualPrinterInstance(
  482. vp_id=23,
  483. name="FreshInstallDefaults",
  484. mode="print_queue",
  485. model="C12",
  486. access_code="12345678",
  487. serial_suffix="391800023",
  488. auto_dispatch=True,
  489. base_dir=tmp_path,
  490. session_factory=mock_session_factory,
  491. )
  492. file_path = tmp_path / "test.3mf"
  493. file_path.write_bytes(b"fake3mf")
  494. mock_archive = MagicMock()
  495. mock_archive.id = 1
  496. mock_archive.print_name = "test"
  497. with (
  498. patch(
  499. "backend.app.api.routes.settings.get_setting",
  500. new_callable=AsyncMock,
  501. return_value=None, # No settings → fall back to schema defaults
  502. ),
  503. patch(
  504. "backend.app.services.archive.ArchiveService.archive_print",
  505. new_callable=AsyncMock,
  506. return_value=mock_archive,
  507. ),
  508. ):
  509. await inst._add_to_print_queue(file_path, "192.168.1.100")
  510. assert len(added_items) == 1
  511. queue_item = added_items[0]
  512. # These must match the AppSettings (Pydantic) defaults in schemas/settings.py
  513. assert queue_item.bed_levelling is True
  514. assert queue_item.flow_cali is False
  515. assert queue_item.vibration_cali is True
  516. assert queue_item.layer_inspect is False
  517. assert queue_item.timelapse is False
  518. @pytest.mark.asyncio
  519. async def test_add_to_print_queue_inherits_slicer_print_options(self, tmp_path):
  520. """#1403: VP-queue items used to fall back to `default_timelapse` even
  521. though the slicer's MQTT `project_file` command carries the user's
  522. actual choice. Capture-via-`on_print_command` flow lets the user's
  523. slicer toggle reach the queue item.
  524. Settings here have timelapse OFF; the slicer's MQTT capture has it ON.
  525. After the fix the queue item must reflect the slicer's choice.
  526. """
  527. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  528. added_items = []
  529. mock_db = AsyncMock()
  530. mock_db.add = MagicMock(side_effect=added_items.append)
  531. mock_db.commit = AsyncMock()
  532. mock_session_factory = MagicMock()
  533. mock_session_ctx = AsyncMock()
  534. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  535. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  536. mock_session_factory.return_value = mock_session_ctx
  537. inst = VirtualPrinterInstance(
  538. vp_id=24,
  539. name="SlicerInherits",
  540. mode="print_queue",
  541. model="C12",
  542. access_code="12345678",
  543. serial_suffix="391800024",
  544. auto_dispatch=True,
  545. base_dir=tmp_path,
  546. session_factory=mock_session_factory,
  547. )
  548. file_path = tmp_path / "test.3mf"
  549. file_path.write_bytes(b"fake3mf")
  550. # Pre-populate the capture as if MQTT `project_file` arrived already.
  551. # Settings (below) deliberately have timelapse OFF — only the slicer
  552. # capture should drive the resulting queue item.
  553. await inst.on_print_command(
  554. file_path.name,
  555. {
  556. "command": "project_file",
  557. "timelapse": True,
  558. "bed_leveling": False, # Note: MQTT field is single-L `bed_leveling`
  559. "flow_cali": True,
  560. "vibration_cali": False,
  561. "layer_inspect": True,
  562. },
  563. )
  564. settings_map = {
  565. "virtual_printer_archive_name_source": None,
  566. "default_bed_levelling": "true",
  567. "default_flow_cali": "false",
  568. "default_vibration_cali": "true",
  569. "default_layer_inspect": "false",
  570. "default_timelapse": "false",
  571. }
  572. async def fake_get_setting(_db, key):
  573. return settings_map.get(key)
  574. mock_archive = MagicMock()
  575. mock_archive.id = 1
  576. mock_archive.print_name = "test"
  577. with (
  578. patch(
  579. "backend.app.api.routes.settings.get_setting",
  580. new=fake_get_setting,
  581. ),
  582. patch(
  583. "backend.app.services.archive.ArchiveService.archive_print",
  584. new_callable=AsyncMock,
  585. return_value=mock_archive,
  586. ),
  587. ):
  588. await inst._add_to_print_queue(file_path, "192.168.1.100")
  589. assert len(added_items) == 1
  590. queue_item = added_items[0]
  591. assert queue_item.timelapse is True, "Slicer's timelapse=True must override settings.default_timelapse=False"
  592. assert queue_item.bed_levelling is False, "Slicer's bed_leveling=False must override default_bed_levelling=True"
  593. assert queue_item.flow_cali is True
  594. assert queue_item.vibration_cali is False
  595. assert queue_item.layer_inspect is True
  596. # Capture is consumed — no lingering state for the next print of the same name.
  597. assert file_path.name not in inst._slicer_print_options
  598. @pytest.mark.asyncio
  599. async def test_add_to_print_queue_coerces_slicer_integer_zero_one(self, tmp_path):
  600. """#1403: H-family firmwares carry calibration flags as integers
  601. (0/1) rather than booleans. The capture must coerce both shapes so
  602. H-family-sliced jobs through the VP queue work the same as P1/X1.
  603. """
  604. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  605. added_items = []
  606. mock_db = AsyncMock()
  607. mock_db.add = MagicMock(side_effect=added_items.append)
  608. mock_db.commit = AsyncMock()
  609. mock_session_factory = MagicMock()
  610. mock_session_ctx = AsyncMock()
  611. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  612. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  613. mock_session_factory.return_value = mock_session_ctx
  614. inst = VirtualPrinterInstance(
  615. vp_id=25,
  616. name="SlicerIntegers",
  617. mode="print_queue",
  618. model="C12",
  619. access_code="12345678",
  620. serial_suffix="391800025",
  621. auto_dispatch=True,
  622. base_dir=tmp_path,
  623. session_factory=mock_session_factory,
  624. )
  625. file_path = tmp_path / "test.3mf"
  626. file_path.write_bytes(b"fake3mf")
  627. await inst.on_print_command(
  628. file_path.name,
  629. {"command": "project_file", "timelapse": 1, "bed_leveling": 0, "flow_cali": 1},
  630. )
  631. mock_archive = MagicMock()
  632. mock_archive.id = 1
  633. mock_archive.print_name = "test"
  634. with (
  635. patch(
  636. "backend.app.api.routes.settings.get_setting",
  637. new_callable=AsyncMock,
  638. return_value=None,
  639. ),
  640. patch(
  641. "backend.app.services.archive.ArchiveService.archive_print",
  642. new_callable=AsyncMock,
  643. return_value=mock_archive,
  644. ),
  645. ):
  646. await inst._add_to_print_queue(file_path, "192.168.1.100")
  647. assert len(added_items) == 1
  648. queue_item = added_items[0]
  649. assert queue_item.timelapse is True, "integer 1 must coerce to True"
  650. assert queue_item.bed_levelling is False, "integer 0 must coerce to False"
  651. assert queue_item.flow_cali is True
  652. @pytest.mark.asyncio
  653. async def test_add_to_print_queue_populates_required_filament_types(self, tmp_path):
  654. """#1188: VP queue-mode used to create PrintQueueItems with no
  655. filament fields, so the scheduler fell through to model-only matching
  656. and dispatched onto whatever printer was free regardless of loaded
  657. colour. ``required_filament_types`` is populated unconditionally
  658. (cheap, helps the scheduler validate type even without
  659. ``force_color_match``) — pin that contract here."""
  660. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  661. added_items = []
  662. mock_db = AsyncMock()
  663. mock_db.add = MagicMock(side_effect=added_items.append)
  664. mock_db.commit = AsyncMock()
  665. mock_session_factory = MagicMock()
  666. mock_session_ctx = AsyncMock()
  667. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  668. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  669. mock_session_factory.return_value = mock_session_ctx
  670. inst = VirtualPrinterInstance(
  671. vp_id=21,
  672. name="Reqs",
  673. mode="print_queue",
  674. model="C12",
  675. access_code="12345678",
  676. serial_suffix="391800021",
  677. auto_dispatch=True,
  678. queue_force_color_match=False, # off → only required_filament_types
  679. base_dir=tmp_path,
  680. session_factory=mock_session_factory,
  681. )
  682. file_path = tmp_path / "multi.3mf"
  683. _write_3mf_with_filaments(
  684. file_path,
  685. [
  686. {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "12.3"},
  687. {"id": "2", "type": "PETG", "color": "#000000", "used_g": "4.5"},
  688. # used_g=0 → not actually consumed by this plate, must be ignored
  689. {"id": "3", "type": "ABS", "color": "#FF0000", "used_g": "0"},
  690. ],
  691. plate_index=1,
  692. )
  693. mock_archive = MagicMock()
  694. mock_archive.id = 1
  695. mock_archive.print_name = "multi"
  696. with (
  697. patch(
  698. "backend.app.api.routes.settings.get_setting",
  699. new_callable=AsyncMock,
  700. return_value=None,
  701. ),
  702. patch(
  703. "backend.app.services.archive.ArchiveService.archive_print",
  704. new_callable=AsyncMock,
  705. return_value=mock_archive,
  706. ),
  707. ):
  708. await inst._add_to_print_queue(file_path, "192.168.1.100")
  709. assert len(added_items) == 1
  710. queue_item = added_items[0]
  711. # Type-only fallback always populated. Sorted, deduped, no zero-use ABS.
  712. assert queue_item.required_filament_types is not None
  713. assert json.loads(queue_item.required_filament_types) == ["PETG", "PLA"]
  714. # Setting off → no force_color_match overrides leaked.
  715. assert queue_item.filament_overrides is None
  716. @pytest.mark.asyncio
  717. async def test_add_to_print_queue_force_color_match_writes_overrides(self, tmp_path):
  718. """#1188 core fix: when the per-VP ``queue_force_color_match`` toggle
  719. is on, every consumed slot lands as a ``filament_overrides`` entry
  720. with ``force_color_match: true``. This is the field the scheduler
  721. keys on (``print_scheduler.py:512``) — without it, slot-by-slot
  722. type+color matching never runs."""
  723. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  724. added_items = []
  725. mock_db = AsyncMock()
  726. mock_db.add = MagicMock(side_effect=added_items.append)
  727. mock_db.commit = AsyncMock()
  728. mock_session_factory = MagicMock()
  729. mock_session_ctx = AsyncMock()
  730. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  731. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  732. mock_session_factory.return_value = mock_session_ctx
  733. inst = VirtualPrinterInstance(
  734. vp_id=22,
  735. name="ForceColor",
  736. mode="print_queue",
  737. model="C12",
  738. access_code="12345678",
  739. serial_suffix="391800022",
  740. auto_dispatch=True,
  741. queue_force_color_match=True, # on
  742. base_dir=tmp_path,
  743. session_factory=mock_session_factory,
  744. )
  745. file_path = tmp_path / "forced.3mf"
  746. _write_3mf_with_filaments(
  747. file_path,
  748. [
  749. {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "10.0"},
  750. {"id": "2", "type": "PLA", "color": "#FF00FF", "used_g": "5.0"},
  751. ],
  752. plate_index=1,
  753. )
  754. mock_archive = MagicMock()
  755. mock_archive.id = 1
  756. mock_archive.print_name = "forced"
  757. with (
  758. patch(
  759. "backend.app.api.routes.settings.get_setting",
  760. new_callable=AsyncMock,
  761. return_value=None,
  762. ),
  763. patch(
  764. "backend.app.services.archive.ArchiveService.archive_print",
  765. new_callable=AsyncMock,
  766. return_value=mock_archive,
  767. ),
  768. ):
  769. await inst._add_to_print_queue(file_path, "192.168.1.100")
  770. assert len(added_items) == 1
  771. queue_item = added_items[0]
  772. assert queue_item.filament_overrides is not None
  773. overrides = json.loads(queue_item.filament_overrides)
  774. assert overrides == [
  775. {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "force_color_match": True},
  776. {"slot_id": 2, "type": "PLA", "color": "#FF00FF", "force_color_match": True},
  777. ]
  778. # required_filament_types still populated alongside overrides.
  779. assert json.loads(queue_item.required_filament_types) == ["PLA"]
  780. @pytest.mark.asyncio
  781. async def test_add_to_print_queue_force_color_match_skips_when_3mf_unparseable(self, tmp_path):
  782. """A malformed or fake-bytes 3MF must not crash the upload path —
  783. we just write the queue item with no filament fields and let the
  784. scheduler fall back to model-only matching (the pre-#1188 default).
  785. Regression guard for the existing fake-bytes happy-path tests."""
  786. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  787. added_items = []
  788. mock_db = AsyncMock()
  789. mock_db.add = MagicMock(side_effect=added_items.append)
  790. mock_db.commit = AsyncMock()
  791. mock_session_factory = MagicMock()
  792. mock_session_ctx = AsyncMock()
  793. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  794. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  795. mock_session_factory.return_value = mock_session_ctx
  796. inst = VirtualPrinterInstance(
  797. vp_id=23,
  798. name="Unparseable",
  799. mode="print_queue",
  800. model="C12",
  801. access_code="12345678",
  802. serial_suffix="391800023",
  803. auto_dispatch=True,
  804. queue_force_color_match=True,
  805. base_dir=tmp_path,
  806. session_factory=mock_session_factory,
  807. )
  808. file_path = tmp_path / "bad.3mf"
  809. file_path.write_bytes(b"not a real 3mf zip")
  810. mock_archive = MagicMock()
  811. mock_archive.id = 1
  812. mock_archive.print_name = "bad"
  813. with (
  814. patch(
  815. "backend.app.api.routes.settings.get_setting",
  816. new_callable=AsyncMock,
  817. return_value=None,
  818. ),
  819. patch(
  820. "backend.app.services.archive.ArchiveService.archive_print",
  821. new_callable=AsyncMock,
  822. return_value=mock_archive,
  823. ),
  824. ):
  825. await inst._add_to_print_queue(file_path, "192.168.1.100")
  826. assert len(added_items) == 1
  827. queue_item = added_items[0]
  828. # No filament data extractable → both fields stay None (graceful
  829. # fallback to model-only scheduling).
  830. assert queue_item.required_filament_types is None
  831. assert queue_item.filament_overrides is None
  832. # ========================================================================
  833. # Tests for archive_name_source setting (#1152)
  834. # ========================================================================
  835. @pytest.mark.asyncio
  836. @pytest.mark.parametrize(
  837. ("setting_value", "expected_prefer_filename"),
  838. [
  839. ("filename", True),
  840. ("metadata", False),
  841. (None, False), # Default when setting unset
  842. ("", False), # Defensive: empty string is not "filename"
  843. ],
  844. )
  845. async def test_archive_file_passes_prefer_filename_per_setting(
  846. self, tmp_path, setting_value, expected_prefer_filename
  847. ):
  848. """_archive_file reads `virtual_printer_archive_name_source` and forwards
  849. prefer_filename_for_name=True only when it equals 'filename' (#1152)."""
  850. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  851. mock_db = AsyncMock()
  852. mock_session_factory = MagicMock()
  853. mock_session_ctx = AsyncMock()
  854. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  855. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  856. mock_session_factory.return_value = mock_session_ctx
  857. inst = VirtualPrinterInstance(
  858. vp_id=20,
  859. name="NameSource",
  860. mode="immediate",
  861. model="C11",
  862. access_code="12345678",
  863. serial_suffix="391800020",
  864. base_dir=tmp_path,
  865. session_factory=mock_session_factory,
  866. )
  867. file_path = tmp_path / "user-renamed-job.3mf"
  868. file_path.write_bytes(b"fake3mf")
  869. mock_archive = MagicMock()
  870. mock_archive.id = 1
  871. mock_archive.print_name = "user-renamed-job"
  872. archive_print_mock = AsyncMock(return_value=mock_archive)
  873. with (
  874. patch(
  875. "backend.app.api.routes.settings.get_setting",
  876. new_callable=AsyncMock,
  877. return_value=setting_value,
  878. ),
  879. patch(
  880. "backend.app.services.archive.ArchiveService.archive_print",
  881. archive_print_mock,
  882. ),
  883. ):
  884. await inst._archive_file(file_path, "192.168.1.100")
  885. assert archive_print_mock.await_count == 1
  886. kwargs = archive_print_mock.await_args.kwargs
  887. assert kwargs.get("prefer_filename_for_name") is expected_prefer_filename
  888. # ========================================================================
  889. # Tests for failure-path cleanup (#audit-R2-1)
  890. # ========================================================================
  891. #
  892. # All three file handlers (_archive_file, _queue_file, _add_to_print_queue)
  893. # previously only popped _pending_files and unlinked the temp file on the
  894. # success branch. Failure paths leaked the marker (blocking same-name
  895. # retries via the FTP layer) and the temp file on disk. The cleanup must
  896. # ALWAYS run, even when archival / queue insert raises.
  897. @pytest.mark.asyncio
  898. async def test_archive_file_failure_path_pops_pending_and_unlinks(self, tmp_path):
  899. """When the archive layer raises, `_pending_files[filename]` must still
  900. be popped and the temp file must be unlinked. Otherwise the FTP layer's
  901. same-name retry guard would silently reject the slicer's next attempt
  902. and the upload_dir would accumulate ghost files."""
  903. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  904. mock_db = AsyncMock()
  905. mock_session_factory = MagicMock()
  906. mock_session_ctx = AsyncMock()
  907. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  908. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  909. mock_session_factory.return_value = mock_session_ctx
  910. inst = VirtualPrinterInstance(
  911. vp_id=40,
  912. name="ArchiveFailCleanup",
  913. mode="immediate",
  914. model="C12",
  915. access_code="12345678",
  916. serial_suffix="391800040",
  917. base_dir=tmp_path,
  918. session_factory=mock_session_factory,
  919. )
  920. file_path = tmp_path / "cleanup-archive.3mf"
  921. file_path.write_bytes(b"fake3mf")
  922. inst._pending_files[file_path.name] = file_path
  923. with (
  924. patch(
  925. "backend.app.api.routes.settings.get_setting",
  926. new_callable=AsyncMock,
  927. return_value=None,
  928. ),
  929. patch(
  930. "backend.app.services.archive.ArchiveService.archive_print",
  931. new_callable=AsyncMock,
  932. side_effect=RuntimeError("archive blew up"),
  933. ),
  934. ):
  935. await inst._archive_file(file_path, "192.168.1.100")
  936. assert file_path.name not in inst._pending_files
  937. assert not file_path.exists()
  938. @pytest.mark.asyncio
  939. async def test_queue_file_failure_path_pops_pending_and_unlinks(self, tmp_path):
  940. """Same invariant for _queue_file: a DB error during PendingUpload
  941. insert must not leak the in-flight marker or the temp file."""
  942. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  943. mock_db = AsyncMock()
  944. # Commit raises — emulating a DB connectivity error.
  945. mock_db.add = MagicMock()
  946. mock_db.commit = AsyncMock(side_effect=RuntimeError("db unreachable"))
  947. mock_session_factory = MagicMock()
  948. mock_session_ctx = AsyncMock()
  949. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  950. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  951. mock_session_factory.return_value = mock_session_ctx
  952. inst = VirtualPrinterInstance(
  953. vp_id=41,
  954. name="QueueFailCleanup",
  955. mode="review",
  956. model="C12",
  957. access_code="12345678",
  958. serial_suffix="391800041",
  959. base_dir=tmp_path,
  960. session_factory=mock_session_factory,
  961. )
  962. file_path = tmp_path / "cleanup-queue.3mf"
  963. file_path.write_bytes(b"fake3mf")
  964. inst._pending_files[file_path.name] = file_path
  965. await inst._queue_file(file_path, "192.168.1.100")
  966. assert file_path.name not in inst._pending_files
  967. assert not file_path.exists()
  968. @pytest.mark.asyncio
  969. async def test_add_to_print_queue_failure_path_pops_pending_and_unlinks(self, tmp_path):
  970. """Same invariant for _add_to_print_queue: a DB error or archive
  971. failure must not leak the in-flight marker or the temp file."""
  972. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  973. mock_db = AsyncMock()
  974. mock_db.add = MagicMock()
  975. mock_db.commit = AsyncMock()
  976. mock_db.execute = AsyncMock(side_effect=RuntimeError("queue insert blew up"))
  977. mock_session_factory = MagicMock()
  978. mock_session_ctx = AsyncMock()
  979. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  980. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  981. mock_session_factory.return_value = mock_session_ctx
  982. inst = VirtualPrinterInstance(
  983. vp_id=42,
  984. name="DispatchFailCleanup",
  985. mode="print_queue",
  986. model="C12",
  987. access_code="12345678",
  988. serial_suffix="391800042",
  989. auto_dispatch=True,
  990. base_dir=tmp_path,
  991. session_factory=mock_session_factory,
  992. )
  993. file_path = tmp_path / "cleanup-dispatch.3mf"
  994. file_path.write_bytes(b"fake3mf")
  995. inst._pending_files[file_path.name] = file_path
  996. with patch(
  997. "backend.app.api.routes.settings.get_setting",
  998. new_callable=AsyncMock,
  999. return_value=None,
  1000. ):
  1001. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1002. assert file_path.name not in inst._pending_files
  1003. assert not file_path.exists()
  1004. # ========================================================================
  1005. # Test for position=MAX+1 (audit-R2)
  1006. # ========================================================================
  1007. @pytest.mark.asyncio
  1008. async def test_add_to_print_queue_position_picks_max_plus_one(self, tmp_path):
  1009. """VP-queue items previously got hardcoded `position=1`, colliding
  1010. with existing items at position 1 and producing non-deterministic
  1011. execution order. Now the position is chosen by `MAX(position)+1`
  1012. against the target queue, matching the canonical `POST /print-queue/`
  1013. path."""
  1014. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1015. # Capture the inserted PrintQueueItem so we can assert on .position.
  1016. added_items: list = []
  1017. class _RecordingDb:
  1018. def __init__(self):
  1019. self.add = lambda item: added_items.append(item)
  1020. self.commit = AsyncMock()
  1021. async def execute(self, query): # noqa: ARG002
  1022. """Return a stub result whose `.scalar()` reports the existing
  1023. MAX(position) for the target. Returning 7 means the new item
  1024. should land at 8."""
  1025. result = MagicMock()
  1026. result.scalar = MagicMock(return_value=7)
  1027. return result
  1028. mock_db = _RecordingDb()
  1029. mock_session_factory = MagicMock()
  1030. mock_session_ctx = AsyncMock()
  1031. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1032. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1033. mock_session_factory.return_value = mock_session_ctx
  1034. inst = VirtualPrinterInstance(
  1035. vp_id=43,
  1036. name="PositionMaxPlusOne",
  1037. mode="print_queue",
  1038. model="C12",
  1039. access_code="12345678",
  1040. serial_suffix="391800043",
  1041. target_printer_id=99,
  1042. auto_dispatch=True,
  1043. base_dir=tmp_path,
  1044. session_factory=mock_session_factory,
  1045. )
  1046. file_path = tmp_path / "next-position.3mf"
  1047. file_path.write_bytes(b"fake3mf")
  1048. mock_archive = MagicMock()
  1049. mock_archive.id = 555
  1050. mock_archive.printer_id = None
  1051. mock_archive.filename = "next-position.3mf"
  1052. mock_archive.print_name = "next-position"
  1053. mock_archive.status = "archived"
  1054. with (
  1055. patch(
  1056. "backend.app.api.routes.settings.get_setting",
  1057. new_callable=AsyncMock,
  1058. return_value=None,
  1059. ),
  1060. patch(
  1061. "backend.app.services.archive.ArchiveService.archive_print",
  1062. new_callable=AsyncMock,
  1063. return_value=mock_archive,
  1064. ),
  1065. patch(
  1066. "backend.app.core.websocket.ws_manager.send_archive_created",
  1067. new_callable=AsyncMock,
  1068. ),
  1069. ):
  1070. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1071. # One queue item was added.
  1072. assert len(added_items) == 1
  1073. queue_item = added_items[0]
  1074. # Position = max(7) + 1 = 8 — NOT the legacy hardcoded 1.
  1075. assert queue_item.position == 8
  1076. class TestVirtualPrinterManager:
  1077. """Tests for VirtualPrinterManager orchestrator."""
  1078. @pytest.fixture
  1079. def manager(self):
  1080. """Create a VirtualPrinterManager instance."""
  1081. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  1082. return VirtualPrinterManager()
  1083. def test_manager_starts_empty(self, manager):
  1084. """Verify manager starts with no instances."""
  1085. assert len(manager._instances) == 0
  1086. assert manager.is_enabled is False
  1087. def test_manager_get_status_empty(self, manager):
  1088. """Verify get_status returns disabled state when no instances."""
  1089. status = manager.get_status()
  1090. assert status["enabled"] is False
  1091. assert status["running"] is False
  1092. assert status["mode"] == "immediate"
  1093. def test_manager_is_enabled_with_instance(self, manager, tmp_path):
  1094. """Verify is_enabled is True when instances exist."""
  1095. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1096. inst = VirtualPrinterInstance(
  1097. vp_id=1,
  1098. name="Test",
  1099. mode="immediate",
  1100. model="C11",
  1101. access_code="12345678",
  1102. serial_suffix="391800001",
  1103. base_dir=tmp_path,
  1104. )
  1105. manager._instances[1] = inst
  1106. assert manager.is_enabled is True
  1107. @pytest.mark.asyncio
  1108. async def test_manager_remove_instance_server(self, manager, tmp_path):
  1109. """Verify remove_instance stops and removes a server-mode instance."""
  1110. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1111. inst = VirtualPrinterInstance(
  1112. vp_id=1,
  1113. name="Test",
  1114. mode="immediate",
  1115. model="C11",
  1116. access_code="12345678",
  1117. serial_suffix="391800001",
  1118. base_dir=tmp_path,
  1119. )
  1120. inst.stop_server = AsyncMock()
  1121. manager._instances[1] = inst
  1122. await manager.remove_instance(1)
  1123. assert 1 not in manager._instances
  1124. inst.stop_server.assert_called_once()
  1125. @pytest.mark.asyncio
  1126. async def test_manager_remove_instance_proxy(self, manager, tmp_path):
  1127. """Verify remove_instance stops proxy-mode instance."""
  1128. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1129. inst = VirtualPrinterInstance(
  1130. vp_id=2,
  1131. name="Proxy",
  1132. mode="proxy",
  1133. model="C11",
  1134. access_code="",
  1135. serial_suffix="391800002",
  1136. target_printer_ip="192.168.1.100",
  1137. base_dir=tmp_path,
  1138. )
  1139. inst.stop_proxy = AsyncMock()
  1140. manager._instances[2] = inst
  1141. await manager.remove_instance(2)
  1142. assert 2 not in manager._instances
  1143. inst.stop_proxy.assert_called_once()
  1144. def test_manager_get_status_with_instance(self, manager, tmp_path):
  1145. """Verify legacy get_status returns first instance data."""
  1146. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1147. inst = VirtualPrinterInstance(
  1148. vp_id=1,
  1149. name="Bambuddy",
  1150. mode="immediate",
  1151. model="C11",
  1152. access_code="12345678",
  1153. serial_suffix="391800001",
  1154. base_dir=tmp_path,
  1155. )
  1156. mock_task = MagicMock(done=MagicMock(return_value=False))
  1157. inst._tasks = [mock_task]
  1158. inst._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")} # nosec B108
  1159. manager._instances[1] = inst
  1160. status = manager.get_status()
  1161. assert status["enabled"] is True
  1162. assert status["running"] is True
  1163. assert status["mode"] == "immediate"
  1164. assert status["name"] == "Bambuddy"
  1165. assert status["serial"] == "01S00A391800001"
  1166. assert status["model"] == "C11"
  1167. assert status["model_name"] == "P1P"
  1168. assert status["pending_files"] == 1
  1169. def test_manager_get_all_status(self, manager, tmp_path):
  1170. """Verify get_all_status returns status for all instances."""
  1171. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1172. for i in range(1, 3):
  1173. inst = VirtualPrinterInstance(
  1174. vp_id=i,
  1175. name=f"VP{i}",
  1176. mode="immediate",
  1177. model="C11",
  1178. access_code="12345678",
  1179. serial_suffix=f"39180000{i}",
  1180. base_dir=tmp_path,
  1181. )
  1182. manager._instances[i] = inst
  1183. statuses = manager.get_all_status()
  1184. assert len(statuses) == 2
  1185. assert statuses[0]["name"] == "VP1"
  1186. assert statuses[1]["name"] == "VP2"
  1187. @pytest.mark.asyncio
  1188. async def test_manager_stop_all(self, manager, tmp_path):
  1189. """Verify stop_all removes all instances."""
  1190. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1191. for i in range(1, 3):
  1192. inst = VirtualPrinterInstance(
  1193. vp_id=i,
  1194. name=f"VP{i}",
  1195. mode="immediate",
  1196. model="C11",
  1197. access_code="12345678",
  1198. serial_suffix=f"39180000{i}",
  1199. base_dir=tmp_path,
  1200. )
  1201. inst.stop_server = AsyncMock()
  1202. manager._instances[i] = inst
  1203. await manager.stop_all()
  1204. assert len(manager._instances) == 0
  1205. # ========================================================================
  1206. # Tests for sync_from_db config change detection
  1207. # ========================================================================
  1208. def _make_db_vp(self, **overrides):
  1209. """Create a mock VirtualPrinter DB object."""
  1210. defaults = {
  1211. "id": 1,
  1212. "name": "TestVP",
  1213. "enabled": True,
  1214. "mode": "immediate",
  1215. "model": "C11",
  1216. "access_code": "12345678",
  1217. "serial_suffix": "391800001",
  1218. "bind_ip": "",
  1219. "remote_interface_ip": "",
  1220. "target_printer_id": None,
  1221. "auto_dispatch": True,
  1222. "tailscale_disabled": True, # Opt-in default (#1070 UX fix)
  1223. "queue_force_color_match": False, # default — must be explicit so MagicMock truthiness doesn't trip the change detector
  1224. "position": 0,
  1225. }
  1226. defaults.update(overrides)
  1227. vp = MagicMock()
  1228. for k, v in defaults.items():
  1229. setattr(vp, k, v)
  1230. return vp
  1231. def _setup_sync_mocks(self, manager, enabled_vps, tmp_path):
  1232. """Wire up session_factory mock for sync_from_db."""
  1233. mock_result = MagicMock()
  1234. mock_result.scalars.return_value.all.return_value = enabled_vps
  1235. mock_db = AsyncMock()
  1236. mock_db.execute = AsyncMock(return_value=mock_result)
  1237. mock_db.__aenter__ = AsyncMock(return_value=mock_db)
  1238. mock_db.__aexit__ = AsyncMock(return_value=False)
  1239. manager._session_factory = MagicMock(return_value=mock_db)
  1240. manager._base_dir = tmp_path
  1241. @pytest.mark.asyncio
  1242. async def test_sync_from_db_restarts_on_mode_change(self, manager, tmp_path):
  1243. """Verify sync_from_db restarts VP when mode changes."""
  1244. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1245. inst = VirtualPrinterInstance(
  1246. vp_id=1,
  1247. name="TestVP",
  1248. mode="immediate",
  1249. model="C11",
  1250. access_code="12345678",
  1251. serial_suffix="391800001",
  1252. base_dir=tmp_path,
  1253. )
  1254. inst.stop_server = AsyncMock()
  1255. manager._instances[1] = inst
  1256. # DB says mode changed to "archive"
  1257. db_vp = self._make_db_vp(mode="archive")
  1258. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  1259. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  1260. # Patch VirtualPrinterInstance to prevent actual start
  1261. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  1262. mock_new = MagicMock()
  1263. mock_new.start_server = AsyncMock()
  1264. MockInst.return_value = mock_new
  1265. await manager.sync_from_db()
  1266. mock_remove.assert_called_once_with(1)
  1267. @pytest.mark.asyncio
  1268. async def test_sync_from_db_restarts_on_access_code_change(self, manager, tmp_path):
  1269. """Verify sync_from_db restarts VP when access_code changes."""
  1270. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1271. inst = VirtualPrinterInstance(
  1272. vp_id=1,
  1273. name="TestVP",
  1274. mode="immediate",
  1275. model="C11",
  1276. access_code="12345678",
  1277. serial_suffix="391800001",
  1278. base_dir=tmp_path,
  1279. )
  1280. inst.stop_server = AsyncMock()
  1281. manager._instances[1] = inst
  1282. db_vp = self._make_db_vp(access_code="newcode99")
  1283. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  1284. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  1285. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  1286. mock_new = MagicMock()
  1287. mock_new.start_server = AsyncMock()
  1288. MockInst.return_value = mock_new
  1289. await manager.sync_from_db()
  1290. mock_remove.assert_called_once_with(1)
  1291. @pytest.mark.asyncio
  1292. async def test_sync_from_db_skips_unchanged_instance(self, manager, tmp_path):
  1293. """Verify sync_from_db does NOT restart when config is identical."""
  1294. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1295. inst = VirtualPrinterInstance(
  1296. vp_id=1,
  1297. name="TestVP",
  1298. mode="immediate",
  1299. model="C11",
  1300. access_code="12345678",
  1301. serial_suffix="391800001",
  1302. base_dir=tmp_path,
  1303. )
  1304. manager._instances[1] = inst
  1305. # DB matches running config exactly
  1306. db_vp = self._make_db_vp()
  1307. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  1308. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  1309. await manager.sync_from_db()
  1310. mock_remove.assert_not_called()
  1311. @pytest.mark.asyncio
  1312. async def test_sync_from_db_restarts_on_bind_ip_change(self, manager, tmp_path):
  1313. """Verify sync_from_db restarts VP when bind_ip changes."""
  1314. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1315. inst = VirtualPrinterInstance(
  1316. vp_id=1,
  1317. name="TestVP",
  1318. mode="immediate",
  1319. model="C11",
  1320. access_code="12345678",
  1321. serial_suffix="391800001",
  1322. bind_ip="192.168.1.10",
  1323. base_dir=tmp_path,
  1324. )
  1325. inst.stop_server = AsyncMock()
  1326. manager._instances[1] = inst
  1327. db_vp = self._make_db_vp(bind_ip="192.168.1.20")
  1328. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  1329. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  1330. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  1331. mock_new = MagicMock()
  1332. mock_new.start_server = AsyncMock()
  1333. MockInst.return_value = mock_new
  1334. await manager.sync_from_db()
  1335. mock_remove.assert_called_once_with(1)
  1336. @pytest.mark.asyncio
  1337. async def test_sync_from_db_restarts_on_model_change(self, manager, tmp_path):
  1338. """Verify sync_from_db restarts VP when model changes."""
  1339. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1340. inst = VirtualPrinterInstance(
  1341. vp_id=1,
  1342. name="TestVP",
  1343. mode="immediate",
  1344. model="C11",
  1345. access_code="12345678",
  1346. serial_suffix="391800001",
  1347. base_dir=tmp_path,
  1348. )
  1349. inst.stop_server = AsyncMock()
  1350. manager._instances[1] = inst
  1351. db_vp = self._make_db_vp(model="C12")
  1352. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  1353. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  1354. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  1355. mock_new = MagicMock()
  1356. mock_new.start_server = AsyncMock()
  1357. MockInst.return_value = mock_new
  1358. await manager.sync_from_db()
  1359. mock_remove.assert_called_once_with(1)
  1360. @pytest.mark.asyncio
  1361. async def test_sync_from_db_does_not_restart_on_tailscale_toggle(self, manager, tmp_path):
  1362. """Flipping tailscale_disabled is purely informational — must NOT trigger a restart.
  1363. Cert provisioning was removed; the toggle only governs whether the VP card surfaces
  1364. the host's Tailscale IP/FQDN to the user. No service needs to reload, so changing
  1365. it through sync_from_db should leave any running instance untouched.
  1366. """
  1367. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1368. inst = VirtualPrinterInstance(
  1369. vp_id=1,
  1370. name="TestVP",
  1371. mode="immediate",
  1372. model="C11",
  1373. access_code="12345678",
  1374. serial_suffix="391800001",
  1375. tailscale_disabled=False,
  1376. base_dir=tmp_path,
  1377. )
  1378. inst.stop_server = AsyncMock()
  1379. manager._instances[1] = inst
  1380. db_vp = self._make_db_vp(tailscale_disabled=True)
  1381. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  1382. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  1383. await manager.sync_from_db()
  1384. mock_remove.assert_not_called()
  1385. class TestFTPSession:
  1386. """Tests for FTP session handling."""
  1387. @pytest.fixture
  1388. def mock_reader(self):
  1389. """Create a mock StreamReader."""
  1390. reader = AsyncMock()
  1391. return reader
  1392. @pytest.fixture
  1393. def mock_writer(self):
  1394. """Create a mock StreamWriter."""
  1395. writer = MagicMock()
  1396. writer.get_extra_info = MagicMock(return_value=("192.168.1.100", 12345))
  1397. writer.write = MagicMock()
  1398. writer.drain = AsyncMock()
  1399. writer.close = MagicMock()
  1400. writer.wait_closed = AsyncMock()
  1401. writer.is_closing = MagicMock(return_value=False)
  1402. return writer
  1403. @pytest.fixture
  1404. def ssl_context(self):
  1405. """Create a mock SSL context."""
  1406. return MagicMock()
  1407. @pytest.fixture
  1408. def session(self, mock_reader, mock_writer, ssl_context, tmp_path):
  1409. """Create an FTPSession instance."""
  1410. from backend.app.services.virtual_printer.ftp_server import FTPSession
  1411. return FTPSession(
  1412. reader=mock_reader,
  1413. writer=mock_writer,
  1414. upload_dir=tmp_path,
  1415. access_code="12345678",
  1416. ssl_context=ssl_context,
  1417. on_file_received=None,
  1418. )
  1419. # ========================================================================
  1420. # Tests for authentication
  1421. # ========================================================================
  1422. @pytest.mark.asyncio
  1423. async def test_user_command_accepts_bblp(self, session):
  1424. """Verify USER command accepts bblp user."""
  1425. await session.cmd_USER("bblp")
  1426. assert session.username == "bblp"
  1427. @pytest.mark.asyncio
  1428. async def test_pass_command_authenticates(self, session):
  1429. """Verify PASS command authenticates with correct code."""
  1430. session.username = "bblp"
  1431. await session.cmd_PASS("12345678")
  1432. assert session.authenticated is True
  1433. @pytest.mark.asyncio
  1434. async def test_pass_command_rejects_wrong_code(self, session):
  1435. """Verify PASS command rejects wrong access code."""
  1436. session.username = "bblp"
  1437. await session.cmd_PASS("wrongcode")
  1438. assert session.authenticated is False
  1439. # ========================================================================
  1440. # Tests for FTP commands
  1441. # ========================================================================
  1442. @pytest.mark.asyncio
  1443. async def test_syst_command(self, session):
  1444. """Verify SYST returns UNIX type."""
  1445. await session.cmd_SYST("")
  1446. session.writer.write.assert_called()
  1447. call_args = session.writer.write.call_args[0][0].decode()
  1448. assert "215" in call_args
  1449. assert "UNIX" in call_args
  1450. @pytest.mark.asyncio
  1451. async def test_pwd_command_requires_auth(self, session):
  1452. """Verify PWD requires authentication."""
  1453. session.authenticated = False
  1454. await session.cmd_PWD("")
  1455. call_args = session.writer.write.call_args[0][0].decode()
  1456. assert "530" in call_args
  1457. @pytest.mark.asyncio
  1458. async def test_pwd_command_when_authenticated(self, session):
  1459. """Verify PWD returns root directory when authenticated."""
  1460. session.authenticated = True
  1461. await session.cmd_PWD("")
  1462. call_args = session.writer.write.call_args[0][0].decode()
  1463. assert "257" in call_args
  1464. @pytest.mark.asyncio
  1465. async def test_type_command_sets_binary(self, session):
  1466. """Verify TYPE I sets binary mode."""
  1467. session.authenticated = True
  1468. await session.cmd_TYPE("I")
  1469. assert session.transfer_type == "I"
  1470. @pytest.mark.asyncio
  1471. async def test_pbsz_command(self, session):
  1472. """Verify PBSZ returns success."""
  1473. await session.cmd_PBSZ("0")
  1474. call_args = session.writer.write.call_args[0][0].decode()
  1475. assert "200" in call_args
  1476. @pytest.mark.asyncio
  1477. async def test_prot_command_accepts_p(self, session):
  1478. """Verify PROT P is accepted."""
  1479. await session.cmd_PROT("P")
  1480. call_args = session.writer.write.call_args[0][0].decode()
  1481. assert "200" in call_args
  1482. @pytest.mark.asyncio
  1483. async def test_quit_command(self, session):
  1484. """Verify QUIT sends goodbye and raises CancelledError."""
  1485. with pytest.raises(asyncio.CancelledError):
  1486. await session.cmd_QUIT("")
  1487. class TestSSDPServer:
  1488. """Tests for Virtual Printer SSDP server."""
  1489. @pytest.fixture
  1490. def ssdp_server(self):
  1491. """Create a VirtualPrinterSSDPServer instance."""
  1492. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  1493. return VirtualPrinterSSDPServer(
  1494. serial="TEST123",
  1495. name="TestPrinter",
  1496. model="BL-P001",
  1497. )
  1498. # ========================================================================
  1499. # Tests for SSDP response
  1500. # ========================================================================
  1501. def test_build_notify_message(self, ssdp_server):
  1502. """Verify NOTIFY packet contains required headers."""
  1503. # Set a known IP for testing
  1504. ssdp_server._local_ip = "192.168.1.100"
  1505. message = ssdp_server._build_notify_message()
  1506. assert b"NOTIFY" in message
  1507. assert b"DevName.bambu.com: TestPrinter" in message
  1508. assert b"USN: TEST123" in message
  1509. def test_build_response_message(self, ssdp_server):
  1510. """Verify response packet contains required headers."""
  1511. # Set a known IP for testing
  1512. ssdp_server._local_ip = "192.168.1.100"
  1513. message = ssdp_server._build_response_message()
  1514. assert b"HTTP/1.1 200 OK" in message
  1515. assert b"DevName.bambu.com: TestPrinter" in message
  1516. assert b"USN: TEST123" in message
  1517. def test_ssdp_server_uses_correct_model(self, ssdp_server):
  1518. """Verify SSDP server uses the provided model."""
  1519. ssdp_server._local_ip = "192.168.1.100"
  1520. message = ssdp_server._build_notify_message()
  1521. assert b"DevModel.bambu.com: BL-P001" in message
  1522. # ========================================================================
  1523. # Tests for advertise_ip parameter
  1524. # ========================================================================
  1525. def test_advertise_ip_sets_local_ip(self):
  1526. """Verify advertise_ip overrides auto-detection."""
  1527. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  1528. server = VirtualPrinterSSDPServer(
  1529. serial="TEST123",
  1530. name="TestPrinter",
  1531. model="BL-P001",
  1532. advertise_ip="10.0.0.50",
  1533. )
  1534. assert server._local_ip == "10.0.0.50"
  1535. def test_advertise_ip_empty_string_uses_auto_detect(self):
  1536. """Verify empty advertise_ip falls back to auto-detection."""
  1537. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  1538. server = VirtualPrinterSSDPServer(
  1539. serial="TEST123",
  1540. name="TestPrinter",
  1541. model="BL-P001",
  1542. advertise_ip="",
  1543. )
  1544. assert server._local_ip is None
  1545. def test_advertise_ip_in_notify_message(self):
  1546. """Verify NOTIFY message uses the advertise_ip."""
  1547. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  1548. server = VirtualPrinterSSDPServer(
  1549. serial="TEST123",
  1550. name="TestPrinter",
  1551. model="BL-P001",
  1552. advertise_ip="10.0.0.50",
  1553. )
  1554. message = server._build_notify_message()
  1555. assert b"Location: 10.0.0.50" in message
  1556. def test_advertise_ip_in_response_message(self):
  1557. """Verify M-SEARCH response uses the advertise_ip."""
  1558. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  1559. server = VirtualPrinterSSDPServer(
  1560. serial="TEST123",
  1561. name="TestPrinter",
  1562. model="BL-P001",
  1563. advertise_ip="10.0.0.50",
  1564. )
  1565. message = server._build_response_message()
  1566. assert b"Location: 10.0.0.50" in message
  1567. def test_default_no_advertise_ip(self):
  1568. """Verify default constructor has None local_ip (auto-detect)."""
  1569. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  1570. server = VirtualPrinterSSDPServer()
  1571. assert server._local_ip is None
  1572. class TestCertificateService:
  1573. """Tests for TLS certificate generation."""
  1574. @pytest.fixture
  1575. def cert_service(self, tmp_path):
  1576. """Create a CertificateService instance."""
  1577. from backend.app.services.virtual_printer.certificate import CertificateService
  1578. return CertificateService(cert_dir=tmp_path, serial="TEST123")
  1579. def test_generate_certificates(self, cert_service, tmp_path):
  1580. """Verify certificates are generated correctly."""
  1581. cert_path, key_path = cert_service.generate_certificates()
  1582. assert cert_path.exists()
  1583. assert key_path.exists()
  1584. # Verify certificate content
  1585. cert_content = cert_path.read_text()
  1586. assert "BEGIN CERTIFICATE" in cert_content
  1587. key_content = key_path.read_text()
  1588. assert "BEGIN" in key_content and "KEY" in key_content
  1589. def test_certificates_reused_if_exist(self, cert_service):
  1590. """Verify existing certificates are reused."""
  1591. # First generation
  1592. cert_path1, key_path1 = cert_service.generate_certificates()
  1593. mtime1 = cert_path1.stat().st_mtime
  1594. # Second call should reuse (via ensure_certificates)
  1595. cert_path2, key_path2 = cert_service.ensure_certificates()
  1596. mtime2 = cert_path2.stat().st_mtime
  1597. assert mtime1 == mtime2 # File wasn't regenerated
  1598. def test_delete_certificates(self, cert_service):
  1599. """Verify certificates can be deleted."""
  1600. cert_service.generate_certificates()
  1601. assert cert_service.cert_path.exists()
  1602. assert cert_service.key_path.exists()
  1603. cert_service.delete_certificates()
  1604. assert not cert_service.cert_path.exists()
  1605. assert not cert_service.key_path.exists()
  1606. def test_ensure_creates_if_not_exist(self, cert_service):
  1607. """Verify ensure_certificates generates if not existing."""
  1608. assert not cert_service.cert_path.exists()
  1609. cert_path, key_path = cert_service.ensure_certificates()
  1610. assert cert_path.exists()
  1611. assert key_path.exists()
  1612. class TestBindServer:
  1613. """Tests for BindServer (port 3002 bind/detect protocol)."""
  1614. @pytest.fixture
  1615. def bind_server(self):
  1616. """Create a BindServer instance."""
  1617. from backend.app.services.virtual_printer.bind_server import BindServer
  1618. return BindServer(
  1619. serial="09400A391800001",
  1620. model="O1D",
  1621. name="Bambuddy",
  1622. )
  1623. def test_build_frame(self, bind_server):
  1624. """Verify frame building produces correct format."""
  1625. payload = {"login": {"command": "detect"}}
  1626. frame = bind_server._build_frame(payload)
  1627. # Header: 0xA5A5
  1628. assert frame[:2] == b"\xa5\xa5"
  1629. # Trailer: 0xA7A7
  1630. assert frame[-2:] == b"\xa7\xa7"
  1631. # Length field is total message size (LE uint16)
  1632. import struct
  1633. total_len = struct.unpack_from("<H", frame, 2)[0]
  1634. assert total_len == len(frame)
  1635. # JSON payload is between header and trailer
  1636. import json
  1637. json_bytes = frame[4:-2]
  1638. parsed = json.loads(json_bytes)
  1639. assert parsed == payload
  1640. def test_parse_frame_valid(self, bind_server):
  1641. """Verify valid frame parsing extracts JSON correctly."""
  1642. import json
  1643. import struct
  1644. payload = {"login": {"command": "detect", "sequence_id": "20000"}}
  1645. json_bytes = json.dumps(payload, separators=(",", ":")).encode()
  1646. total_len = 4 + len(json_bytes) + 2
  1647. frame = b"\xa5\xa5" + struct.pack("<H", total_len) + json_bytes + b"\xa7\xa7"
  1648. result = bind_server._parse_frame(frame)
  1649. assert result is not None
  1650. assert result["login"]["command"] == "detect"
  1651. assert result["login"]["sequence_id"] == "20000"
  1652. def test_parse_frame_invalid_header(self, bind_server):
  1653. """Verify invalid header returns None."""
  1654. result = bind_server._parse_frame(b"\xbb\xbb\x06\x00{}\xa7\xa7")
  1655. assert result is None
  1656. def test_parse_frame_invalid_trailer(self, bind_server):
  1657. """Verify invalid trailer returns None."""
  1658. result = bind_server._parse_frame(b"\xa5\xa5\x06\x00{}\xbb\xbb")
  1659. assert result is None
  1660. def test_parse_frame_too_short(self, bind_server):
  1661. """Verify short data returns None."""
  1662. result = bind_server._parse_frame(b"\xa5\xa5\x00")
  1663. assert result is None
  1664. def test_parse_frame_invalid_json(self, bind_server):
  1665. """Verify invalid JSON returns None."""
  1666. import struct
  1667. bad_json = b"not json"
  1668. total_len = 4 + len(bad_json) + 2
  1669. frame = b"\xa5\xa5" + struct.pack("<H", total_len) + bad_json + b"\xa7\xa7"
  1670. result = bind_server._parse_frame(frame)
  1671. assert result is None
  1672. def test_build_frame_roundtrip(self, bind_server):
  1673. """Verify build_frame output can be parsed back."""
  1674. payload = {
  1675. "login": {
  1676. "bind": "free",
  1677. "command": "detect",
  1678. "connect": "lan",
  1679. "dev_cap": 1,
  1680. "id": "09400A391800001",
  1681. "model": "O1D",
  1682. "name": "Bambuddy",
  1683. "sequence_id": 3021,
  1684. "version": "01.00.00.00",
  1685. }
  1686. }
  1687. frame = bind_server._build_frame(payload)
  1688. parsed = bind_server._parse_frame(frame)
  1689. assert parsed is not None
  1690. assert parsed["login"]["id"] == "09400A391800001"
  1691. assert parsed["login"]["model"] == "O1D"
  1692. assert parsed["login"]["name"] == "Bambuddy"
  1693. assert parsed["login"]["bind"] == "free"
  1694. def test_bind_server_stores_config(self, bind_server):
  1695. """Verify bind server stores serial, model, name."""
  1696. assert bind_server.serial == "09400A391800001"
  1697. assert bind_server.model == "O1D"
  1698. assert bind_server.name == "Bambuddy"
  1699. assert bind_server.version == "01.00.00.00"
  1700. def test_bind_server_custom_version(self):
  1701. """Verify custom firmware version is stored."""
  1702. from backend.app.services.virtual_printer.bind_server import BindServer
  1703. server = BindServer(
  1704. serial="TEST123",
  1705. model="C13",
  1706. name="Test",
  1707. version="02.03.04.05",
  1708. )
  1709. assert server.version == "02.03.04.05"
  1710. def test_bind_ports_constant(self):
  1711. """Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility."""
  1712. from backend.app.services.virtual_printer.bind_server import BIND_PORTS
  1713. assert 3000 in BIND_PORTS
  1714. assert 3002 in BIND_PORTS
  1715. def test_bind_server_initializes_empty_servers_list(self, bind_server):
  1716. """Verify bind server starts with empty servers list."""
  1717. assert bind_server._servers == []
  1718. assert bind_server._running is False
  1719. class TestSlicerProxyManager:
  1720. """Tests for SlicerProxyManager (proxy mode)."""
  1721. @pytest.fixture
  1722. def proxy_manager(self, tmp_path):
  1723. """Create a SlicerProxyManager instance."""
  1724. from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
  1725. # Create dummy cert files
  1726. cert_path = tmp_path / "cert.pem"
  1727. key_path = tmp_path / "key.pem"
  1728. cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
  1729. # Split string to avoid pre-commit hook false positive on test data
  1730. key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
  1731. return SlicerProxyManager(
  1732. target_host="192.168.1.100",
  1733. cert_path=cert_path,
  1734. key_path=key_path,
  1735. )
  1736. def test_proxy_manager_initializes_ports(self, proxy_manager):
  1737. """Verify proxy manager has correct port constants."""
  1738. # FTP proxy uses privileged port 990 to match what Bambu Studio expects
  1739. assert proxy_manager.LOCAL_FTP_PORT == 990
  1740. assert proxy_manager.LOCAL_MQTT_PORT == 8883
  1741. assert proxy_manager.PRINTER_FTP_PORT == 990
  1742. assert proxy_manager.PRINTER_MQTT_PORT == 8883
  1743. assert proxy_manager.PRINTER_FILE_TRANSFER_PORT == 6000
  1744. assert proxy_manager.PRINTER_RTSP_PORT == 322
  1745. # Auxiliary ports: undocumented proprietary ports for A1/P1S etc.
  1746. assert proxy_manager.PRINTER_AUX_PORTS == [2024, 2025, 2026]
  1747. # Bind ports: both 3000 and 3002 for slicer compatibility
  1748. assert proxy_manager.PRINTER_BIND_PORTS == [3000, 3002]
  1749. # FTP data port range for transparent EPSV proxying
  1750. assert proxy_manager.FTP_DATA_PORT_MIN == 50000
  1751. assert proxy_manager.FTP_DATA_PORT_MAX == 50100
  1752. def test_proxy_manager_stores_target_host(self, proxy_manager):
  1753. """Verify proxy manager stores target host."""
  1754. assert proxy_manager.target_host == "192.168.1.100"
  1755. def test_get_status_before_start(self, proxy_manager):
  1756. """Verify get_status returns zeros before start."""
  1757. status = proxy_manager.get_status()
  1758. assert status["running"] is False
  1759. assert status["ftp_connections"] == 0
  1760. assert status["mqtt_connections"] == 0
  1761. @pytest.mark.asyncio
  1762. async def test_proxy_start_creates_transparent_proxies(self, tmp_path):
  1763. """Verify start() uses TCPProxy for FTP/FileTransfer/RTSP and TLSProxy only for MQTT.
  1764. The transparent proxy architecture preserves end-to-end TLS between
  1765. slicer and printer for all protocols except MQTT, which must be
  1766. TLS-terminated to rewrite the printer's IP in MQTT payloads.
  1767. """
  1768. from unittest.mock import AsyncMock, patch
  1769. from backend.app.services.virtual_printer.tcp_proxy import (
  1770. SlicerProxyManager,
  1771. TCPProxy,
  1772. TLSProxy,
  1773. )
  1774. cert_path = tmp_path / "cert.pem"
  1775. key_path = tmp_path / "key.pem"
  1776. cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
  1777. key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
  1778. mgr = SlicerProxyManager(
  1779. target_host="192.168.1.100",
  1780. cert_path=cert_path,
  1781. key_path=key_path,
  1782. bind_address="10.0.0.1",
  1783. )
  1784. # Mock asyncio.create_task and asyncio.gather to prevent actual server start
  1785. with (
  1786. patch("asyncio.create_task") as mock_create_task,
  1787. patch("asyncio.gather", new_callable=AsyncMock),
  1788. patch.object(SlicerProxyManager, "_log_activity"),
  1789. ):
  1790. mock_create_task.return_value = MagicMock()
  1791. # start() will create proxies then try to gather tasks — we just
  1792. # need to verify the proxy types after creation.
  1793. # Trigger start but let gather return immediately.
  1794. await mgr.start()
  1795. # FTP, FileTransfer, RTSP should be TCPProxy (transparent)
  1796. assert isinstance(mgr._ftp_proxy, TCPProxy), "FTP should be TCPProxy (transparent)"
  1797. assert isinstance(mgr._file_transfer_proxy, TCPProxy), "FileTransfer should be TCPProxy"
  1798. assert isinstance(mgr._rtsp_proxy, TCPProxy), "RTSP should be TCPProxy"
  1799. # MQTT should be TLSProxy (TLS-terminated for IP rewriting)
  1800. assert isinstance(mgr._mqtt_proxy, TLSProxy), "MQTT should be TLSProxy (TLS-terminated)"
  1801. # Auxiliary ports (2024-2026) should be TCPProxy (transparent)
  1802. assert len(mgr._aux_proxies) == 3, "Should have 3 aux port proxies"
  1803. for ap in mgr._aux_proxies:
  1804. assert isinstance(ap, TCPProxy), "Aux proxies should be TCPProxy"
  1805. assert mgr._aux_proxies[0].listen_port == 2024
  1806. assert mgr._aux_proxies[0].target_port == 2024
  1807. assert mgr._aux_proxies[2].listen_port == 2026
  1808. # FTP data ports should be pre-created as TCPProxy instances
  1809. assert len(mgr._ftp_data_proxies) == 101 # 50000-50100 inclusive
  1810. for dp in mgr._ftp_data_proxies:
  1811. assert isinstance(dp, TCPProxy), "FTP data proxies should be TCPProxy"
  1812. # Verify FTP data proxies target the same port on the printer
  1813. first_dp = mgr._ftp_data_proxies[0]
  1814. assert first_dp.listen_port == 50000
  1815. assert first_dp.target_port == 50000
  1816. assert first_dp.target_host == "192.168.1.100"
  1817. last_dp = mgr._ftp_data_proxies[-1]
  1818. assert last_dp.listen_port == 50100
  1819. assert last_dp.target_port == 50100
  1820. def test_proxy_manager_mqtt_has_ip_rewriting(self, tmp_path):
  1821. """Verify MQTT proxy is configured with IP rewriting when bind_address is set."""
  1822. from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
  1823. cert_path = tmp_path / "cert.pem"
  1824. key_path = tmp_path / "key.pem"
  1825. cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
  1826. key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
  1827. mgr = SlicerProxyManager(
  1828. target_host="192.168.1.100",
  1829. cert_path=cert_path,
  1830. key_path=key_path,
  1831. bind_address="10.0.0.1",
  1832. )
  1833. # Before start, proxies are None — verify constructor stores rewrite config
  1834. assert mgr.bind_address == "10.0.0.1"
  1835. assert mgr.target_host == "192.168.1.100"
  1836. class TestSSDPProxy:
  1837. """Tests for SSDPProxy (cross-network SSDP relay)."""
  1838. @pytest.fixture
  1839. def ssdp_proxy(self):
  1840. """Create an SSDPProxy instance."""
  1841. from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
  1842. return SSDPProxy(
  1843. local_interface_ip="192.168.1.100",
  1844. remote_interface_ip="10.0.0.100",
  1845. target_printer_ip="192.168.1.50",
  1846. )
  1847. def test_ssdp_proxy_stores_interface_ips(self, ssdp_proxy):
  1848. """Verify SSDPProxy stores interface IPs correctly."""
  1849. assert ssdp_proxy.local_interface_ip == "192.168.1.100"
  1850. assert ssdp_proxy.remote_interface_ip == "10.0.0.100"
  1851. assert ssdp_proxy.target_printer_ip == "192.168.1.50"
  1852. def test_rewrite_ssdp_location(self, ssdp_proxy):
  1853. """Verify SSDP Location header is rewritten to remote interface IP."""
  1854. original_packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: TestPrinter\r\n\r\n"
  1855. rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
  1856. # Location should be changed to remote interface IP
  1857. assert b"Location: 10.0.0.100" in rewritten
  1858. assert b"Location: 192.168.1.50" not in rewritten
  1859. # Other headers should be preserved
  1860. assert b"DevName.bambu.com: TestPrinter" in rewritten
  1861. def test_rewrite_ssdp_location_case_insensitive(self, ssdp_proxy):
  1862. """Verify SSDP Location rewrite is case insensitive."""
  1863. original_packet = b"NOTIFY * HTTP/1.1\r\nlocation: 192.168.1.50\r\n\r\n"
  1864. rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
  1865. assert b"10.0.0.100" in rewritten
  1866. def test_rewrite_ssdp_location_no_match(self, ssdp_proxy):
  1867. """Verify packet without Location header is returned unchanged."""
  1868. original_packet = b"NOTIFY * HTTP/1.1\r\nDevName.bambu.com: Test\r\n\r\n"
  1869. rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
  1870. # No Location header, but _rewrite_ssdp logs a warning and returns as-is
  1871. assert b"DevName.bambu.com: Test" in rewritten
  1872. def test_parse_ssdp_message(self, ssdp_proxy):
  1873. """Verify SSDP message parsing extracts headers."""
  1874. packet = (
  1875. b"NOTIFY * HTTP/1.1\r\n"
  1876. b"Location: 192.168.1.50\r\n"
  1877. b"DevName.bambu.com: TestPrinter\r\n"
  1878. b"DevModel.bambu.com: BL-P001\r\n"
  1879. b"\r\n"
  1880. )
  1881. headers = ssdp_proxy._parse_ssdp_message(packet)
  1882. assert headers["location"] == "192.168.1.50"
  1883. assert headers["devname.bambu.com"] == "TestPrinter"
  1884. assert headers["devmodel.bambu.com"] == "BL-P001"
  1885. class TestVirtualPrinterManagerDirectories:
  1886. """Tests for VirtualPrinterManager directory management."""
  1887. def test_ensure_base_directories_creates_subdirs(self, tmp_path):
  1888. """Verify _ensure_base_directories creates required base directories."""
  1889. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  1890. manager = VirtualPrinterManager()
  1891. manager._base_dir = tmp_path / "virtual_printer"
  1892. manager._ensure_base_directories()
  1893. assert (tmp_path / "virtual_printer").exists()
  1894. assert (tmp_path / "virtual_printer" / "uploads").exists()
  1895. assert (tmp_path / "virtual_printer" / "certs").exists()
  1896. def test_ensure_base_directories_handles_permission_error(self, tmp_path, caplog):
  1897. """Verify _ensure_base_directories logs error on permission failure."""
  1898. import logging
  1899. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  1900. manager = VirtualPrinterManager()
  1901. vp_dir = tmp_path / "virtual_printer"
  1902. manager._base_dir = vp_dir
  1903. original_mkdir = type(vp_dir).mkdir
  1904. def mock_mkdir(self, *args, **kwargs):
  1905. if "virtual_printer" in str(self):
  1906. raise PermissionError("Permission denied")
  1907. return original_mkdir(self, *args, **kwargs)
  1908. with caplog.at_level(logging.ERROR), patch.object(type(vp_dir), "mkdir", mock_mkdir):
  1909. manager._ensure_base_directories()
  1910. assert "Permission denied" in caplog.text
  1911. def test_instance_creates_per_vp_directories(self, tmp_path):
  1912. """Verify VirtualPrinterInstance creates per-VP upload and cert dirs."""
  1913. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1914. VirtualPrinterInstance(
  1915. vp_id=42,
  1916. name="Test",
  1917. mode="immediate",
  1918. model="C11",
  1919. access_code="12345678",
  1920. serial_suffix="391800042",
  1921. base_dir=tmp_path,
  1922. )
  1923. assert (tmp_path / "uploads" / "42").exists()
  1924. assert (tmp_path / "uploads" / "42" / "cache").exists()
  1925. assert (tmp_path / "certs" / "42").exists()
  1926. class TestVirtualPrinterInstanceProxyMode:
  1927. """Tests for VirtualPrinterInstance proxy mode."""
  1928. @pytest.fixture
  1929. def proxy_instance(self, tmp_path):
  1930. """Create a proxy-mode VirtualPrinterInstance."""
  1931. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1932. return VirtualPrinterInstance(
  1933. vp_id=10,
  1934. name="ProxyTest",
  1935. mode="proxy",
  1936. model="C11",
  1937. access_code="",
  1938. serial_suffix="391800010",
  1939. target_printer_ip="192.168.1.100",
  1940. target_printer_serial="01P00A000000001",
  1941. base_dir=tmp_path,
  1942. )
  1943. def test_proxy_instance_properties(self, proxy_instance):
  1944. """Verify proxy instance stores config correctly."""
  1945. assert proxy_instance.is_proxy is True
  1946. assert proxy_instance.mode == "proxy"
  1947. assert proxy_instance.target_printer_ip == "192.168.1.100"
  1948. assert proxy_instance.target_printer_serial == "01P00A000000001"
  1949. def test_proxy_instance_does_not_require_access_code(self, proxy_instance):
  1950. """Verify proxy mode can have empty access code."""
  1951. assert proxy_instance.access_code == ""
  1952. def test_get_status_proxy_includes_proxy_fields(self, proxy_instance):
  1953. """Verify get_status includes proxy fields when proxy is active."""
  1954. mock_proxy = MagicMock()
  1955. mock_proxy.get_status.return_value = {
  1956. "running": True,
  1957. "ftp_port": 990,
  1958. "mqtt_port": 8883,
  1959. "ftp_connections": 1,
  1960. "mqtt_connections": 2,
  1961. "target_host": "192.168.1.100",
  1962. }
  1963. proxy_instance._proxy = mock_proxy
  1964. status = proxy_instance.get_status()
  1965. assert "proxy" in status
  1966. assert status["proxy"]["ftp_port"] == 990
  1967. assert status["proxy"]["mqtt_connections"] == 2
  1968. def test_proxy_instance_stores_remote_interface(self, tmp_path):
  1969. """Verify proxy instance stores remote_interface_ip."""
  1970. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1971. inst = VirtualPrinterInstance(
  1972. vp_id=11,
  1973. name="Proxy2",
  1974. mode="proxy",
  1975. model="C11",
  1976. access_code="",
  1977. serial_suffix="391800011",
  1978. target_printer_ip="192.168.1.100",
  1979. remote_interface_ip="10.0.0.50",
  1980. base_dir=tmp_path,
  1981. )
  1982. assert inst.remote_interface_ip == "10.0.0.50"
  1983. class TestVirtualPrinterInstanceIPOverride:
  1984. """Tests for remote_interface_ip and bind_ip on VirtualPrinterInstance."""
  1985. @pytest.fixture
  1986. def instance_with_remote_ip(self, tmp_path):
  1987. """Create an instance with remote_interface_ip set."""
  1988. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1989. return VirtualPrinterInstance(
  1990. vp_id=20,
  1991. name="IPTest",
  1992. mode="immediate",
  1993. model="BL-P001",
  1994. access_code="12345678",
  1995. serial_suffix="391800020",
  1996. bind_ip="192.168.1.50",
  1997. remote_interface_ip="10.0.0.50",
  1998. base_dir=tmp_path,
  1999. )
  2000. def test_instance_stores_bind_ip(self, instance_with_remote_ip):
  2001. """Verify bind_ip is stored."""
  2002. assert instance_with_remote_ip.bind_ip == "192.168.1.50"
  2003. def test_instance_stores_remote_interface_ip(self, instance_with_remote_ip):
  2004. """Verify remote_interface_ip is stored."""
  2005. assert instance_with_remote_ip.remote_interface_ip == "10.0.0.50"
  2006. def test_generate_certificates_includes_remote_and_bind_ip(self, instance_with_remote_ip):
  2007. """Verify generate_certificates passes remote_interface_ip and bind_ip as SANs."""
  2008. with (
  2009. patch.object(instance_with_remote_ip._cert_service, "delete_printer_certificate"),
  2010. patch.object(
  2011. instance_with_remote_ip._cert_service,
  2012. "generate_certificates",
  2013. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  2014. ) as mock_gen,
  2015. ):
  2016. instance_with_remote_ip.generate_certificates()
  2017. mock_gen.assert_called_once_with(additional_ips=["10.0.0.50", "192.168.1.50"])
  2018. def test_generate_certificates_no_remote_ip(self, tmp_path):
  2019. """Verify generate_certificates passes only bind_ip when no remote_interface_ip."""
  2020. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2021. inst = VirtualPrinterInstance(
  2022. vp_id=21,
  2023. name="NoRemote",
  2024. mode="immediate",
  2025. model="BL-P001",
  2026. access_code="12345678",
  2027. serial_suffix="391800021",
  2028. bind_ip="192.168.1.50",
  2029. base_dir=tmp_path,
  2030. )
  2031. with (
  2032. patch.object(inst._cert_service, "delete_printer_certificate"),
  2033. patch.object(
  2034. inst._cert_service,
  2035. "generate_certificates",
  2036. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  2037. ) as mock_gen,
  2038. ):
  2039. inst.generate_certificates()
  2040. mock_gen.assert_called_once_with(additional_ips=["192.168.1.50"])
  2041. def test_generate_certificates_no_ips(self, tmp_path):
  2042. """Verify generate_certificates passes None when no IPs configured."""
  2043. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2044. inst = VirtualPrinterInstance(
  2045. vp_id=22,
  2046. name="NoIPs",
  2047. mode="immediate",
  2048. model="BL-P001",
  2049. access_code="12345678",
  2050. serial_suffix="391800022",
  2051. base_dir=tmp_path,
  2052. )
  2053. with (
  2054. patch.object(inst._cert_service, "delete_printer_certificate"),
  2055. patch.object(
  2056. inst._cert_service,
  2057. "generate_certificates",
  2058. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  2059. ) as mock_gen,
  2060. ):
  2061. inst.generate_certificates()
  2062. mock_gen.assert_called_once_with(additional_ips=None)
  2063. class TestBindServer:
  2064. """Tests for the BindServer (port 3002 bind/detect protocol)."""
  2065. @pytest.fixture
  2066. def bind_server(self):
  2067. """Create a BindServer instance."""
  2068. from backend.app.services.virtual_printer.bind_server import BindServer
  2069. return BindServer(
  2070. serial="01S00C000000001",
  2071. model="BL-P001",
  2072. name="Bambuddy",
  2073. )
  2074. def test_build_frame(self, bind_server):
  2075. """Verify frame format: 0xA5A5 + len(u16le) + JSON + 0xA7A7."""
  2076. payload = {"login": {"command": "detect"}}
  2077. frame = bind_server._build_frame(payload)
  2078. assert frame[:2] == b"\xa5\xa5"
  2079. assert frame[-2:] == b"\xa7\xa7"
  2080. # Length field is total message size
  2081. import struct
  2082. total_len = struct.unpack_from("<H", frame, 2)[0]
  2083. assert total_len == len(frame)
  2084. # JSON payload is between header and trailer
  2085. import json
  2086. json_bytes = frame[4:-2]
  2087. parsed = json.loads(json_bytes)
  2088. assert parsed == payload
  2089. def test_parse_frame_valid(self, bind_server):
  2090. """Verify valid frame parsing."""
  2091. frame = bind_server._build_frame({"login": {"command": "detect", "sequence_id": "20000"}})
  2092. result = bind_server._parse_frame(frame)
  2093. assert result is not None
  2094. assert result["login"]["command"] == "detect"
  2095. assert result["login"]["sequence_id"] == "20000"
  2096. def test_parse_frame_invalid_header(self, bind_server):
  2097. """Verify invalid header returns None."""
  2098. frame = b"\xb5\xb5\x10\x00" + b'{"login":{}}' + b"\xa7\xa7"
  2099. assert bind_server._parse_frame(frame) is None
  2100. def test_parse_frame_invalid_trailer(self, bind_server):
  2101. """Verify invalid trailer returns None."""
  2102. frame = b"\xa5\xa5\x10\x00" + b'{"login":{}}' + b"\xb7\xb7"
  2103. assert bind_server._parse_frame(frame) is None
  2104. def test_parse_frame_too_short(self, bind_server):
  2105. """Verify short data returns None."""
  2106. assert bind_server._parse_frame(b"\xa5\xa5\x00") is None
  2107. assert bind_server._parse_frame(b"") is None
  2108. def test_parse_frame_invalid_json(self, bind_server):
  2109. """Verify invalid JSON returns None."""
  2110. import struct
  2111. bad_json = b"not json"
  2112. total_len = 4 + len(bad_json) + 2
  2113. frame = b"\xa5\xa5" + struct.pack("<H", total_len) + bad_json + b"\xa7\xa7"
  2114. assert bind_server._parse_frame(frame) is None
  2115. def test_build_frame_roundtrip(self, bind_server):
  2116. """Verify build then parse roundtrip."""
  2117. original = {"login": {"bind": "free", "command": "detect", "id": "01S00C000000001"}}
  2118. frame = bind_server._build_frame(original)
  2119. parsed = bind_server._parse_frame(frame)
  2120. assert parsed == original
  2121. def test_bind_server_stores_config(self, bind_server):
  2122. """Verify config is stored correctly."""
  2123. assert bind_server.serial == "01S00C000000001"
  2124. assert bind_server.model == "BL-P001"
  2125. assert bind_server.name == "Bambuddy"
  2126. assert bind_server.version == "01.00.00.00"
  2127. def test_bind_server_custom_version(self):
  2128. """Verify custom firmware version is stored."""
  2129. from backend.app.services.virtual_printer.bind_server import BindServer
  2130. server = BindServer(
  2131. serial="01S00C000000001",
  2132. model="BL-P001",
  2133. name="Bambuddy",
  2134. version="01.09.00.10",
  2135. )
  2136. assert server.version == "01.09.00.10"
  2137. def test_bind_ports_includes_both(self):
  2138. """Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility."""
  2139. from backend.app.services.virtual_printer.bind_server import BIND_PORTS
  2140. assert 3000 in BIND_PORTS
  2141. assert 3002 in BIND_PORTS
  2142. def test_bind_server_initializes_empty_servers_list(self, bind_server):
  2143. """Verify bind server starts with empty servers list."""
  2144. assert bind_server._servers == []
  2145. assert bind_server._running is False
  2146. @pytest.mark.asyncio
  2147. async def test_start_server_creates_bind_server(self, tmp_path):
  2148. """Verify start_server creates BindServer with correct params."""
  2149. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2150. inst = VirtualPrinterInstance(
  2151. vp_id=99,
  2152. name="Bambuddy",
  2153. mode="immediate",
  2154. model="BL-P001",
  2155. access_code="12345678",
  2156. serial_suffix="391800099",
  2157. bind_ip="192.168.1.50",
  2158. base_dir=tmp_path,
  2159. )
  2160. # Each mocked child service exposes a real asyncio.Event for the
  2161. # readiness barrier added in start_server (set on instantiation so
  2162. # the barrier returns immediately in tests).
  2163. ready_event = asyncio.Event()
  2164. ready_event.set()
  2165. def with_ready(*_args, **_kwargs):
  2166. child = MagicMock()
  2167. child.ready = ready_event
  2168. return child
  2169. with (
  2170. patch(
  2171. "backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer",
  2172. side_effect=with_ready,
  2173. ),
  2174. patch(
  2175. "backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer",
  2176. side_effect=with_ready,
  2177. ),
  2178. patch(
  2179. "backend.app.services.virtual_printer.manager.SimpleMQTTServer",
  2180. side_effect=with_ready,
  2181. ),
  2182. patch(
  2183. "backend.app.services.virtual_printer.manager.BindServer",
  2184. side_effect=with_ready,
  2185. ) as mock_bind_cls,
  2186. patch.object(inst._cert_service, "delete_printer_certificate"),
  2187. patch.object(
  2188. inst._cert_service,
  2189. "generate_certificates",
  2190. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  2191. ),
  2192. ):
  2193. await inst.start_server()
  2194. mock_bind_cls.assert_called_once_with(
  2195. serial=inst.serial,
  2196. model="BL-P001",
  2197. name="Bambuddy",
  2198. bind_address="192.168.1.50",
  2199. cert_path=Path("/tmp/cert.pem"), # nosec B108
  2200. key_path=Path("/tmp/key.pem"), # nosec B108
  2201. )
  2202. class TestResolveModelCodes:
  2203. """Tests for model code resolution (display name → SSDP code)."""
  2204. def test_display_name_to_model_code_maps_all_models(self):
  2205. """Verify reverse mapping covers all VIRTUAL_PRINTER_MODELS entries."""
  2206. from backend.app.services.virtual_printer.manager import DISPLAY_NAME_TO_MODEL_CODE, VIRTUAL_PRINTER_MODELS
  2207. for _code, display_name in VIRTUAL_PRINTER_MODELS.items():
  2208. assert display_name in DISPLAY_NAME_TO_MODEL_CODE
  2209. # For non-duplicate display names, should map back to a valid code
  2210. assert DISPLAY_NAME_TO_MODEL_CODE[display_name] in VIRTUAL_PRINTER_MODELS
  2211. def test_resolve_printer_model_with_ssdp_code(self):
  2212. """SSDP codes pass through unchanged."""
  2213. from backend.app.api.routes.virtual_printers import _resolve_printer_model
  2214. assert _resolve_printer_model("BL-P001") == "BL-P001"
  2215. assert _resolve_printer_model("O1D") == "O1D"
  2216. assert _resolve_printer_model("N2S") == "N2S"
  2217. def test_resolve_printer_model_with_display_name(self):
  2218. """Display names resolve to SSDP codes."""
  2219. from backend.app.api.routes.virtual_printers import _resolve_printer_model
  2220. assert _resolve_printer_model("X1C") == "BL-P001"
  2221. assert _resolve_printer_model("H2D") == "O1D"
  2222. assert _resolve_printer_model("A1") == "N2S"
  2223. assert _resolve_printer_model("P1S") == "C12"
  2224. def test_resolve_printer_model_with_none_or_unknown(self):
  2225. """None and unknown values return None."""
  2226. from backend.app.api.routes.virtual_printers import _resolve_printer_model
  2227. assert _resolve_printer_model(None) is None
  2228. assert _resolve_printer_model("UnknownModel") is None
  2229. class TestMqttIpRewrite:
  2230. """Tests for TLSProxy._rewrite_mqtt_ip() MQTT packet IP rewriting."""
  2231. @staticmethod
  2232. def _build_mqtt_publish(topic: str, payload: bytes) -> bytes:
  2233. """Build a minimal MQTT PUBLISH packet."""
  2234. # PUBLISH fixed header: type 3, no flags
  2235. topic_bytes = topic.encode("utf-8")
  2236. # Variable header: topic length (2 bytes) + topic
  2237. var_header = len(topic_bytes).to_bytes(2, "big") + topic_bytes
  2238. body = var_header + payload
  2239. # Encode remaining length
  2240. remaining = len(body)
  2241. header = bytearray([0x30]) # PUBLISH, QoS 0
  2242. while True:
  2243. encoded_byte = remaining % 128
  2244. remaining //= 128
  2245. if remaining > 0:
  2246. encoded_byte |= 0x80
  2247. header.append(encoded_byte)
  2248. if remaining == 0:
  2249. break
  2250. return bytes(header) + body
  2251. @staticmethod
  2252. def _build_mqtt_pingreq() -> bytes:
  2253. """Build an MQTT PINGREQ packet (2 bytes, no payload)."""
  2254. return b"\xc0\x00"
  2255. def test_rewrite_ip_in_publish(self):
  2256. """IP string in PUBLISH payload is rewritten."""
  2257. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  2258. payload = b'{"rtsp_url":"rtsps://192.168.1.100:322/live"}'
  2259. packet = self._build_mqtt_publish("device/status", payload)
  2260. result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray())
  2261. assert b"10.0.0.1" in result
  2262. assert b"192.168.1.100" not in result
  2263. def test_no_rewrite_when_ip_absent(self):
  2264. """Packets without the target IP are passed through unchanged."""
  2265. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  2266. payload = b'{"status":"idle"}'
  2267. packet = self._build_mqtt_publish("device/status", payload)
  2268. result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray())
  2269. assert result == packet
  2270. def test_non_publish_packets_unchanged(self):
  2271. """Non-PUBLISH packets (e.g. PINGREQ) are never rewritten."""
  2272. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  2273. pingreq = self._build_mqtt_pingreq()
  2274. result, buf = TLSProxy._rewrite_mqtt_ip(pingreq, b"192.168.1.100", b"10.0.0.1", bytearray())
  2275. assert result == pingreq
  2276. def test_rewrite_preserves_packet_framing(self):
  2277. """Rewritten packet has valid MQTT remaining length."""
  2278. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  2279. # Use IPs of different lengths to test length re-encoding
  2280. old_ip = b"192.168.255.133" # 15 bytes
  2281. new_ip = b"10.0.0.1" # 8 bytes
  2282. payload = b'{"ip":"192.168.255.133"}'
  2283. packet = self._build_mqtt_publish("device/status", payload)
  2284. result, buf = TLSProxy._rewrite_mqtt_ip(packet, old_ip, new_ip, bytearray())
  2285. # Parse the result to verify framing
  2286. assert result[0] == 0x30 # PUBLISH header byte
  2287. # Decode remaining length
  2288. pos = 1
  2289. remaining = 0
  2290. multiplier = 1
  2291. while True:
  2292. b = result[pos]
  2293. pos += 1
  2294. remaining += (b & 0x7F) * multiplier
  2295. multiplier *= 128
  2296. if (b & 0x80) == 0:
  2297. break
  2298. # Remaining length should match actual data
  2299. assert pos + remaining == len(result)
  2300. assert new_ip in result
  2301. def test_incomplete_packet_buffered(self):
  2302. """Incomplete packet at end of chunk is buffered for next call."""
  2303. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  2304. payload = b'{"ip":"192.168.1.100"}'
  2305. packet = self._build_mqtt_publish("device/status", payload)
  2306. # Split packet in the middle
  2307. half = len(packet) // 2
  2308. chunk1 = packet[:half]
  2309. chunk2 = packet[half:]
  2310. result1, buf = TLSProxy._rewrite_mqtt_ip(chunk1, b"192.168.1.100", b"10.0.0.1", bytearray())
  2311. # First chunk should be buffered (incomplete packet)
  2312. assert len(buf) > 0
  2313. result2, buf = TLSProxy._rewrite_mqtt_ip(chunk2, b"192.168.1.100", b"10.0.0.1", buf)
  2314. # Second chunk completes the packet, IP should be rewritten
  2315. combined = result1 + result2
  2316. assert b"10.0.0.1" in combined
  2317. assert b"192.168.1.100" not in combined
  2318. def test_multiple_packets_in_one_chunk(self):
  2319. """Multiple MQTT packets in a single chunk are all processed."""
  2320. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  2321. payload1 = b'{"ip":"192.168.1.100"}'
  2322. payload2 = b'{"other":"data"}'
  2323. packet1 = self._build_mqtt_publish("topic1", payload1)
  2324. packet2 = self._build_mqtt_publish("topic2", payload2)
  2325. combined = packet1 + packet2
  2326. result, buf = TLSProxy._rewrite_mqtt_ip(combined, b"192.168.1.100", b"10.0.0.1", bytearray())
  2327. assert b"10.0.0.1" in result
  2328. assert b"192.168.1.100" not in result
  2329. # Second packet should still be present
  2330. assert b"other" in result
  2331. def test_extra_replacements(self):
  2332. """Extra replacement pairs (e.g. integer IP) are also applied."""
  2333. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  2334. payload = b'{"net":{"info":[{"ip":2248124608}]}}'
  2335. packet = self._build_mqtt_publish("device/status", payload)
  2336. result, buf = TLSProxy._rewrite_mqtt_ip(
  2337. packet,
  2338. b"NOMATCH",
  2339. b"NOREPLACE",
  2340. bytearray(),
  2341. extra_replacements=[(b"2248124608", b"285190336")],
  2342. )
  2343. assert b"285190336" in result
  2344. assert b"2248124608" not in result
  2345. class TestIpToLeIntBytes:
  2346. """Tests for TLSProxy._ip_to_le_int_bytes() integer IP conversion."""
  2347. def test_converts_ip_to_le_int(self):
  2348. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  2349. assert TLSProxy._ip_to_le_int_bytes("192.168.255.133") == b"2248124608"
  2350. assert TLSProxy._ip_to_le_int_bytes("192.168.255.16") == b"285190336"
  2351. assert TLSProxy._ip_to_le_int_bytes("10.0.0.1") == b"16777226"
  2352. def test_roundtrip(self):
  2353. """Verify the integer converts back to the correct IP."""
  2354. import struct
  2355. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  2356. for ip in ["192.168.1.1", "10.0.0.1", "172.16.0.100", "192.168.255.133"]:
  2357. le_int = int(TLSProxy._ip_to_le_int_bytes(ip))
  2358. parts = ip.split(".")
  2359. expected = struct.unpack("<I", bytes(int(p) for p in parts))[0]
  2360. assert le_int == expected
  2361. class TestSSDPProxyName:
  2362. """Tests for SSDPProxy VP name rewriting."""
  2363. @pytest.fixture
  2364. def ssdp_proxy_with_name(self):
  2365. from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
  2366. return SSDPProxy(
  2367. local_interface_ip="192.168.1.100",
  2368. remote_interface_ip="10.0.0.100",
  2369. target_printer_ip="192.168.1.50",
  2370. name="H2D-1 Proxy",
  2371. )
  2372. @pytest.fixture
  2373. def ssdp_proxy_without_name(self):
  2374. from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
  2375. return SSDPProxy(
  2376. local_interface_ip="192.168.1.100",
  2377. remote_interface_ip="10.0.0.100",
  2378. target_printer_ip="192.168.1.50",
  2379. )
  2380. def test_rewrite_uses_configured_name(self, ssdp_proxy_with_name):
  2381. """When name is set, DevName is replaced entirely."""
  2382. packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: RealPrinter\r\nDevBind.bambu.com: cloud\r\n\r\n"
  2383. rewritten = ssdp_proxy_with_name._rewrite_ssdp(packet)
  2384. assert b"DevName.bambu.com: H2D-1 Proxy" in rewritten
  2385. assert b"RealPrinter" not in rewritten
  2386. def test_rewrite_appends_proxy_without_name(self, ssdp_proxy_without_name):
  2387. """When no name is set, ' - Proxy' is appended to the real name."""
  2388. packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: RealPrinter\r\nDevBind.bambu.com: cloud\r\n\r\n"
  2389. rewritten = ssdp_proxy_without_name._rewrite_ssdp(packet)
  2390. assert b"DevName.bambu.com: RealPrinter - Proxy" in rewritten