test_virtual_printer.py 148 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862
  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="archive",
  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 == "archive"
  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="archive",
  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 = "archive"
  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 = "archive"
  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_on_print_command_schedules_finish_release_non_proxy(self, instance):
  171. """#1658: Bambu Studio 2.7.x flipped the slicer's Send flow to
  172. FTP → FTP → MQTT project_file. Under that order the synthetic
  173. project_file ack overwrites the FINISH set by #1280 in
  174. ``on_file_received`` back to PREPARE, leaving the slicer's
  175. "Downloading" modal stuck forever. Re-firing FINISH a moment after
  176. the ack releases the modal.
  177. """
  178. instance.mode = "archive"
  179. instance._mqtt = MagicMock()
  180. instance._mqtt.set_gcode_state = MagicMock()
  181. with patch.object(instance, "_delayed_finish_release", new_callable=AsyncMock) as mock_delayed:
  182. await instance.on_print_command("test.3mf", {"command": "project_file"})
  183. mock_delayed.assert_called_once()
  184. # First positional arg should be the filename; second is the delay seconds.
  185. args = mock_delayed.call_args.args
  186. assert args[0] == "test.3mf"
  187. assert isinstance(args[1], int | float)
  188. @pytest.mark.asyncio
  189. async def test_on_print_command_proxy_mode_does_not_reschedule_finish(self, instance):
  190. """Proxy mode hands push_status straight from the real printer through
  191. the bridge. Re-firing a synthetic FINISH would clobber a real
  192. PREPARE / RUNNING transition coming back from the printer, so the
  193. scheduler is exempt for proxy mode."""
  194. instance.mode = "proxy"
  195. instance._mqtt = MagicMock()
  196. with patch.object(instance, "_delayed_finish_release", new_callable=AsyncMock) as mock_delayed:
  197. await instance.on_print_command("test.3mf", {"command": "project_file"})
  198. mock_delayed.assert_not_called()
  199. @pytest.mark.asyncio
  200. async def test_on_print_command_no_mqtt_does_not_schedule(self, instance):
  201. """If the MQTT server isn't running yet (transient race during boot),
  202. the scheduler must skip silently — no AttributeError, no orphan task."""
  203. instance.mode = "queue"
  204. instance._mqtt = None
  205. # Should not raise.
  206. await instance.on_print_command("test.3mf", {"command": "project_file"})
  207. assert instance._finish_release_task is None
  208. @pytest.mark.asyncio
  209. async def test_schedule_finish_release_cancels_previous_timer(self, instance):
  210. """A slicer that fires project_file twice (e.g. retry after a transient
  211. FTP hiccup) must only result in one FINISH transition — the earlier
  212. in-flight timer is cancelled when the next one is scheduled."""
  213. instance.mode = "queue"
  214. instance._mqtt = MagicMock()
  215. instance._schedule_finish_release("first.3mf", delay=10.0)
  216. first_task = instance._finish_release_task
  217. assert first_task is not None
  218. instance._schedule_finish_release("second.3mf", delay=10.0)
  219. second_task = instance._finish_release_task
  220. assert second_task is not first_task
  221. # Give the loop one tick so the cancelled task settles.
  222. await asyncio.sleep(0)
  223. assert first_task.cancelled() or first_task.done()
  224. # Clean up the still-pending second task so the test doesn't leak it.
  225. second_task.cancel()
  226. try:
  227. await second_task
  228. except asyncio.CancelledError:
  229. pass
  230. @pytest.mark.asyncio
  231. async def test_delayed_finish_release_sets_finish_state(self, instance):
  232. """End-to-end: after the delay elapses, set_gcode_state is called with
  233. FINISH and prepare_percent=100, matching the wire-format the slicer's
  234. Print flow consumes to release "Downloading"."""
  235. instance._mqtt = MagicMock()
  236. instance._mqtt.set_gcode_state = MagicMock()
  237. await instance._delayed_finish_release("queued.3mf", delay=0.0)
  238. instance._mqtt.set_gcode_state.assert_called_once_with("FINISH", filename="queued.3mf", prepare_percent="100")
  239. @pytest.mark.asyncio
  240. async def test_on_print_command_no_filename_does_not_schedule(self, instance):
  241. """A project_file command without a subtask_name (defensive — real
  242. slicers always send one) must not schedule a no-op FINISH that would
  243. carry an empty filename on the next 1 Hz push."""
  244. instance.mode = "queue"
  245. instance._mqtt = MagicMock()
  246. with patch.object(instance, "_delayed_finish_release", new_callable=AsyncMock) as mock_delayed:
  247. await instance.on_print_command("", {"command": "project_file"})
  248. mock_delayed.assert_not_called()
  249. @pytest.mark.asyncio
  250. async def test_archive_file_skips_non_3mf(self, instance):
  251. """Verify non-3MF files are skipped and cleaned up."""
  252. instance._session_factory = MagicMock()
  253. instance._pending_files["verify_job"] = Path("/tmp/verify_job") # nosec B108
  254. with patch("pathlib.Path.unlink"):
  255. await instance._archive_file(Path("/tmp/verify_job"), "192.168.1.100") # nosec B108
  256. assert "verify_job" not in instance._pending_files
  257. @pytest.mark.asyncio
  258. async def test_archive_file_broadcasts_archive_created(self, tmp_path):
  259. """#1282: VP immediate-mode archives must broadcast archive_created so
  260. the Archives page refreshes without a tab switch. Real-printer prints
  261. get this via main.py's MQTT print_start handler; the VP path used to
  262. skip the broadcast entirely."""
  263. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  264. mock_db = AsyncMock()
  265. mock_db.commit = AsyncMock()
  266. mock_session_factory = MagicMock()
  267. mock_session_ctx = AsyncMock()
  268. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  269. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  270. mock_session_factory.return_value = mock_session_ctx
  271. inst = VirtualPrinterInstance(
  272. vp_id=30,
  273. name="ImmediateBroadcast",
  274. mode="archive",
  275. model="C12",
  276. access_code="12345678",
  277. serial_suffix="391800030",
  278. base_dir=tmp_path,
  279. session_factory=mock_session_factory,
  280. )
  281. file_path = tmp_path / "test.3mf"
  282. file_path.write_bytes(b"fake3mf")
  283. mock_archive = MagicMock()
  284. mock_archive.id = 99
  285. mock_archive.printer_id = None
  286. mock_archive.filename = "test.3mf"
  287. mock_archive.print_name = "test"
  288. mock_archive.status = "archived"
  289. with (
  290. patch(
  291. "backend.app.api.routes.settings.get_setting",
  292. new_callable=AsyncMock,
  293. return_value=None,
  294. ),
  295. patch(
  296. "backend.app.services.archive.ArchiveService.archive_print",
  297. new_callable=AsyncMock,
  298. return_value=mock_archive,
  299. ),
  300. patch(
  301. "backend.app.core.websocket.ws_manager.send_archive_created",
  302. new_callable=AsyncMock,
  303. ) as mock_broadcast,
  304. ):
  305. await inst._archive_file(file_path, "192.168.1.100")
  306. mock_broadcast.assert_awaited_once()
  307. payload = mock_broadcast.await_args.args[0]
  308. assert payload["id"] == 99
  309. assert payload["filename"] == "test.3mf"
  310. assert payload["status"] == "archived"
  311. # ========================================================================
  312. # Tests for auto_dispatch
  313. # ========================================================================
  314. def test_auto_dispatch_defaults_to_true(self, tmp_path):
  315. """Verify auto_dispatch defaults to True when not specified."""
  316. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  317. inst = VirtualPrinterInstance(
  318. vp_id=10,
  319. name="DefaultDispatch",
  320. mode="queue",
  321. model="C11",
  322. access_code="12345678",
  323. serial_suffix="391800010",
  324. base_dir=tmp_path,
  325. )
  326. assert inst.auto_dispatch is True
  327. @pytest.mark.asyncio
  328. async def test_add_to_print_queue_with_auto_dispatch_on(self, tmp_path):
  329. """Verify queue items have manual_start=False when auto_dispatch=True."""
  330. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  331. mock_db = AsyncMock()
  332. added_items = []
  333. def capture_add(item):
  334. added_items.append(item)
  335. mock_db.add = MagicMock(side_effect=capture_add)
  336. mock_db.commit = AsyncMock()
  337. mock_session_factory = MagicMock()
  338. mock_session_ctx = AsyncMock()
  339. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  340. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  341. mock_session_factory.return_value = mock_session_ctx
  342. inst = VirtualPrinterInstance(
  343. vp_id=11,
  344. name="AutoDispatchOn",
  345. mode="queue",
  346. model="C11",
  347. access_code="12345678",
  348. serial_suffix="391800011",
  349. auto_dispatch=True,
  350. base_dir=tmp_path,
  351. session_factory=mock_session_factory,
  352. )
  353. # Create a temp 3mf file
  354. file_path = tmp_path / "test.3mf"
  355. file_path.write_bytes(b"fake3mf")
  356. mock_archive = MagicMock()
  357. mock_archive.id = 1
  358. mock_archive.print_name = "test"
  359. with (
  360. patch(
  361. "backend.app.api.routes.settings.get_setting",
  362. new_callable=AsyncMock,
  363. return_value=None,
  364. ),
  365. patch(
  366. "backend.app.services.archive.ArchiveService.archive_print",
  367. new_callable=AsyncMock,
  368. return_value=mock_archive,
  369. ),
  370. ):
  371. await inst._add_to_print_queue(file_path, "192.168.1.100")
  372. assert len(added_items) == 1
  373. queue_item = added_items[0]
  374. assert queue_item.manual_start is False
  375. @pytest.mark.asyncio
  376. async def test_add_to_print_queue_broadcasts_archive_created(self, tmp_path):
  377. """#1282: VP queue-mode uploads must broadcast archive_created so the
  378. Archives page picks up the new entry live. Pre-fix the page only
  379. refreshed when the user manually switched tabs."""
  380. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  381. mock_db = AsyncMock()
  382. mock_db.add = MagicMock()
  383. mock_db.commit = AsyncMock()
  384. mock_session_factory = MagicMock()
  385. mock_session_ctx = AsyncMock()
  386. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  387. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  388. mock_session_factory.return_value = mock_session_ctx
  389. inst = VirtualPrinterInstance(
  390. vp_id=31,
  391. name="QueueBroadcast",
  392. mode="queue",
  393. model="C12",
  394. access_code="12345678",
  395. serial_suffix="391800031",
  396. auto_dispatch=True,
  397. base_dir=tmp_path,
  398. session_factory=mock_session_factory,
  399. )
  400. file_path = tmp_path / "test.3mf"
  401. file_path.write_bytes(b"fake3mf")
  402. mock_archive = MagicMock()
  403. mock_archive.id = 77
  404. mock_archive.printer_id = None
  405. mock_archive.filename = "test.3mf"
  406. mock_archive.print_name = "test"
  407. mock_archive.status = "archived"
  408. with (
  409. patch(
  410. "backend.app.api.routes.settings.get_setting",
  411. new_callable=AsyncMock,
  412. return_value=None,
  413. ),
  414. patch(
  415. "backend.app.services.archive.ArchiveService.archive_print",
  416. new_callable=AsyncMock,
  417. return_value=mock_archive,
  418. ),
  419. patch(
  420. "backend.app.core.websocket.ws_manager.send_archive_created",
  421. new_callable=AsyncMock,
  422. ) as mock_broadcast,
  423. ):
  424. await inst._add_to_print_queue(file_path, "192.168.1.100")
  425. mock_broadcast.assert_awaited_once()
  426. payload = mock_broadcast.await_args.args[0]
  427. assert payload["id"] == 77
  428. assert payload["print_name"] == "test"
  429. assert payload["status"] == "archived"
  430. @pytest.mark.asyncio
  431. async def test_add_to_print_queue_with_auto_dispatch_off(self, tmp_path):
  432. """Verify queue items have manual_start=True when auto_dispatch=False."""
  433. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  434. mock_db = AsyncMock()
  435. added_items = []
  436. def capture_add(item):
  437. added_items.append(item)
  438. mock_db.add = MagicMock(side_effect=capture_add)
  439. mock_db.commit = AsyncMock()
  440. mock_session_factory = MagicMock()
  441. mock_session_ctx = AsyncMock()
  442. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  443. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  444. mock_session_factory.return_value = mock_session_ctx
  445. inst = VirtualPrinterInstance(
  446. vp_id=12,
  447. name="AutoDispatchOff",
  448. mode="queue",
  449. model="C11",
  450. access_code="12345678",
  451. serial_suffix="391800012",
  452. auto_dispatch=False,
  453. base_dir=tmp_path,
  454. session_factory=mock_session_factory,
  455. )
  456. # Create a temp 3mf file
  457. file_path = tmp_path / "test.3mf"
  458. file_path.write_bytes(b"fake3mf")
  459. mock_archive = MagicMock()
  460. mock_archive.id = 1
  461. mock_archive.print_name = "test"
  462. with (
  463. patch(
  464. "backend.app.api.routes.settings.get_setting",
  465. new_callable=AsyncMock,
  466. return_value=None,
  467. ),
  468. patch(
  469. "backend.app.services.archive.ArchiveService.archive_print",
  470. new_callable=AsyncMock,
  471. return_value=mock_archive,
  472. ),
  473. ):
  474. await inst._add_to_print_queue(file_path, "192.168.1.100")
  475. assert len(added_items) == 1
  476. queue_item = added_items[0]
  477. assert queue_item.manual_start is True
  478. @pytest.mark.asyncio
  479. async def test_add_to_print_queue_gcode_injection_on(self, tmp_path):
  480. """#1516: queue items opt into injection when the VP has gcode_injection=True."""
  481. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  482. mock_db = AsyncMock()
  483. added_items = []
  484. mock_db.add = MagicMock(side_effect=added_items.append)
  485. mock_db.commit = AsyncMock()
  486. mock_session_factory = MagicMock()
  487. mock_session_ctx = AsyncMock()
  488. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  489. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  490. mock_session_factory.return_value = mock_session_ctx
  491. inst = VirtualPrinterInstance(
  492. vp_id=13,
  493. name="InjectOn",
  494. mode="queue",
  495. model="C11",
  496. access_code="12345678",
  497. serial_suffix="391800013",
  498. gcode_injection=True,
  499. base_dir=tmp_path,
  500. session_factory=mock_session_factory,
  501. )
  502. file_path = tmp_path / "test.3mf"
  503. file_path.write_bytes(b"fake3mf")
  504. mock_archive = MagicMock()
  505. mock_archive.id = 1
  506. mock_archive.print_name = "test"
  507. with (
  508. patch(
  509. "backend.app.api.routes.settings.get_setting",
  510. new_callable=AsyncMock,
  511. return_value=None,
  512. ),
  513. patch(
  514. "backend.app.services.archive.ArchiveService.archive_print",
  515. new_callable=AsyncMock,
  516. return_value=mock_archive,
  517. ),
  518. ):
  519. await inst._add_to_print_queue(file_path, "192.168.1.100")
  520. assert len(added_items) == 1
  521. assert added_items[0].gcode_injection is True
  522. @pytest.mark.asyncio
  523. async def test_add_to_print_queue_gcode_injection_off_by_default(self, tmp_path):
  524. """#1516: queue items do NOT inject when the VP leaves gcode_injection at its default."""
  525. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  526. mock_db = AsyncMock()
  527. added_items = []
  528. mock_db.add = MagicMock(side_effect=added_items.append)
  529. mock_db.commit = AsyncMock()
  530. mock_session_factory = MagicMock()
  531. mock_session_ctx = AsyncMock()
  532. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  533. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  534. mock_session_factory.return_value = mock_session_ctx
  535. inst = VirtualPrinterInstance(
  536. vp_id=14,
  537. name="InjectOff",
  538. mode="queue",
  539. model="C11",
  540. access_code="12345678",
  541. serial_suffix="391800014",
  542. base_dir=tmp_path,
  543. session_factory=mock_session_factory,
  544. )
  545. file_path = tmp_path / "test.3mf"
  546. file_path.write_bytes(b"fake3mf")
  547. mock_archive = MagicMock()
  548. mock_archive.id = 1
  549. mock_archive.print_name = "test"
  550. with (
  551. patch(
  552. "backend.app.api.routes.settings.get_setting",
  553. new_callable=AsyncMock,
  554. return_value=None,
  555. ),
  556. patch(
  557. "backend.app.services.archive.ArchiveService.archive_print",
  558. new_callable=AsyncMock,
  559. return_value=mock_archive,
  560. ),
  561. ):
  562. await inst._add_to_print_queue(file_path, "192.168.1.100")
  563. assert len(added_items) == 1
  564. assert added_items[0].gcode_injection is False
  565. @pytest.mark.asyncio
  566. async def test_add_to_print_queue_uses_workflow_defaults_from_settings(self, tmp_path):
  567. """#1235: VP queue-mode constructed PrintQueueItem without specifying
  568. bed_levelling / flow_cali / vibration_cali / layer_inspect / timelapse,
  569. so SQLAlchemy applied the column-level defaults and ignored the user's
  570. workflow preferences entirely. Every print sent from the slicer to the
  571. VP came through with the OPPOSITE of what the workflow page said,
  572. forcing the user to edit each queue item by hand.
  573. """
  574. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  575. added_items = []
  576. mock_db = AsyncMock()
  577. mock_db.add = MagicMock(side_effect=added_items.append)
  578. mock_db.commit = AsyncMock()
  579. mock_session_factory = MagicMock()
  580. mock_session_ctx = AsyncMock()
  581. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  582. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  583. mock_session_factory.return_value = mock_session_ctx
  584. inst = VirtualPrinterInstance(
  585. vp_id=22,
  586. name="DefaultsTest",
  587. mode="queue",
  588. model="C12",
  589. access_code="12345678",
  590. serial_suffix="391800022",
  591. auto_dispatch=True,
  592. base_dir=tmp_path,
  593. session_factory=mock_session_factory,
  594. )
  595. file_path = tmp_path / "test.3mf"
  596. file_path.write_bytes(b"fake3mf")
  597. # The reporter set every workflow default to the OPPOSITE of the model's
  598. # column default. Pre-fix the column defaults won; with the fix the
  599. # settings values must flow through to the queue item exactly as stored.
  600. settings_map = {
  601. "virtual_printer_archive_name_source": None,
  602. "default_bed_levelling": "false", # model default: True
  603. "default_flow_cali": "true", # model default: False
  604. "default_vibration_cali": "false", # model default: True
  605. "default_layer_inspect": "true", # model default: False
  606. "default_timelapse": "true", # model default: False
  607. }
  608. async def fake_get_setting(_db, key):
  609. return settings_map.get(key)
  610. mock_archive = MagicMock()
  611. mock_archive.id = 1
  612. mock_archive.print_name = "test"
  613. with (
  614. patch(
  615. "backend.app.api.routes.settings.get_setting",
  616. new=fake_get_setting,
  617. ),
  618. patch(
  619. "backend.app.services.archive.ArchiveService.archive_print",
  620. new_callable=AsyncMock,
  621. return_value=mock_archive,
  622. ),
  623. ):
  624. await inst._add_to_print_queue(file_path, "192.168.1.100")
  625. assert len(added_items) == 1
  626. queue_item = added_items[0]
  627. assert queue_item.bed_levelling is False, "default_bed_levelling=false must flow through"
  628. assert queue_item.flow_cali is True, "default_flow_cali=true must flow through"
  629. assert queue_item.vibration_cali is False, "default_vibration_cali=false must flow through"
  630. assert queue_item.layer_inspect is True, "default_layer_inspect=true must flow through"
  631. assert queue_item.timelapse is True, "default_timelapse=true must flow through"
  632. @pytest.mark.asyncio
  633. async def test_add_to_print_queue_falls_back_to_schema_defaults_when_unset(self, tmp_path):
  634. """#1235 fallback: when no workflow setting is in the DB, the queue
  635. item should use the AppSettings (Pydantic) defaults — same values
  636. the user sees in the workflow page on a fresh install.
  637. """
  638. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  639. added_items = []
  640. mock_db = AsyncMock()
  641. mock_db.add = MagicMock(side_effect=added_items.append)
  642. mock_db.commit = AsyncMock()
  643. mock_session_factory = MagicMock()
  644. mock_session_ctx = AsyncMock()
  645. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  646. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  647. mock_session_factory.return_value = mock_session_ctx
  648. inst = VirtualPrinterInstance(
  649. vp_id=23,
  650. name="FreshInstallDefaults",
  651. mode="queue",
  652. model="C12",
  653. access_code="12345678",
  654. serial_suffix="391800023",
  655. auto_dispatch=True,
  656. base_dir=tmp_path,
  657. session_factory=mock_session_factory,
  658. )
  659. file_path = tmp_path / "test.3mf"
  660. file_path.write_bytes(b"fake3mf")
  661. mock_archive = MagicMock()
  662. mock_archive.id = 1
  663. mock_archive.print_name = "test"
  664. with (
  665. patch(
  666. "backend.app.api.routes.settings.get_setting",
  667. new_callable=AsyncMock,
  668. return_value=None, # No settings → fall back to schema defaults
  669. ),
  670. patch(
  671. "backend.app.services.archive.ArchiveService.archive_print",
  672. new_callable=AsyncMock,
  673. return_value=mock_archive,
  674. ),
  675. ):
  676. await inst._add_to_print_queue(file_path, "192.168.1.100")
  677. assert len(added_items) == 1
  678. queue_item = added_items[0]
  679. # These must match the AppSettings (Pydantic) defaults in schemas/settings.py
  680. assert queue_item.bed_levelling is True
  681. assert queue_item.flow_cali is False
  682. assert queue_item.vibration_cali is True
  683. assert queue_item.layer_inspect is False
  684. assert queue_item.timelapse is False
  685. @pytest.mark.asyncio
  686. async def test_add_to_print_queue_inherits_slicer_print_options(self, tmp_path):
  687. """#1403: VP-queue items used to fall back to `default_timelapse` even
  688. though the slicer's MQTT `project_file` command carries the user's
  689. actual choice. Capture-via-`on_print_command` flow lets the user's
  690. slicer toggle reach the queue item.
  691. Settings here have timelapse OFF; the slicer's MQTT capture has it ON.
  692. After the fix the queue item must reflect the slicer's choice.
  693. """
  694. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  695. added_items = []
  696. mock_db = AsyncMock()
  697. mock_db.add = MagicMock(side_effect=added_items.append)
  698. mock_db.commit = AsyncMock()
  699. mock_session_factory = MagicMock()
  700. mock_session_ctx = AsyncMock()
  701. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  702. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  703. mock_session_factory.return_value = mock_session_ctx
  704. inst = VirtualPrinterInstance(
  705. vp_id=24,
  706. name="SlicerInherits",
  707. mode="queue",
  708. model="C12",
  709. access_code="12345678",
  710. serial_suffix="391800024",
  711. auto_dispatch=True,
  712. base_dir=tmp_path,
  713. session_factory=mock_session_factory,
  714. )
  715. file_path = tmp_path / "test.3mf"
  716. file_path.write_bytes(b"fake3mf")
  717. # Pre-populate the capture as if MQTT `project_file` arrived already.
  718. # Settings (below) deliberately have timelapse OFF — only the slicer
  719. # capture should drive the resulting queue item.
  720. await inst.on_print_command(
  721. file_path.name,
  722. {
  723. "command": "project_file",
  724. "timelapse": True,
  725. "bed_leveling": False, # Note: MQTT field is single-L `bed_leveling`
  726. "flow_cali": True,
  727. "vibration_cali": False,
  728. "layer_inspect": True,
  729. },
  730. )
  731. settings_map = {
  732. "virtual_printer_archive_name_source": None,
  733. "default_bed_levelling": "true",
  734. "default_flow_cali": "false",
  735. "default_vibration_cali": "true",
  736. "default_layer_inspect": "false",
  737. "default_timelapse": "false",
  738. }
  739. async def fake_get_setting(_db, key):
  740. return settings_map.get(key)
  741. mock_archive = MagicMock()
  742. mock_archive.id = 1
  743. mock_archive.print_name = "test"
  744. with (
  745. patch(
  746. "backend.app.api.routes.settings.get_setting",
  747. new=fake_get_setting,
  748. ),
  749. patch(
  750. "backend.app.services.archive.ArchiveService.archive_print",
  751. new_callable=AsyncMock,
  752. return_value=mock_archive,
  753. ),
  754. ):
  755. await inst._add_to_print_queue(file_path, "192.168.1.100")
  756. assert len(added_items) == 1
  757. queue_item = added_items[0]
  758. assert queue_item.timelapse is True, "Slicer's timelapse=True must override settings.default_timelapse=False"
  759. assert queue_item.bed_levelling is False, "Slicer's bed_leveling=False must override default_bed_levelling=True"
  760. assert queue_item.flow_cali is True
  761. assert queue_item.vibration_cali is False
  762. assert queue_item.layer_inspect is True
  763. # Capture is consumed — no lingering state for the next print of the same name.
  764. assert file_path.name not in inst._slicer_print_options
  765. @pytest.mark.asyncio
  766. async def test_add_to_print_queue_coerces_slicer_integer_zero_one(self, tmp_path):
  767. """#1403: H-family firmwares carry calibration flags as integers
  768. (0/1) rather than booleans. The capture must coerce both shapes so
  769. H-family-sliced jobs through the VP queue work the same as P1/X1.
  770. """
  771. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  772. added_items = []
  773. mock_db = AsyncMock()
  774. mock_db.add = MagicMock(side_effect=added_items.append)
  775. mock_db.commit = AsyncMock()
  776. mock_session_factory = MagicMock()
  777. mock_session_ctx = AsyncMock()
  778. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  779. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  780. mock_session_factory.return_value = mock_session_ctx
  781. inst = VirtualPrinterInstance(
  782. vp_id=25,
  783. name="SlicerIntegers",
  784. mode="queue",
  785. model="C12",
  786. access_code="12345678",
  787. serial_suffix="391800025",
  788. auto_dispatch=True,
  789. base_dir=tmp_path,
  790. session_factory=mock_session_factory,
  791. )
  792. file_path = tmp_path / "test.3mf"
  793. file_path.write_bytes(b"fake3mf")
  794. await inst.on_print_command(
  795. file_path.name,
  796. {"command": "project_file", "timelapse": 1, "bed_leveling": 0, "flow_cali": 1},
  797. )
  798. mock_archive = MagicMock()
  799. mock_archive.id = 1
  800. mock_archive.print_name = "test"
  801. with (
  802. patch(
  803. "backend.app.api.routes.settings.get_setting",
  804. new_callable=AsyncMock,
  805. return_value=None,
  806. ),
  807. patch(
  808. "backend.app.services.archive.ArchiveService.archive_print",
  809. new_callable=AsyncMock,
  810. return_value=mock_archive,
  811. ),
  812. ):
  813. await inst._add_to_print_queue(file_path, "192.168.1.100")
  814. assert len(added_items) == 1
  815. queue_item = added_items[0]
  816. assert queue_item.timelapse is True, "integer 1 must coerce to True"
  817. assert queue_item.bed_levelling is False, "integer 0 must coerce to False"
  818. assert queue_item.flow_cali is True
  819. @pytest.mark.asyncio
  820. async def test_add_to_print_queue_populates_required_filament_types(self, tmp_path):
  821. """#1188: VP queue-mode used to create PrintQueueItems with no
  822. filament fields, so the scheduler fell through to model-only matching
  823. and dispatched onto whatever printer was free regardless of loaded
  824. colour. ``required_filament_types`` is populated unconditionally
  825. (cheap, helps the scheduler validate type even without
  826. ``force_color_match``) — pin that contract here."""
  827. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  828. added_items = []
  829. mock_db = AsyncMock()
  830. mock_db.add = MagicMock(side_effect=added_items.append)
  831. mock_db.commit = AsyncMock()
  832. mock_session_factory = MagicMock()
  833. mock_session_ctx = AsyncMock()
  834. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  835. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  836. mock_session_factory.return_value = mock_session_ctx
  837. inst = VirtualPrinterInstance(
  838. vp_id=21,
  839. name="Reqs",
  840. mode="queue",
  841. model="C12",
  842. access_code="12345678",
  843. serial_suffix="391800021",
  844. auto_dispatch=True,
  845. queue_force_color_match=False, # off → only required_filament_types
  846. base_dir=tmp_path,
  847. session_factory=mock_session_factory,
  848. )
  849. file_path = tmp_path / "multi.3mf"
  850. _write_3mf_with_filaments(
  851. file_path,
  852. [
  853. {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "12.3"},
  854. {"id": "2", "type": "PETG", "color": "#000000", "used_g": "4.5"},
  855. # used_g=0 → not actually consumed by this plate, must be ignored
  856. {"id": "3", "type": "ABS", "color": "#FF0000", "used_g": "0"},
  857. ],
  858. plate_index=1,
  859. )
  860. mock_archive = MagicMock()
  861. mock_archive.id = 1
  862. mock_archive.print_name = "multi"
  863. with (
  864. patch(
  865. "backend.app.api.routes.settings.get_setting",
  866. new_callable=AsyncMock,
  867. return_value=None,
  868. ),
  869. patch(
  870. "backend.app.services.archive.ArchiveService.archive_print",
  871. new_callable=AsyncMock,
  872. return_value=mock_archive,
  873. ),
  874. ):
  875. await inst._add_to_print_queue(file_path, "192.168.1.100")
  876. assert len(added_items) == 1
  877. queue_item = added_items[0]
  878. # Type-only fallback always populated. Sorted, deduped, no zero-use ABS.
  879. assert queue_item.required_filament_types is not None
  880. assert json.loads(queue_item.required_filament_types) == ["PETG", "PLA"]
  881. # Setting off → no force_color_match overrides leaked.
  882. assert queue_item.filament_overrides is None
  883. @pytest.mark.asyncio
  884. async def test_add_to_print_queue_force_color_match_writes_overrides(self, tmp_path):
  885. """#1188 core fix: when the per-VP ``queue_force_color_match`` toggle
  886. is on, every consumed slot lands as a ``filament_overrides`` entry
  887. with ``force_color_match: true``. This is the field the scheduler
  888. keys on (``print_scheduler.py:512``) — without it, slot-by-slot
  889. type+color matching never runs."""
  890. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  891. added_items = []
  892. mock_db = AsyncMock()
  893. mock_db.add = MagicMock(side_effect=added_items.append)
  894. mock_db.commit = AsyncMock()
  895. mock_session_factory = MagicMock()
  896. mock_session_ctx = AsyncMock()
  897. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  898. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  899. mock_session_factory.return_value = mock_session_ctx
  900. inst = VirtualPrinterInstance(
  901. vp_id=22,
  902. name="ForceColor",
  903. mode="queue",
  904. model="C12",
  905. access_code="12345678",
  906. serial_suffix="391800022",
  907. auto_dispatch=True,
  908. queue_force_color_match=True, # on
  909. base_dir=tmp_path,
  910. session_factory=mock_session_factory,
  911. )
  912. file_path = tmp_path / "forced.3mf"
  913. _write_3mf_with_filaments(
  914. file_path,
  915. [
  916. {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "10.0"},
  917. {"id": "2", "type": "PLA", "color": "#FF00FF", "used_g": "5.0"},
  918. ],
  919. plate_index=1,
  920. )
  921. mock_archive = MagicMock()
  922. mock_archive.id = 1
  923. mock_archive.print_name = "forced"
  924. with (
  925. patch(
  926. "backend.app.api.routes.settings.get_setting",
  927. new_callable=AsyncMock,
  928. return_value=None,
  929. ),
  930. patch(
  931. "backend.app.services.archive.ArchiveService.archive_print",
  932. new_callable=AsyncMock,
  933. return_value=mock_archive,
  934. ),
  935. ):
  936. await inst._add_to_print_queue(file_path, "192.168.1.100")
  937. assert len(added_items) == 1
  938. queue_item = added_items[0]
  939. assert queue_item.filament_overrides is not None
  940. overrides = json.loads(queue_item.filament_overrides)
  941. assert overrides == [
  942. {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "force_color_match": True},
  943. {"slot_id": 2, "type": "PLA", "color": "#FF00FF", "force_color_match": True},
  944. ]
  945. # required_filament_types still populated alongside overrides.
  946. assert json.loads(queue_item.required_filament_types) == ["PLA"]
  947. @pytest.mark.asyncio
  948. async def test_add_to_print_queue_force_color_match_skips_when_3mf_unparseable(self, tmp_path):
  949. """A malformed or fake-bytes 3MF must not crash the upload path —
  950. we just write the queue item with no filament fields and let the
  951. scheduler fall back to model-only matching (the pre-#1188 default).
  952. Regression guard for the existing fake-bytes happy-path tests."""
  953. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  954. added_items = []
  955. mock_db = AsyncMock()
  956. mock_db.add = MagicMock(side_effect=added_items.append)
  957. mock_db.commit = AsyncMock()
  958. mock_session_factory = MagicMock()
  959. mock_session_ctx = AsyncMock()
  960. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  961. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  962. mock_session_factory.return_value = mock_session_ctx
  963. inst = VirtualPrinterInstance(
  964. vp_id=23,
  965. name="Unparseable",
  966. mode="queue",
  967. model="C12",
  968. access_code="12345678",
  969. serial_suffix="391800023",
  970. auto_dispatch=True,
  971. queue_force_color_match=True,
  972. base_dir=tmp_path,
  973. session_factory=mock_session_factory,
  974. )
  975. file_path = tmp_path / "bad.3mf"
  976. file_path.write_bytes(b"not a real 3mf zip")
  977. mock_archive = MagicMock()
  978. mock_archive.id = 1
  979. mock_archive.print_name = "bad"
  980. with (
  981. patch(
  982. "backend.app.api.routes.settings.get_setting",
  983. new_callable=AsyncMock,
  984. return_value=None,
  985. ),
  986. patch(
  987. "backend.app.services.archive.ArchiveService.archive_print",
  988. new_callable=AsyncMock,
  989. return_value=mock_archive,
  990. ),
  991. ):
  992. await inst._add_to_print_queue(file_path, "192.168.1.100")
  993. assert len(added_items) == 1
  994. queue_item = added_items[0]
  995. # No filament data extractable → both fields stay None (graceful
  996. # fallback to model-only scheduling).
  997. assert queue_item.required_filament_types is None
  998. assert queue_item.filament_overrides is None
  999. # ========================================================================
  1000. # Tests for archive_name_source setting (#1152)
  1001. # ========================================================================
  1002. @pytest.mark.asyncio
  1003. @pytest.mark.parametrize(
  1004. ("setting_value", "expected_prefer_filename"),
  1005. [
  1006. ("filename", True),
  1007. ("metadata", False),
  1008. (None, False), # Default when setting unset
  1009. ("", False), # Defensive: empty string is not "filename"
  1010. ],
  1011. )
  1012. async def test_archive_file_passes_prefer_filename_per_setting(
  1013. self, tmp_path, setting_value, expected_prefer_filename
  1014. ):
  1015. """_archive_file reads `virtual_printer_archive_name_source` and forwards
  1016. prefer_filename_for_name=True only when it equals 'filename' (#1152)."""
  1017. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1018. mock_db = AsyncMock()
  1019. mock_session_factory = MagicMock()
  1020. mock_session_ctx = AsyncMock()
  1021. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1022. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1023. mock_session_factory.return_value = mock_session_ctx
  1024. inst = VirtualPrinterInstance(
  1025. vp_id=20,
  1026. name="NameSource",
  1027. mode="archive",
  1028. model="C11",
  1029. access_code="12345678",
  1030. serial_suffix="391800020",
  1031. base_dir=tmp_path,
  1032. session_factory=mock_session_factory,
  1033. )
  1034. file_path = tmp_path / "user-renamed-job.3mf"
  1035. file_path.write_bytes(b"fake3mf")
  1036. mock_archive = MagicMock()
  1037. mock_archive.id = 1
  1038. mock_archive.print_name = "user-renamed-job"
  1039. archive_print_mock = AsyncMock(return_value=mock_archive)
  1040. with (
  1041. patch(
  1042. "backend.app.api.routes.settings.get_setting",
  1043. new_callable=AsyncMock,
  1044. return_value=setting_value,
  1045. ),
  1046. patch(
  1047. "backend.app.services.archive.ArchiveService.archive_print",
  1048. archive_print_mock,
  1049. ),
  1050. ):
  1051. await inst._archive_file(file_path, "192.168.1.100")
  1052. assert archive_print_mock.await_count == 1
  1053. kwargs = archive_print_mock.await_args.kwargs
  1054. assert kwargs.get("prefer_filename_for_name") is expected_prefer_filename
  1055. # ========================================================================
  1056. # Tests for failure-path cleanup (#audit-R2-1)
  1057. # ========================================================================
  1058. #
  1059. # All three file handlers (_archive_file, _queue_file, _add_to_print_queue)
  1060. # previously only popped _pending_files and unlinked the temp file on the
  1061. # success branch. Failure paths leaked the marker (blocking same-name
  1062. # retries via the FTP layer) and the temp file on disk. The cleanup must
  1063. # ALWAYS run, even when archival / queue insert raises.
  1064. @pytest.mark.asyncio
  1065. async def test_archive_file_failure_path_pops_pending_and_unlinks(self, tmp_path):
  1066. """When the archive layer raises, `_pending_files[filename]` must still
  1067. be popped and the temp file must be unlinked. Otherwise the FTP layer's
  1068. same-name retry guard would silently reject the slicer's next attempt
  1069. and the upload_dir would accumulate ghost files."""
  1070. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1071. mock_db = AsyncMock()
  1072. mock_session_factory = MagicMock()
  1073. mock_session_ctx = AsyncMock()
  1074. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1075. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1076. mock_session_factory.return_value = mock_session_ctx
  1077. inst = VirtualPrinterInstance(
  1078. vp_id=40,
  1079. name="ArchiveFailCleanup",
  1080. mode="archive",
  1081. model="C12",
  1082. access_code="12345678",
  1083. serial_suffix="391800040",
  1084. base_dir=tmp_path,
  1085. session_factory=mock_session_factory,
  1086. )
  1087. file_path = tmp_path / "cleanup-archive.3mf"
  1088. file_path.write_bytes(b"fake3mf")
  1089. inst._pending_files[file_path.name] = file_path
  1090. with (
  1091. patch(
  1092. "backend.app.api.routes.settings.get_setting",
  1093. new_callable=AsyncMock,
  1094. return_value=None,
  1095. ),
  1096. patch(
  1097. "backend.app.services.archive.ArchiveService.archive_print",
  1098. new_callable=AsyncMock,
  1099. side_effect=RuntimeError("archive blew up"),
  1100. ),
  1101. ):
  1102. await inst._archive_file(file_path, "192.168.1.100")
  1103. assert file_path.name not in inst._pending_files
  1104. assert not file_path.exists()
  1105. @pytest.mark.asyncio
  1106. async def test_queue_file_failure_path_pops_pending_and_unlinks(self, tmp_path):
  1107. """Same invariant for _queue_file: a DB error during PendingUpload
  1108. insert must not leak the in-flight marker or the temp file."""
  1109. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1110. mock_db = AsyncMock()
  1111. # Commit raises — emulating a DB connectivity error.
  1112. mock_db.add = MagicMock()
  1113. mock_db.commit = AsyncMock(side_effect=RuntimeError("db unreachable"))
  1114. mock_session_factory = MagicMock()
  1115. mock_session_ctx = AsyncMock()
  1116. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1117. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1118. mock_session_factory.return_value = mock_session_ctx
  1119. inst = VirtualPrinterInstance(
  1120. vp_id=41,
  1121. name="QueueFailCleanup",
  1122. mode="review",
  1123. model="C12",
  1124. access_code="12345678",
  1125. serial_suffix="391800041",
  1126. base_dir=tmp_path,
  1127. session_factory=mock_session_factory,
  1128. )
  1129. file_path = tmp_path / "cleanup-queue.3mf"
  1130. file_path.write_bytes(b"fake3mf")
  1131. inst._pending_files[file_path.name] = file_path
  1132. await inst._queue_file(file_path, "192.168.1.100")
  1133. assert file_path.name not in inst._pending_files
  1134. assert not file_path.exists()
  1135. @pytest.mark.asyncio
  1136. async def test_add_to_print_queue_failure_path_pops_pending_and_unlinks(self, tmp_path):
  1137. """Same invariant for _add_to_print_queue: a DB error or archive
  1138. failure must not leak the in-flight marker or the temp file."""
  1139. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1140. mock_db = AsyncMock()
  1141. mock_db.add = MagicMock()
  1142. mock_db.commit = AsyncMock()
  1143. mock_db.execute = AsyncMock(side_effect=RuntimeError("queue insert blew up"))
  1144. mock_session_factory = MagicMock()
  1145. mock_session_ctx = AsyncMock()
  1146. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1147. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1148. mock_session_factory.return_value = mock_session_ctx
  1149. inst = VirtualPrinterInstance(
  1150. vp_id=42,
  1151. name="DispatchFailCleanup",
  1152. mode="queue",
  1153. model="C12",
  1154. access_code="12345678",
  1155. serial_suffix="391800042",
  1156. auto_dispatch=True,
  1157. base_dir=tmp_path,
  1158. session_factory=mock_session_factory,
  1159. )
  1160. file_path = tmp_path / "cleanup-dispatch.3mf"
  1161. file_path.write_bytes(b"fake3mf")
  1162. inst._pending_files[file_path.name] = file_path
  1163. with patch(
  1164. "backend.app.api.routes.settings.get_setting",
  1165. new_callable=AsyncMock,
  1166. return_value=None,
  1167. ):
  1168. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1169. assert file_path.name not in inst._pending_files
  1170. assert not file_path.exists()
  1171. # ========================================================================
  1172. # Test for position=MAX+1 (audit-R2)
  1173. # ========================================================================
  1174. @pytest.mark.asyncio
  1175. async def test_add_to_print_queue_position_picks_max_plus_one(self, tmp_path):
  1176. """VP-queue items previously got hardcoded `position=1`, colliding
  1177. with existing items at position 1 and producing non-deterministic
  1178. execution order. Now the position is chosen by `MAX(position)+1`
  1179. against the target queue, matching the canonical `POST /print-queue/`
  1180. path."""
  1181. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1182. # Capture the inserted PrintQueueItem so we can assert on .position.
  1183. added_items: list = []
  1184. class _RecordingDb:
  1185. def __init__(self):
  1186. self.add = lambda item: added_items.append(item)
  1187. self.commit = AsyncMock()
  1188. async def execute(self, query): # noqa: ARG002
  1189. """Return a stub result whose `.scalar()` reports the existing
  1190. MAX(position) for the target. Returning 7 means the new item
  1191. should land at 8."""
  1192. result = MagicMock()
  1193. result.scalar = MagicMock(return_value=7)
  1194. return result
  1195. mock_db = _RecordingDb()
  1196. mock_session_factory = MagicMock()
  1197. mock_session_ctx = AsyncMock()
  1198. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1199. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1200. mock_session_factory.return_value = mock_session_ctx
  1201. inst = VirtualPrinterInstance(
  1202. vp_id=43,
  1203. name="PositionMaxPlusOne",
  1204. mode="queue",
  1205. model="C12",
  1206. access_code="12345678",
  1207. serial_suffix="391800043",
  1208. target_printer_id=99,
  1209. auto_dispatch=True,
  1210. base_dir=tmp_path,
  1211. session_factory=mock_session_factory,
  1212. )
  1213. file_path = tmp_path / "next-position.3mf"
  1214. file_path.write_bytes(b"fake3mf")
  1215. mock_archive = MagicMock()
  1216. mock_archive.id = 555
  1217. mock_archive.printer_id = None
  1218. mock_archive.filename = "next-position.3mf"
  1219. mock_archive.print_name = "next-position"
  1220. mock_archive.status = "archived"
  1221. with (
  1222. patch(
  1223. "backend.app.api.routes.settings.get_setting",
  1224. new_callable=AsyncMock,
  1225. return_value=None,
  1226. ),
  1227. patch(
  1228. "backend.app.services.archive.ArchiveService.archive_print",
  1229. new_callable=AsyncMock,
  1230. return_value=mock_archive,
  1231. ),
  1232. patch(
  1233. "backend.app.core.websocket.ws_manager.send_archive_created",
  1234. new_callable=AsyncMock,
  1235. ),
  1236. ):
  1237. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1238. # One queue item was added.
  1239. assert len(added_items) == 1
  1240. queue_item = added_items[0]
  1241. # Position = max(7) + 1 = 8 — NOT the legacy hardcoded 1.
  1242. assert queue_item.position == 8
  1243. @pytest.mark.asyncio
  1244. async def test_add_to_print_queue_multi_plate_send_all_enqueues_one_per_plate(self, tmp_path):
  1245. """#1733: BambuStudio / OrcaSlicer "Send All" of a multi-plate project
  1246. uploads ONE 3MF containing every plate. Pre-fix only the first plate
  1247. index was extracted and one queue item was created; plates 2..N were
  1248. silently dropped. Post-fix every `<plate>` block in `slice_info.config`
  1249. produces its own PrintQueueItem with the correct ``plate_id``, sharing
  1250. the same backing archive, with consecutive positions for plate-order
  1251. execution.
  1252. """
  1253. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1254. added_items: list = []
  1255. class _RecordingDb:
  1256. def __init__(self):
  1257. # Capture inserted items as they're added; assign a fake .id
  1258. # on flush so the manager's logger doesn't see None.
  1259. self._next_id = 1000
  1260. def _add(item):
  1261. added_items.append(item)
  1262. self.add = _add
  1263. self.commit = AsyncMock()
  1264. async def execute(self, query): # noqa: ARG002
  1265. """Return MAX(position) = 0 so plate items land at 1, 2, 3."""
  1266. result = MagicMock()
  1267. result.scalar = MagicMock(return_value=0)
  1268. return result
  1269. async def flush(self):
  1270. # Mimic the FK populate so queue_item.id is available after add().
  1271. for item in added_items:
  1272. if getattr(item, "id", None) is None:
  1273. item.id = self._next_id
  1274. self._next_id += 1
  1275. mock_db = _RecordingDb()
  1276. mock_session_factory = MagicMock()
  1277. mock_session_ctx = AsyncMock()
  1278. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1279. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1280. mock_session_factory.return_value = mock_session_ctx
  1281. inst = VirtualPrinterInstance(
  1282. vp_id=44,
  1283. name="MultiPlateSendAll",
  1284. mode="queue",
  1285. model="O1D", # H2D — matches the live VP H2D-1 Proxy in #1733
  1286. access_code="12345678",
  1287. serial_suffix="391800044",
  1288. target_printer_id=1,
  1289. auto_dispatch=False, # manual_start, mirrors the live VP
  1290. base_dir=tmp_path,
  1291. session_factory=mock_session_factory,
  1292. )
  1293. # Build a 3MF with three plates baked into slice_info.config —
  1294. # mirrors what BambuStudio / OrcaSlicer's "Send All" puts on the wire.
  1295. file_path = tmp_path / "Cube.gcode.3mf"
  1296. _write_3mf_with_filaments(
  1297. file_path, [{"id": 1, "type": "PLA", "color": "#000000", "used_g": "15.61"}], plate_index=1
  1298. )
  1299. # Append plate 2 and 3 blocks to slice_info.config to mimic Send All.
  1300. with zipfile.ZipFile(file_path, "r") as zf:
  1301. existing = zf.read("Metadata/slice_info.config").decode()
  1302. # Inject two additional <plate> blocks (indices 2 and 3) inside <config>.
  1303. multi_plate_config = existing.replace(
  1304. "</config>",
  1305. (
  1306. '<plate><metadata key="index" value="2"/>'
  1307. '<filament id="2" type="PETG" color="#FB0207" used_g="14.45"/>'
  1308. "</plate>"
  1309. '<plate><metadata key="index" value="3"/>'
  1310. '<filament id="3" type="PLA" color="#FFFFFF" used_g="12.10"/>'
  1311. "</plate>"
  1312. "</config>"
  1313. ),
  1314. )
  1315. # Repack the zip with the expanded slice_info.config.
  1316. import io as _io
  1317. buf = _io.BytesIO()
  1318. with zipfile.ZipFile(file_path, "r") as src, zipfile.ZipFile(buf, "w") as dst:
  1319. for name in src.namelist():
  1320. if name == "Metadata/slice_info.config":
  1321. dst.writestr(name, multi_plate_config)
  1322. else:
  1323. dst.writestr(name, src.read(name))
  1324. # Plate-2 and plate-3 gcode payloads so `extract_filament_requirements`
  1325. # has something to read for each — contents irrelevant, presence matters.
  1326. dst.writestr("Metadata/plate_2.gcode", "; plate 2 gcode\n")
  1327. dst.writestr("Metadata/plate_3.gcode", "; plate 3 gcode\n")
  1328. file_path.write_bytes(buf.getvalue())
  1329. mock_archive = MagicMock()
  1330. mock_archive.id = 999
  1331. mock_archive.printer_id = None
  1332. mock_archive.filename = "Cube.gcode.3mf"
  1333. mock_archive.print_name = "Cube"
  1334. mock_archive.status = "archived"
  1335. with (
  1336. patch(
  1337. "backend.app.api.routes.settings.get_setting",
  1338. new_callable=AsyncMock,
  1339. return_value=None,
  1340. ),
  1341. patch(
  1342. "backend.app.services.archive.ArchiveService.archive_print",
  1343. new_callable=AsyncMock,
  1344. return_value=mock_archive,
  1345. ),
  1346. patch(
  1347. "backend.app.core.websocket.ws_manager.send_archive_created",
  1348. new_callable=AsyncMock,
  1349. ),
  1350. ):
  1351. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1352. # Three queue items, one per plate, with the correct plate_id and
  1353. # consecutive positions starting at MAX(position)+1 = 1.
  1354. assert len(added_items) == 3, f"Expected 3 queue items for 3-plate Send All, got {len(added_items)}"
  1355. plate_ids = [q.plate_id for q in added_items]
  1356. assert plate_ids == [1, 2, 3], f"plate_ids should preserve slice_info order, got {plate_ids}"
  1357. positions = [q.position for q in added_items]
  1358. assert positions == [1, 2, 3], f"positions should be consecutive, got {positions}"
  1359. archive_ids = {q.archive_id for q in added_items}
  1360. assert archive_ids == {999}, f"All queue items must share the single backing archive, got {archive_ids}"
  1361. # auto_dispatch=False on the VP → every item is manual_start.
  1362. assert all(q.manual_start for q in added_items)
  1363. @pytest.mark.asyncio
  1364. async def test_add_to_print_queue_captures_nozzle_mapping(self, tmp_path):
  1365. """#1780: BambuStudio's project_file for H2C rack-swap (O1C2) sends
  1366. per-filament physical nozzle position IDs in `nozzle_mapping`. VP
  1367. intake must store it as a JSON string on the queue item so the
  1368. dispatcher can replay it. Without this the H2C firmware falls back
  1369. to "last matching nozzle" auto-pick and ignores the user's slicer
  1370. choice.
  1371. """
  1372. import json as _json
  1373. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1374. added_items = []
  1375. mock_db = AsyncMock()
  1376. mock_db.add = MagicMock(side_effect=added_items.append)
  1377. mock_db.commit = AsyncMock()
  1378. mock_session_factory = MagicMock()
  1379. mock_session_ctx = AsyncMock()
  1380. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1381. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1382. mock_session_factory.return_value = mock_session_ctx
  1383. inst = VirtualPrinterInstance(
  1384. vp_id=42,
  1385. name="H2CRack",
  1386. mode="queue",
  1387. model="O1C2",
  1388. access_code="12345678",
  1389. serial_suffix="391800042",
  1390. base_dir=tmp_path,
  1391. session_factory=mock_session_factory,
  1392. )
  1393. file_path = tmp_path / "test.3mf"
  1394. file_path.write_bytes(b"fake3mf")
  1395. # Pre-populate as if BS's project_file arrived. Wire shape matches
  1396. # BambuStudio's PrintJob params: nozzle_mapping = 32-entry array of
  1397. # per-filament physical nozzle position IDs (verified via H2C wire
  1398. # capture). The slicer-side `nozzles_info` field that the original
  1399. # #1780 attempt also looked for was never actually sent — it has
  1400. # been dropped from the capture path entirely.
  1401. await inst.on_print_command(
  1402. file_path.name,
  1403. {
  1404. "command": "project_file",
  1405. "nozzle_mapping": [16, -1, -1, 1, -1, -1, -1, -1],
  1406. },
  1407. )
  1408. mock_archive = MagicMock()
  1409. mock_archive.id = 1
  1410. mock_archive.print_name = "test"
  1411. with (
  1412. patch(
  1413. "backend.app.api.routes.settings.get_setting",
  1414. new_callable=AsyncMock,
  1415. return_value=None,
  1416. ),
  1417. patch(
  1418. "backend.app.services.archive.ArchiveService.archive_print",
  1419. new_callable=AsyncMock,
  1420. return_value=mock_archive,
  1421. ),
  1422. ):
  1423. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1424. assert len(added_items) == 1
  1425. item = added_items[0]
  1426. assert item.nozzle_mapping is not None
  1427. assert _json.loads(item.nozzle_mapping) == [16, -1, -1, 1, -1, -1, -1, -1]
  1428. @pytest.mark.asyncio
  1429. async def test_add_to_print_queue_no_nozzle_mapping_when_slicer_omits(self, tmp_path):
  1430. """#1780: every model other than O1C2 sends no nozzle_mapping — the
  1431. queue item must carry NULL, not an empty list. NULL is what the
  1432. dispatch layer keys off of to skip the injection entirely on non-
  1433. rack-swap printers.
  1434. """
  1435. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1436. added_items = []
  1437. mock_db = AsyncMock()
  1438. mock_db.add = MagicMock(side_effect=added_items.append)
  1439. mock_db.commit = AsyncMock()
  1440. mock_session_factory = MagicMock()
  1441. mock_session_ctx = AsyncMock()
  1442. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1443. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1444. mock_session_factory.return_value = mock_session_ctx
  1445. inst = VirtualPrinterInstance(
  1446. vp_id=43,
  1447. name="NotH2C",
  1448. mode="queue",
  1449. model="C11",
  1450. access_code="12345678",
  1451. serial_suffix="391800043",
  1452. base_dir=tmp_path,
  1453. session_factory=mock_session_factory,
  1454. )
  1455. file_path = tmp_path / "test.3mf"
  1456. file_path.write_bytes(b"fake3mf")
  1457. # X1C-style slicer command — no nozzle fields.
  1458. await inst.on_print_command(
  1459. file_path.name,
  1460. {"command": "project_file", "timelapse": False, "bed_leveling": True},
  1461. )
  1462. mock_archive = MagicMock()
  1463. mock_archive.id = 1
  1464. mock_archive.print_name = "test"
  1465. with (
  1466. patch(
  1467. "backend.app.api.routes.settings.get_setting",
  1468. new_callable=AsyncMock,
  1469. return_value=None,
  1470. ),
  1471. patch(
  1472. "backend.app.services.archive.ArchiveService.archive_print",
  1473. new_callable=AsyncMock,
  1474. return_value=mock_archive,
  1475. ),
  1476. ):
  1477. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1478. assert len(added_items) == 1
  1479. item = added_items[0]
  1480. assert item.nozzle_mapping is None
  1481. @pytest.mark.asyncio
  1482. async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch):
  1483. """#1780 × #1697/#1188: a multi-plate Send All from BS must stamp the
  1484. same nozzle_mapping on every plate's queue item, not only the first.
  1485. Mirrors the per-plate stamping for gcode_injection,
  1486. filament_overrides, etc.
  1487. """
  1488. import json as _json
  1489. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1490. added_items = []
  1491. mock_db = AsyncMock()
  1492. mock_db.add = MagicMock(side_effect=added_items.append)
  1493. mock_db.flush = AsyncMock()
  1494. mock_db.commit = AsyncMock()
  1495. mock_db.execute = AsyncMock()
  1496. mock_db.execute.return_value.scalar.return_value = None
  1497. mock_session_factory = MagicMock()
  1498. mock_session_ctx = AsyncMock()
  1499. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1500. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1501. mock_session_factory.return_value = mock_session_ctx
  1502. inst = VirtualPrinterInstance(
  1503. vp_id=44,
  1504. name="H2CMultiPlate",
  1505. mode="queue",
  1506. model="O1C2",
  1507. access_code="12345678",
  1508. serial_suffix="391800044",
  1509. base_dir=tmp_path,
  1510. session_factory=mock_session_factory,
  1511. )
  1512. file_path = tmp_path / "test.3mf"
  1513. file_path.write_bytes(b"fake3mf")
  1514. # Force 3 plates so the queue loop runs three times.
  1515. monkeypatch.setattr(inst, "_extract_plate_ids", lambda _p: [1, 2, 3])
  1516. await inst.on_print_command(
  1517. file_path.name,
  1518. {
  1519. "command": "project_file",
  1520. "nozzle_mapping": [16, 0],
  1521. },
  1522. )
  1523. mock_archive = MagicMock()
  1524. mock_archive.id = 1
  1525. mock_archive.print_name = "test"
  1526. with (
  1527. patch(
  1528. "backend.app.api.routes.settings.get_setting",
  1529. new_callable=AsyncMock,
  1530. return_value=None,
  1531. ),
  1532. patch(
  1533. "backend.app.services.archive.ArchiveService.archive_print",
  1534. new_callable=AsyncMock,
  1535. return_value=mock_archive,
  1536. ),
  1537. ):
  1538. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1539. assert len(added_items) == 3
  1540. for item in added_items:
  1541. assert _json.loads(item.nozzle_mapping) == [16, 0]
  1542. @pytest.mark.asyncio
  1543. async def test_on_print_command_late_mqtt_retroactively_stamps_queue_item(self, tmp_path):
  1544. """#1780 round 3: Bambu Studio's MQTT project_file can arrive AFTER
  1545. `_add_to_print_queue` already gave up waiting (observed at 2.085 s
  1546. on H2C wireless setups). The queue item was committed with settings
  1547. defaults; the slicer's nozzle_mapping + workflow flags must be
  1548. patched onto it when MQTT lands, otherwise the H2C firmware falls
  1549. back to auto-pick.
  1550. """
  1551. import json as _json
  1552. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1553. added_items: list = []
  1554. mock_db = AsyncMock()
  1555. mock_db.add = MagicMock(
  1556. side_effect=lambda item: (added_items.append(item), setattr(item, "id", 100 + len(added_items)))[0]
  1557. )
  1558. async def _flush():
  1559. # added_items[-1].id was set by `add`; nothing else to do.
  1560. return None
  1561. mock_db.flush = AsyncMock(side_effect=_flush)
  1562. mock_db.commit = AsyncMock()
  1563. # First execute() call (the position-max SELECT inside _add_to_print_queue)
  1564. # returns None; second (the eligible-pending SELECT in
  1565. # _restamp_recent_queue_item) returns the committed queue id; third
  1566. # (the UPDATE) is fire-and-forget.
  1567. position_max_result = MagicMock()
  1568. position_max_result.scalar = MagicMock(return_value=None)
  1569. select_pending_result = MagicMock()
  1570. select_pending_result.all = MagicMock(return_value=[(101,)])
  1571. update_result = MagicMock()
  1572. mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
  1573. mock_session_factory = MagicMock()
  1574. mock_session_ctx = AsyncMock()
  1575. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1576. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1577. mock_session_factory.return_value = mock_session_ctx
  1578. inst = VirtualPrinterInstance(
  1579. vp_id=99,
  1580. name="LateMQTT",
  1581. mode="queue",
  1582. model="O1C2",
  1583. access_code="12345678",
  1584. serial_suffix="391800099",
  1585. base_dir=tmp_path,
  1586. session_factory=mock_session_factory,
  1587. )
  1588. # MQTT server presence enables the wait_for path; we don't actually
  1589. # use any methods on it.
  1590. inst._mqtt = MagicMock()
  1591. file_path = tmp_path / "test.3mf"
  1592. file_path.write_bytes(b"fake3mf")
  1593. mock_archive = MagicMock()
  1594. mock_archive.id = 1
  1595. mock_archive.print_name = "test"
  1596. # 1. _add_to_print_queue runs WITHOUT a prior on_print_command —
  1597. # the wait_for times out (settings-default fallback) and the
  1598. # queue item is committed.
  1599. with (
  1600. patch(
  1601. "backend.app.api.routes.settings.get_setting",
  1602. new_callable=AsyncMock,
  1603. return_value=None,
  1604. ),
  1605. patch(
  1606. "backend.app.services.archive.ArchiveService.archive_print",
  1607. new_callable=AsyncMock,
  1608. return_value=mock_archive,
  1609. ),
  1610. # Shorten the wait so the test isn't slow.
  1611. patch(
  1612. "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
  1613. 0.05,
  1614. ),
  1615. ):
  1616. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1617. assert len(added_items) == 1
  1618. assert added_items[0].nozzle_mapping is None # MQTT was never received
  1619. assert file_path.name in inst._recent_queue_items
  1620. # 2. MQTT project_file arrives AFTER the wait expired — must
  1621. # retroactively patch the queue item.
  1622. await inst.on_print_command(
  1623. file_path.name,
  1624. {
  1625. "command": "project_file",
  1626. "file": file_path.name,
  1627. "nozzle_mapping": [16, -1, -1, 1],
  1628. "timelapse": True,
  1629. "bed_leveling": False,
  1630. },
  1631. )
  1632. # The UPDATE call is the third execute. Inspect its values.
  1633. update_call = mock_db.execute.await_args_list[2]
  1634. update_stmt = update_call.args[0]
  1635. compiled = update_stmt.compile(compile_kwargs={"literal_binds": False})
  1636. params = dict(compiled.params)
  1637. assert _json.loads(params["nozzle_mapping"]) == [16, -1, -1, 1]
  1638. assert params["timelapse"] is True
  1639. assert params["bed_levelling"] is False # MQTT bed_leveling → column bed_levelling
  1640. # Recent-queue tracking dict is cleared after the patch.
  1641. assert file_path.name not in inst._recent_queue_items
  1642. @pytest.mark.asyncio
  1643. async def test_add_to_print_queue_catches_mqtt_stashed_post_wait_timeout(self, tmp_path):
  1644. """The actual race-window scenario: wait_for times out, then MQTT
  1645. arrives and stashes options AFTER the wait but BEFORE the
  1646. post-commit re-check. The post-commit pop must catch it.
  1647. """
  1648. import json as _json
  1649. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1650. added_items: list = []
  1651. mock_db = AsyncMock()
  1652. mock_db.add = MagicMock(
  1653. side_effect=lambda item: (added_items.append(item), setattr(item, "id", 300 + len(added_items)))[0]
  1654. )
  1655. mock_db.flush = AsyncMock()
  1656. mock_db.commit = AsyncMock()
  1657. position_max_result = MagicMock()
  1658. position_max_result.scalar = MagicMock(return_value=None)
  1659. select_pending_result = MagicMock()
  1660. select_pending_result.all = MagicMock(return_value=[(301,)])
  1661. update_result = MagicMock()
  1662. mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
  1663. mock_session_factory = MagicMock()
  1664. mock_session_ctx = AsyncMock()
  1665. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1666. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1667. mock_session_factory.return_value = mock_session_ctx
  1668. inst = VirtualPrinterInstance(
  1669. vp_id=96,
  1670. name="RaceCommitYield",
  1671. mode="queue",
  1672. model="O1C2",
  1673. access_code="12345678",
  1674. serial_suffix="391800096",
  1675. base_dir=tmp_path,
  1676. session_factory=mock_session_factory,
  1677. )
  1678. inst._mqtt = MagicMock()
  1679. file_path = tmp_path / "test.3mf"
  1680. file_path.write_bytes(b"fake3mf")
  1681. # Stash MQTT data on the FIRST commit (simulating MQTT arrival
  1682. # during _add_to_print_queue's commit yield); _restamp also calls
  1683. # db.commit later, so we one-shot the side effect.
  1684. commit_calls = {"n": 0}
  1685. async def _delayed_stash(*_args, **_kwargs):
  1686. commit_calls["n"] += 1
  1687. if commit_calls["n"] == 1:
  1688. inst._slicer_print_options[file_path.name] = {
  1689. "command": "project_file",
  1690. "file": file_path.name,
  1691. "nozzle_mapping": [0, 16, -1, -1],
  1692. "timelapse": False,
  1693. }
  1694. return None
  1695. mock_db.commit = AsyncMock(side_effect=_delayed_stash)
  1696. mock_archive = MagicMock()
  1697. mock_archive.id = 1
  1698. mock_archive.print_name = "test"
  1699. with (
  1700. patch(
  1701. "backend.app.api.routes.settings.get_setting",
  1702. new_callable=AsyncMock,
  1703. return_value=None,
  1704. ),
  1705. patch(
  1706. "backend.app.services.archive.ArchiveService.archive_print",
  1707. new_callable=AsyncMock,
  1708. return_value=mock_archive,
  1709. ),
  1710. patch(
  1711. "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
  1712. 0.05,
  1713. ),
  1714. ):
  1715. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1716. # Queue item INSERTed with defaults (wait timed out, no slicer_opts).
  1717. assert len(added_items) == 1
  1718. # But the post-commit pop caught the late stash and applied the
  1719. # slicer nozzle_mapping via _restamp's UPDATE.
  1720. update_call = mock_db.execute.await_args_list[2]
  1721. update_stmt = update_call.args[0]
  1722. compiled = update_stmt.compile(compile_kwargs={"literal_binds": False})
  1723. params = dict(compiled.params)
  1724. assert _json.loads(params["nozzle_mapping"]) == [0, 16, -1, -1]
  1725. assert params["timelapse"] is False
  1726. # _recent_queue_items entry was consumed by the post-commit
  1727. # _restamp call.
  1728. assert file_path.name not in inst._recent_queue_items
  1729. # And the stash is empty.
  1730. assert file_path.name not in inst._slicer_print_options
  1731. @pytest.mark.asyncio
  1732. async def test_on_print_command_late_mqtt_skips_already_dispatched_item(self, tmp_path):
  1733. """Once the scheduler has picked the queue item up (status != pending),
  1734. the retroactive patch is a no-op — racing the dispatcher would be
  1735. unsafe.
  1736. """
  1737. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1738. mock_db = AsyncMock()
  1739. # The eligible-pending SELECT returns nothing — item is no longer pending.
  1740. empty_result = MagicMock()
  1741. empty_result.all = MagicMock(return_value=[])
  1742. mock_db.execute = AsyncMock(return_value=empty_result)
  1743. mock_db.commit = AsyncMock()
  1744. mock_session_factory = MagicMock()
  1745. mock_session_ctx = AsyncMock()
  1746. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1747. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1748. mock_session_factory.return_value = mock_session_ctx
  1749. inst = VirtualPrinterInstance(
  1750. vp_id=98,
  1751. name="LateMQTTDispatched",
  1752. mode="queue",
  1753. model="O1C2",
  1754. access_code="12345678",
  1755. serial_suffix="391800098",
  1756. base_dir=tmp_path,
  1757. session_factory=mock_session_factory,
  1758. )
  1759. inst._mqtt = MagicMock()
  1760. # Pre-seed the recent-queue dict — pretend _add_to_print_queue just
  1761. # committed item id 42.
  1762. inst._recent_queue_items["test.3mf"] = ([42], 1_000_000.0)
  1763. # Drive _restamp via on_print_command on the late-MQTT path.
  1764. with patch("backend.app.services.virtual_printer.manager.time.monotonic", return_value=1_000_001.0):
  1765. await inst.on_print_command(
  1766. "test.3mf",
  1767. {
  1768. "command": "project_file",
  1769. "file": "test.3mf",
  1770. "nozzle_mapping": [16, -1],
  1771. },
  1772. )
  1773. # No UPDATE was issued — only the eligibility SELECT ran.
  1774. assert mock_db.execute.await_count == 1
  1775. mock_db.commit.assert_not_awaited()
  1776. assert "test.3mf" not in inst._recent_queue_items
  1777. class TestVirtualPrinterManager:
  1778. """Tests for VirtualPrinterManager orchestrator."""
  1779. @pytest.fixture
  1780. def manager(self):
  1781. """Create a VirtualPrinterManager instance."""
  1782. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  1783. return VirtualPrinterManager()
  1784. def test_manager_starts_empty(self, manager):
  1785. """Verify manager starts with no instances."""
  1786. assert len(manager._instances) == 0
  1787. assert manager.is_enabled is False
  1788. def test_manager_get_status_empty(self, manager):
  1789. """Verify get_status returns disabled state when no instances."""
  1790. status = manager.get_status()
  1791. assert status["enabled"] is False
  1792. assert status["running"] is False
  1793. assert status["mode"] == "archive"
  1794. def test_manager_is_enabled_with_instance(self, manager, tmp_path):
  1795. """Verify is_enabled is True when instances exist."""
  1796. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1797. inst = VirtualPrinterInstance(
  1798. vp_id=1,
  1799. name="Test",
  1800. mode="archive",
  1801. model="C11",
  1802. access_code="12345678",
  1803. serial_suffix="391800001",
  1804. base_dir=tmp_path,
  1805. )
  1806. manager._instances[1] = inst
  1807. assert manager.is_enabled is True
  1808. @pytest.mark.asyncio
  1809. async def test_manager_remove_instance_server(self, manager, tmp_path):
  1810. """Verify remove_instance stops and removes a server-mode instance."""
  1811. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1812. inst = VirtualPrinterInstance(
  1813. vp_id=1,
  1814. name="Test",
  1815. mode="archive",
  1816. model="C11",
  1817. access_code="12345678",
  1818. serial_suffix="391800001",
  1819. base_dir=tmp_path,
  1820. )
  1821. inst.stop_server = AsyncMock()
  1822. manager._instances[1] = inst
  1823. await manager.remove_instance(1)
  1824. assert 1 not in manager._instances
  1825. inst.stop_server.assert_called_once()
  1826. @pytest.mark.asyncio
  1827. async def test_manager_remove_instance_proxy(self, manager, tmp_path):
  1828. """Verify remove_instance stops proxy-mode instance."""
  1829. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1830. inst = VirtualPrinterInstance(
  1831. vp_id=2,
  1832. name="Proxy",
  1833. mode="proxy",
  1834. model="C11",
  1835. access_code="",
  1836. serial_suffix="391800002",
  1837. target_printer_ip="192.168.1.100",
  1838. base_dir=tmp_path,
  1839. )
  1840. inst.stop_proxy = AsyncMock()
  1841. manager._instances[2] = inst
  1842. await manager.remove_instance(2)
  1843. assert 2 not in manager._instances
  1844. inst.stop_proxy.assert_called_once()
  1845. def test_manager_get_status_with_instance(self, manager, tmp_path):
  1846. """Verify legacy get_status returns first instance data."""
  1847. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1848. inst = VirtualPrinterInstance(
  1849. vp_id=1,
  1850. name="Bambuddy",
  1851. mode="archive",
  1852. model="C11",
  1853. access_code="12345678",
  1854. serial_suffix="391800001",
  1855. base_dir=tmp_path,
  1856. )
  1857. mock_task = MagicMock(done=MagicMock(return_value=False))
  1858. inst._tasks = [mock_task]
  1859. inst._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")} # nosec B108
  1860. manager._instances[1] = inst
  1861. status = manager.get_status()
  1862. assert status["enabled"] is True
  1863. assert status["running"] is True
  1864. assert status["mode"] == "archive"
  1865. assert status["name"] == "Bambuddy"
  1866. assert status["serial"] == "01S00A391800001"
  1867. assert status["model"] == "C11"
  1868. assert status["model_name"] == "P1P"
  1869. assert status["pending_files"] == 1
  1870. def test_manager_get_all_status(self, manager, tmp_path):
  1871. """Verify get_all_status returns status for all instances."""
  1872. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1873. for i in range(1, 3):
  1874. inst = VirtualPrinterInstance(
  1875. vp_id=i,
  1876. name=f"VP{i}",
  1877. mode="archive",
  1878. model="C11",
  1879. access_code="12345678",
  1880. serial_suffix=f"39180000{i}",
  1881. base_dir=tmp_path,
  1882. )
  1883. manager._instances[i] = inst
  1884. statuses = manager.get_all_status()
  1885. assert len(statuses) == 2
  1886. assert statuses[0]["name"] == "VP1"
  1887. assert statuses[1]["name"] == "VP2"
  1888. @pytest.mark.asyncio
  1889. async def test_manager_stop_all(self, manager, tmp_path):
  1890. """Verify stop_all removes all instances."""
  1891. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1892. for i in range(1, 3):
  1893. inst = VirtualPrinterInstance(
  1894. vp_id=i,
  1895. name=f"VP{i}",
  1896. mode="archive",
  1897. model="C11",
  1898. access_code="12345678",
  1899. serial_suffix=f"39180000{i}",
  1900. base_dir=tmp_path,
  1901. )
  1902. inst.stop_server = AsyncMock()
  1903. manager._instances[i] = inst
  1904. await manager.stop_all()
  1905. assert len(manager._instances) == 0
  1906. # ========================================================================
  1907. # Tests for sync_from_db config change detection
  1908. # ========================================================================
  1909. def _make_db_vp(self, **overrides):
  1910. """Create a mock VirtualPrinter DB object."""
  1911. defaults = {
  1912. "id": 1,
  1913. "name": "TestVP",
  1914. "enabled": True,
  1915. "mode": "archive",
  1916. "model": "C11",
  1917. "access_code": "12345678",
  1918. "serial_suffix": "391800001",
  1919. "bind_ip": "",
  1920. "remote_interface_ip": "",
  1921. "target_printer_id": None,
  1922. "auto_dispatch": True,
  1923. "tailscale_disabled": True, # Opt-in default (#1070 UX fix)
  1924. "queue_force_color_match": False, # default — must be explicit so MagicMock truthiness doesn't trip the change detector
  1925. "gcode_injection": False, # same reason as above
  1926. "position": 0,
  1927. }
  1928. defaults.update(overrides)
  1929. vp = MagicMock()
  1930. for k, v in defaults.items():
  1931. setattr(vp, k, v)
  1932. return vp
  1933. def _setup_sync_mocks(self, manager, enabled_vps, tmp_path):
  1934. """Wire up session_factory mock for sync_from_db."""
  1935. mock_result = MagicMock()
  1936. mock_result.scalars.return_value.all.return_value = enabled_vps
  1937. mock_db = AsyncMock()
  1938. mock_db.execute = AsyncMock(return_value=mock_result)
  1939. mock_db.__aenter__ = AsyncMock(return_value=mock_db)
  1940. mock_db.__aexit__ = AsyncMock(return_value=False)
  1941. manager._session_factory = MagicMock(return_value=mock_db)
  1942. manager._base_dir = tmp_path
  1943. @pytest.mark.asyncio
  1944. async def test_sync_from_db_restarts_on_mode_change(self, manager, tmp_path):
  1945. """Verify sync_from_db restarts VP when mode changes."""
  1946. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1947. inst = VirtualPrinterInstance(
  1948. vp_id=1,
  1949. name="TestVP",
  1950. mode="archive",
  1951. model="C11",
  1952. access_code="12345678",
  1953. serial_suffix="391800001",
  1954. base_dir=tmp_path,
  1955. )
  1956. inst.stop_server = AsyncMock()
  1957. manager._instances[1] = inst
  1958. # DB says mode changed to "review"
  1959. db_vp = self._make_db_vp(mode="review")
  1960. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  1961. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  1962. # Patch VirtualPrinterInstance to prevent actual start
  1963. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  1964. mock_new = MagicMock()
  1965. mock_new.start_server = AsyncMock()
  1966. MockInst.return_value = mock_new
  1967. await manager.sync_from_db()
  1968. mock_remove.assert_called_once_with(1)
  1969. @pytest.mark.asyncio
  1970. async def test_sync_from_db_restarts_on_access_code_change(self, manager, tmp_path):
  1971. """Verify sync_from_db restarts VP when access_code changes."""
  1972. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1973. inst = VirtualPrinterInstance(
  1974. vp_id=1,
  1975. name="TestVP",
  1976. mode="archive",
  1977. model="C11",
  1978. access_code="12345678",
  1979. serial_suffix="391800001",
  1980. base_dir=tmp_path,
  1981. )
  1982. inst.stop_server = AsyncMock()
  1983. manager._instances[1] = inst
  1984. db_vp = self._make_db_vp(access_code="newcode99")
  1985. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  1986. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  1987. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  1988. mock_new = MagicMock()
  1989. mock_new.start_server = AsyncMock()
  1990. MockInst.return_value = mock_new
  1991. await manager.sync_from_db()
  1992. mock_remove.assert_called_once_with(1)
  1993. @pytest.mark.asyncio
  1994. async def test_sync_from_db_skips_unchanged_instance(self, manager, tmp_path):
  1995. """Verify sync_from_db does NOT restart when config is identical."""
  1996. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1997. inst = VirtualPrinterInstance(
  1998. vp_id=1,
  1999. name="TestVP",
  2000. mode="archive",
  2001. model="C11",
  2002. access_code="12345678",
  2003. serial_suffix="391800001",
  2004. base_dir=tmp_path,
  2005. )
  2006. manager._instances[1] = inst
  2007. # DB matches running config exactly
  2008. db_vp = self._make_db_vp()
  2009. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  2010. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  2011. await manager.sync_from_db()
  2012. mock_remove.assert_not_called()
  2013. @pytest.mark.asyncio
  2014. async def test_sync_from_db_restarts_on_bind_ip_change(self, manager, tmp_path):
  2015. """Verify sync_from_db restarts VP when bind_ip changes."""
  2016. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2017. inst = VirtualPrinterInstance(
  2018. vp_id=1,
  2019. name="TestVP",
  2020. mode="archive",
  2021. model="C11",
  2022. access_code="12345678",
  2023. serial_suffix="391800001",
  2024. bind_ip="192.168.1.10",
  2025. base_dir=tmp_path,
  2026. )
  2027. inst.stop_server = AsyncMock()
  2028. manager._instances[1] = inst
  2029. db_vp = self._make_db_vp(bind_ip="192.168.1.20")
  2030. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  2031. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  2032. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  2033. mock_new = MagicMock()
  2034. mock_new.start_server = AsyncMock()
  2035. MockInst.return_value = mock_new
  2036. await manager.sync_from_db()
  2037. mock_remove.assert_called_once_with(1)
  2038. @pytest.mark.asyncio
  2039. async def test_sync_from_db_restarts_on_model_change(self, manager, tmp_path):
  2040. """Verify sync_from_db restarts VP when model changes."""
  2041. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2042. inst = VirtualPrinterInstance(
  2043. vp_id=1,
  2044. name="TestVP",
  2045. mode="archive",
  2046. model="C11",
  2047. access_code="12345678",
  2048. serial_suffix="391800001",
  2049. base_dir=tmp_path,
  2050. )
  2051. inst.stop_server = AsyncMock()
  2052. manager._instances[1] = inst
  2053. db_vp = self._make_db_vp(model="C12")
  2054. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  2055. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  2056. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  2057. mock_new = MagicMock()
  2058. mock_new.start_server = AsyncMock()
  2059. MockInst.return_value = mock_new
  2060. await manager.sync_from_db()
  2061. mock_remove.assert_called_once_with(1)
  2062. @pytest.mark.asyncio
  2063. async def test_sync_from_db_does_not_restart_on_tailscale_toggle(self, manager, tmp_path):
  2064. """Flipping tailscale_disabled is purely informational — must NOT trigger a restart.
  2065. Cert provisioning was removed; the toggle only governs whether the VP card surfaces
  2066. the host's Tailscale IP/FQDN to the user. No service needs to reload, so changing
  2067. it through sync_from_db should leave any running instance untouched.
  2068. """
  2069. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2070. inst = VirtualPrinterInstance(
  2071. vp_id=1,
  2072. name="TestVP",
  2073. mode="archive",
  2074. model="C11",
  2075. access_code="12345678",
  2076. serial_suffix="391800001",
  2077. tailscale_disabled=False,
  2078. base_dir=tmp_path,
  2079. )
  2080. inst.stop_server = AsyncMock()
  2081. manager._instances[1] = inst
  2082. db_vp = self._make_db_vp(tailscale_disabled=True)
  2083. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  2084. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  2085. await manager.sync_from_db()
  2086. mock_remove.assert_not_called()
  2087. @pytest.mark.asyncio
  2088. async def test_sync_from_db_restarts_on_gcode_injection_toggle(self, manager, tmp_path):
  2089. """Toggling gcode_injection in the DB must restart the running instance.
  2090. Without this, the in-memory ``self.gcode_injection`` keeps its old value
  2091. and ``_add_to_print_queue`` stamps the stale flag on every new queue
  2092. item — so disabling injection in the UI silently has no effect until
  2093. the process restarts.
  2094. """
  2095. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2096. inst = VirtualPrinterInstance(
  2097. vp_id=1,
  2098. name="TestVP",
  2099. mode="archive",
  2100. model="C11",
  2101. access_code="12345678",
  2102. serial_suffix="391800001",
  2103. gcode_injection=True,
  2104. base_dir=tmp_path,
  2105. )
  2106. inst.stop_server = AsyncMock()
  2107. manager._instances[1] = inst
  2108. db_vp = self._make_db_vp(gcode_injection=False)
  2109. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  2110. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  2111. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  2112. mock_new = MagicMock()
  2113. mock_new.start_server = AsyncMock()
  2114. MockInst.return_value = mock_new
  2115. await manager.sync_from_db()
  2116. mock_remove.assert_called_once_with(1)
  2117. class TestFTPSession:
  2118. """Tests for FTP session handling."""
  2119. @pytest.fixture
  2120. def mock_reader(self):
  2121. """Create a mock StreamReader."""
  2122. reader = AsyncMock()
  2123. return reader
  2124. @pytest.fixture
  2125. def mock_writer(self):
  2126. """Create a mock StreamWriter."""
  2127. writer = MagicMock()
  2128. writer.get_extra_info = MagicMock(return_value=("192.168.1.100", 12345))
  2129. writer.write = MagicMock()
  2130. writer.drain = AsyncMock()
  2131. writer.close = MagicMock()
  2132. writer.wait_closed = AsyncMock()
  2133. writer.is_closing = MagicMock(return_value=False)
  2134. return writer
  2135. @pytest.fixture
  2136. def ssl_context(self):
  2137. """Create a mock SSL context."""
  2138. return MagicMock()
  2139. @pytest.fixture
  2140. def session(self, mock_reader, mock_writer, ssl_context, tmp_path):
  2141. """Create an FTPSession instance."""
  2142. from backend.app.services.virtual_printer.ftp_server import FTPSession
  2143. return FTPSession(
  2144. reader=mock_reader,
  2145. writer=mock_writer,
  2146. upload_dir=tmp_path,
  2147. access_code="12345678",
  2148. ssl_context=ssl_context,
  2149. on_file_received=None,
  2150. )
  2151. # ========================================================================
  2152. # Tests for authentication
  2153. # ========================================================================
  2154. @pytest.mark.asyncio
  2155. async def test_user_command_accepts_bblp(self, session):
  2156. """Verify USER command accepts bblp user."""
  2157. await session.cmd_USER("bblp")
  2158. assert session.username == "bblp"
  2159. @pytest.mark.asyncio
  2160. async def test_pass_command_authenticates(self, session):
  2161. """Verify PASS command authenticates with correct code."""
  2162. session.username = "bblp"
  2163. await session.cmd_PASS("12345678")
  2164. assert session.authenticated is True
  2165. @pytest.mark.asyncio
  2166. async def test_pass_command_rejects_wrong_code(self, session):
  2167. """Verify PASS command rejects wrong access code."""
  2168. session.username = "bblp"
  2169. await session.cmd_PASS("wrongcode")
  2170. assert session.authenticated is False
  2171. # ========================================================================
  2172. # Tests for FTP commands
  2173. # ========================================================================
  2174. @pytest.mark.asyncio
  2175. async def test_syst_command(self, session):
  2176. """Verify SYST returns UNIX type."""
  2177. await session.cmd_SYST("")
  2178. session.writer.write.assert_called()
  2179. call_args = session.writer.write.call_args[0][0].decode()
  2180. assert "215" in call_args
  2181. assert "UNIX" in call_args
  2182. @pytest.mark.asyncio
  2183. async def test_pwd_command_requires_auth(self, session):
  2184. """Verify PWD requires authentication."""
  2185. session.authenticated = False
  2186. await session.cmd_PWD("")
  2187. call_args = session.writer.write.call_args[0][0].decode()
  2188. assert "530" in call_args
  2189. @pytest.mark.asyncio
  2190. async def test_pwd_command_when_authenticated(self, session):
  2191. """Verify PWD returns root directory when authenticated."""
  2192. session.authenticated = True
  2193. await session.cmd_PWD("")
  2194. call_args = session.writer.write.call_args[0][0].decode()
  2195. assert "257" in call_args
  2196. @pytest.mark.asyncio
  2197. async def test_type_command_sets_binary(self, session):
  2198. """Verify TYPE I sets binary mode."""
  2199. session.authenticated = True
  2200. await session.cmd_TYPE("I")
  2201. assert session.transfer_type == "I"
  2202. @pytest.mark.asyncio
  2203. async def test_pbsz_command(self, session):
  2204. """Verify PBSZ returns success."""
  2205. await session.cmd_PBSZ("0")
  2206. call_args = session.writer.write.call_args[0][0].decode()
  2207. assert "200" in call_args
  2208. @pytest.mark.asyncio
  2209. async def test_prot_command_accepts_p(self, session):
  2210. """Verify PROT P is accepted."""
  2211. await session.cmd_PROT("P")
  2212. call_args = session.writer.write.call_args[0][0].decode()
  2213. assert "200" in call_args
  2214. @pytest.mark.asyncio
  2215. async def test_quit_command(self, session):
  2216. """Verify QUIT sends goodbye and raises CancelledError."""
  2217. with pytest.raises(asyncio.CancelledError):
  2218. await session.cmd_QUIT("")
  2219. class TestSSDPServer:
  2220. """Tests for Virtual Printer SSDP server."""
  2221. @pytest.fixture
  2222. def ssdp_server(self):
  2223. """Create a VirtualPrinterSSDPServer instance."""
  2224. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  2225. return VirtualPrinterSSDPServer(
  2226. serial="TEST123",
  2227. name="TestPrinter",
  2228. model="BL-P001",
  2229. )
  2230. # ========================================================================
  2231. # Tests for SSDP response
  2232. # ========================================================================
  2233. def test_build_notify_message(self, ssdp_server):
  2234. """Verify NOTIFY packet contains required headers."""
  2235. # Set a known IP for testing
  2236. ssdp_server._local_ip = "192.168.1.100"
  2237. message = ssdp_server._build_notify_message()
  2238. assert b"NOTIFY" in message
  2239. assert b"DevName.bambu.com: TestPrinter" in message
  2240. assert b"USN: TEST123" in message
  2241. def test_build_response_message(self, ssdp_server):
  2242. """Verify response packet contains required headers."""
  2243. # Set a known IP for testing
  2244. ssdp_server._local_ip = "192.168.1.100"
  2245. message = ssdp_server._build_response_message()
  2246. assert b"HTTP/1.1 200 OK" in message
  2247. assert b"DevName.bambu.com: TestPrinter" in message
  2248. assert b"USN: TEST123" in message
  2249. def test_ssdp_server_uses_correct_model(self, ssdp_server):
  2250. """Verify SSDP server uses the provided model."""
  2251. ssdp_server._local_ip = "192.168.1.100"
  2252. message = ssdp_server._build_notify_message()
  2253. assert b"DevModel.bambu.com: BL-P001" in message
  2254. # ========================================================================
  2255. # Tests for advertise_ip parameter
  2256. # ========================================================================
  2257. def test_advertise_ip_sets_local_ip(self):
  2258. """Verify advertise_ip overrides auto-detection."""
  2259. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  2260. server = VirtualPrinterSSDPServer(
  2261. serial="TEST123",
  2262. name="TestPrinter",
  2263. model="BL-P001",
  2264. advertise_ip="10.0.0.50",
  2265. )
  2266. assert server._local_ip == "10.0.0.50"
  2267. def test_advertise_ip_empty_string_uses_auto_detect(self):
  2268. """Verify empty advertise_ip falls back to auto-detection."""
  2269. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  2270. server = VirtualPrinterSSDPServer(
  2271. serial="TEST123",
  2272. name="TestPrinter",
  2273. model="BL-P001",
  2274. advertise_ip="",
  2275. )
  2276. assert server._local_ip is None
  2277. def test_advertise_ip_in_notify_message(self):
  2278. """Verify NOTIFY message uses the advertise_ip."""
  2279. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  2280. server = VirtualPrinterSSDPServer(
  2281. serial="TEST123",
  2282. name="TestPrinter",
  2283. model="BL-P001",
  2284. advertise_ip="10.0.0.50",
  2285. )
  2286. message = server._build_notify_message()
  2287. assert b"Location: 10.0.0.50" in message
  2288. def test_advertise_ip_in_response_message(self):
  2289. """Verify M-SEARCH response uses the advertise_ip."""
  2290. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  2291. server = VirtualPrinterSSDPServer(
  2292. serial="TEST123",
  2293. name="TestPrinter",
  2294. model="BL-P001",
  2295. advertise_ip="10.0.0.50",
  2296. )
  2297. message = server._build_response_message()
  2298. assert b"Location: 10.0.0.50" in message
  2299. def test_default_no_advertise_ip(self):
  2300. """Verify default constructor has None local_ip (auto-detect)."""
  2301. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  2302. server = VirtualPrinterSSDPServer()
  2303. assert server._local_ip is None
  2304. class TestCertificateService:
  2305. """Tests for TLS certificate generation."""
  2306. @pytest.fixture
  2307. def cert_service(self, tmp_path):
  2308. """Create a CertificateService instance."""
  2309. from backend.app.services.virtual_printer.certificate import CertificateService
  2310. return CertificateService(cert_dir=tmp_path, serial="TEST123")
  2311. def test_generate_certificates(self, cert_service, tmp_path):
  2312. """Verify certificates are generated correctly."""
  2313. cert_path, key_path = cert_service.generate_certificates()
  2314. assert cert_path.exists()
  2315. assert key_path.exists()
  2316. # Verify certificate content
  2317. cert_content = cert_path.read_text()
  2318. assert "BEGIN CERTIFICATE" in cert_content
  2319. key_content = key_path.read_text()
  2320. assert "BEGIN" in key_content and "KEY" in key_content
  2321. def test_certificates_reused_if_exist(self, cert_service):
  2322. """Verify existing certificates are reused."""
  2323. # First generation
  2324. cert_path1, key_path1 = cert_service.generate_certificates()
  2325. mtime1 = cert_path1.stat().st_mtime
  2326. # Second call should reuse (via ensure_certificates)
  2327. cert_path2, key_path2 = cert_service.ensure_certificates()
  2328. mtime2 = cert_path2.stat().st_mtime
  2329. assert mtime1 == mtime2 # File wasn't regenerated
  2330. def test_delete_certificates(self, cert_service):
  2331. """Verify certificates can be deleted."""
  2332. cert_service.generate_certificates()
  2333. assert cert_service.cert_path.exists()
  2334. assert cert_service.key_path.exists()
  2335. cert_service.delete_certificates()
  2336. assert not cert_service.cert_path.exists()
  2337. assert not cert_service.key_path.exists()
  2338. def test_ensure_creates_if_not_exist(self, cert_service):
  2339. """Verify ensure_certificates generates if not existing."""
  2340. assert not cert_service.cert_path.exists()
  2341. cert_path, key_path = cert_service.ensure_certificates()
  2342. assert cert_path.exists()
  2343. assert key_path.exists()
  2344. class TestBindServer:
  2345. """Tests for BindServer (port 3002 bind/detect protocol)."""
  2346. @pytest.fixture
  2347. def bind_server(self):
  2348. """Create a BindServer instance."""
  2349. from backend.app.services.virtual_printer.bind_server import BindServer
  2350. return BindServer(
  2351. serial="09400A391800001",
  2352. model="O1D",
  2353. name="Bambuddy",
  2354. )
  2355. def test_build_frame(self, bind_server):
  2356. """Verify frame building produces correct format."""
  2357. payload = {"login": {"command": "detect"}}
  2358. frame = bind_server._build_frame(payload)
  2359. # Header: 0xA5A5
  2360. assert frame[:2] == b"\xa5\xa5"
  2361. # Trailer: 0xA7A7
  2362. assert frame[-2:] == b"\xa7\xa7"
  2363. # Length field is total message size (LE uint16)
  2364. import struct
  2365. total_len = struct.unpack_from("<H", frame, 2)[0]
  2366. assert total_len == len(frame)
  2367. # JSON payload is between header and trailer
  2368. import json
  2369. json_bytes = frame[4:-2]
  2370. parsed = json.loads(json_bytes)
  2371. assert parsed == payload
  2372. def test_parse_frame_valid(self, bind_server):
  2373. """Verify valid frame parsing extracts JSON correctly."""
  2374. import json
  2375. import struct
  2376. payload = {"login": {"command": "detect", "sequence_id": "20000"}}
  2377. json_bytes = json.dumps(payload, separators=(",", ":")).encode()
  2378. total_len = 4 + len(json_bytes) + 2
  2379. frame = b"\xa5\xa5" + struct.pack("<H", total_len) + json_bytes + b"\xa7\xa7"
  2380. result = bind_server._parse_frame(frame)
  2381. assert result is not None
  2382. assert result["login"]["command"] == "detect"
  2383. assert result["login"]["sequence_id"] == "20000"
  2384. def test_parse_frame_invalid_header(self, bind_server):
  2385. """Verify invalid header returns None."""
  2386. result = bind_server._parse_frame(b"\xbb\xbb\x06\x00{}\xa7\xa7")
  2387. assert result is None
  2388. def test_parse_frame_invalid_trailer(self, bind_server):
  2389. """Verify invalid trailer returns None."""
  2390. result = bind_server._parse_frame(b"\xa5\xa5\x06\x00{}\xbb\xbb")
  2391. assert result is None
  2392. def test_parse_frame_too_short(self, bind_server):
  2393. """Verify short data returns None."""
  2394. result = bind_server._parse_frame(b"\xa5\xa5\x00")
  2395. assert result is None
  2396. def test_parse_frame_invalid_json(self, bind_server):
  2397. """Verify invalid JSON returns None."""
  2398. import struct
  2399. bad_json = b"not json"
  2400. total_len = 4 + len(bad_json) + 2
  2401. frame = b"\xa5\xa5" + struct.pack("<H", total_len) + bad_json + b"\xa7\xa7"
  2402. result = bind_server._parse_frame(frame)
  2403. assert result is None
  2404. def test_build_frame_roundtrip(self, bind_server):
  2405. """Verify build_frame output can be parsed back."""
  2406. payload = {
  2407. "login": {
  2408. "bind": "free",
  2409. "command": "detect",
  2410. "connect": "lan",
  2411. "dev_cap": 1,
  2412. "id": "09400A391800001",
  2413. "model": "O1D",
  2414. "name": "Bambuddy",
  2415. "sequence_id": 3021,
  2416. "version": "01.00.00.00",
  2417. }
  2418. }
  2419. frame = bind_server._build_frame(payload)
  2420. parsed = bind_server._parse_frame(frame)
  2421. assert parsed is not None
  2422. assert parsed["login"]["id"] == "09400A391800001"
  2423. assert parsed["login"]["model"] == "O1D"
  2424. assert parsed["login"]["name"] == "Bambuddy"
  2425. assert parsed["login"]["bind"] == "free"
  2426. def test_bind_server_stores_config(self, bind_server):
  2427. """Verify bind server stores serial, model, name."""
  2428. assert bind_server.serial == "09400A391800001"
  2429. assert bind_server.model == "O1D"
  2430. assert bind_server.name == "Bambuddy"
  2431. assert bind_server.version == "01.00.00.00"
  2432. def test_bind_server_custom_version(self):
  2433. """Verify custom firmware version is stored."""
  2434. from backend.app.services.virtual_printer.bind_server import BindServer
  2435. server = BindServer(
  2436. serial="TEST123",
  2437. model="C13",
  2438. name="Test",
  2439. version="02.03.04.05",
  2440. )
  2441. assert server.version == "02.03.04.05"
  2442. def test_bind_ports_constant(self):
  2443. """Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility."""
  2444. from backend.app.services.virtual_printer.bind_server import BIND_PORTS
  2445. assert 3000 in BIND_PORTS
  2446. assert 3002 in BIND_PORTS
  2447. def test_bind_server_initializes_empty_servers_list(self, bind_server):
  2448. """Verify bind server starts with empty servers list."""
  2449. assert bind_server._servers == []
  2450. assert bind_server._running is False
  2451. class TestSlicerProxyManager:
  2452. """Tests for SlicerProxyManager (proxy mode)."""
  2453. @pytest.fixture
  2454. def proxy_manager(self, tmp_path):
  2455. """Create a SlicerProxyManager instance."""
  2456. from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
  2457. # Create dummy cert files
  2458. cert_path = tmp_path / "cert.pem"
  2459. key_path = tmp_path / "key.pem"
  2460. cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
  2461. # Split string to avoid pre-commit hook false positive on test data
  2462. key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
  2463. return SlicerProxyManager(
  2464. target_host="192.168.1.100",
  2465. cert_path=cert_path,
  2466. key_path=key_path,
  2467. )
  2468. def test_proxy_manager_initializes_ports(self, proxy_manager):
  2469. """Verify proxy manager has correct port constants."""
  2470. # FTP proxy uses privileged port 990 to match what Bambu Studio expects
  2471. assert proxy_manager.LOCAL_FTP_PORT == 990
  2472. assert proxy_manager.LOCAL_MQTT_PORT == 8883
  2473. assert proxy_manager.PRINTER_FTP_PORT == 990
  2474. assert proxy_manager.PRINTER_MQTT_PORT == 8883
  2475. assert proxy_manager.PRINTER_FILE_TRANSFER_PORT == 6000
  2476. assert proxy_manager.PRINTER_RTSP_PORT == 322
  2477. # Auxiliary ports: undocumented proprietary ports for A1/P1S etc.
  2478. assert proxy_manager.PRINTER_AUX_PORTS == [2024, 2025, 2026]
  2479. # Bind ports: both 3000 and 3002 for slicer compatibility
  2480. assert proxy_manager.PRINTER_BIND_PORTS == [3000, 3002]
  2481. # FTP data port range for transparent EPSV proxying
  2482. assert proxy_manager.FTP_DATA_PORT_MIN == 50000
  2483. assert proxy_manager.FTP_DATA_PORT_MAX == 50100
  2484. def test_proxy_manager_stores_target_host(self, proxy_manager):
  2485. """Verify proxy manager stores target host."""
  2486. assert proxy_manager.target_host == "192.168.1.100"
  2487. def test_get_status_before_start(self, proxy_manager):
  2488. """Verify get_status returns zeros before start."""
  2489. status = proxy_manager.get_status()
  2490. assert status["running"] is False
  2491. assert status["ftp_connections"] == 0
  2492. assert status["mqtt_connections"] == 0
  2493. @pytest.mark.asyncio
  2494. async def test_proxy_start_creates_transparent_proxies(self, tmp_path):
  2495. """Verify start() uses TCPProxy for FTP/FileTransfer/RTSP and TLSProxy only for MQTT.
  2496. The transparent proxy architecture preserves end-to-end TLS between
  2497. slicer and printer for all protocols except MQTT, which must be
  2498. TLS-terminated to rewrite the printer's IP in MQTT payloads.
  2499. """
  2500. from unittest.mock import patch
  2501. from backend.app.services.virtual_printer.tcp_proxy import (
  2502. SlicerProxyManager,
  2503. TCPProxy,
  2504. TLSProxy,
  2505. )
  2506. cert_path = tmp_path / "cert.pem"
  2507. key_path = tmp_path / "key.pem"
  2508. cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
  2509. key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
  2510. mgr = SlicerProxyManager(
  2511. target_host="192.168.1.100",
  2512. cert_path=cert_path,
  2513. key_path=key_path,
  2514. bind_address="10.0.0.1",
  2515. )
  2516. # Mock asyncio.create_task and asyncio.gather to prevent actual
  2517. # server start. Close every coroutine handed to gather — otherwise
  2518. # the ~110 run_with_logging() coros built inside start() are
  2519. # garbage-collected unfinalized and surface later as
  2520. # PytestUnraisableExceptionWarning at random in other tests.
  2521. async def _close_pending(*coros, **_):
  2522. for c in coros:
  2523. if asyncio.iscoroutine(c):
  2524. c.close()
  2525. with (
  2526. patch("asyncio.create_task") as mock_create_task,
  2527. patch("asyncio.gather", side_effect=_close_pending),
  2528. patch.object(SlicerProxyManager, "_log_activity"),
  2529. ):
  2530. mock_create_task.return_value = MagicMock()
  2531. await mgr.start()
  2532. # FTP, FileTransfer, RTSP should be TCPProxy (transparent)
  2533. assert isinstance(mgr._ftp_proxy, TCPProxy), "FTP should be TCPProxy (transparent)"
  2534. assert isinstance(mgr._file_transfer_proxy, TCPProxy), "FileTransfer should be TCPProxy"
  2535. assert isinstance(mgr._rtsp_proxy, TCPProxy), "RTSP should be TCPProxy"
  2536. # MQTT should be TLSProxy (TLS-terminated for IP rewriting)
  2537. assert isinstance(mgr._mqtt_proxy, TLSProxy), "MQTT should be TLSProxy (TLS-terminated)"
  2538. # Auxiliary ports (2024-2026) should be TCPProxy (transparent)
  2539. assert len(mgr._aux_proxies) == 3, "Should have 3 aux port proxies"
  2540. for ap in mgr._aux_proxies:
  2541. assert isinstance(ap, TCPProxy), "Aux proxies should be TCPProxy"
  2542. assert mgr._aux_proxies[0].listen_port == 2024
  2543. assert mgr._aux_proxies[0].target_port == 2024
  2544. assert mgr._aux_proxies[2].listen_port == 2026
  2545. # FTP data ports should be pre-created as TCPProxy instances
  2546. assert len(mgr._ftp_data_proxies) == 101 # 50000-50100 inclusive
  2547. for dp in mgr._ftp_data_proxies:
  2548. assert isinstance(dp, TCPProxy), "FTP data proxies should be TCPProxy"
  2549. # Verify FTP data proxies target the same port on the printer
  2550. first_dp = mgr._ftp_data_proxies[0]
  2551. assert first_dp.listen_port == 50000
  2552. assert first_dp.target_port == 50000
  2553. assert first_dp.target_host == "192.168.1.100"
  2554. last_dp = mgr._ftp_data_proxies[-1]
  2555. assert last_dp.listen_port == 50100
  2556. assert last_dp.target_port == 50100
  2557. def test_proxy_manager_mqtt_has_ip_rewriting(self, tmp_path):
  2558. """Verify MQTT proxy is configured with IP rewriting when bind_address is set."""
  2559. from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
  2560. cert_path = tmp_path / "cert.pem"
  2561. key_path = tmp_path / "key.pem"
  2562. cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
  2563. key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
  2564. mgr = SlicerProxyManager(
  2565. target_host="192.168.1.100",
  2566. cert_path=cert_path,
  2567. key_path=key_path,
  2568. bind_address="10.0.0.1",
  2569. )
  2570. # Before start, proxies are None — verify constructor stores rewrite config
  2571. assert mgr.bind_address == "10.0.0.1"
  2572. assert mgr.target_host == "192.168.1.100"
  2573. class TestSSDPProxy:
  2574. """Tests for SSDPProxy (cross-network SSDP relay)."""
  2575. @pytest.fixture
  2576. def ssdp_proxy(self):
  2577. """Create an SSDPProxy instance."""
  2578. from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
  2579. return SSDPProxy(
  2580. local_interface_ip="192.168.1.100",
  2581. remote_interface_ip="10.0.0.100",
  2582. target_printer_ip="192.168.1.50",
  2583. )
  2584. def test_ssdp_proxy_stores_interface_ips(self, ssdp_proxy):
  2585. """Verify SSDPProxy stores interface IPs correctly."""
  2586. assert ssdp_proxy.local_interface_ip == "192.168.1.100"
  2587. assert ssdp_proxy.remote_interface_ip == "10.0.0.100"
  2588. assert ssdp_proxy.target_printer_ip == "192.168.1.50"
  2589. def test_rewrite_ssdp_location(self, ssdp_proxy):
  2590. """Verify SSDP Location header is rewritten to remote interface IP."""
  2591. original_packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: TestPrinter\r\n\r\n"
  2592. rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
  2593. # Location should be changed to remote interface IP
  2594. assert b"Location: 10.0.0.100" in rewritten
  2595. assert b"Location: 192.168.1.50" not in rewritten
  2596. # Other headers should be preserved
  2597. assert b"DevName.bambu.com: TestPrinter" in rewritten
  2598. def test_rewrite_ssdp_location_case_insensitive(self, ssdp_proxy):
  2599. """Verify SSDP Location rewrite is case insensitive."""
  2600. original_packet = b"NOTIFY * HTTP/1.1\r\nlocation: 192.168.1.50\r\n\r\n"
  2601. rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
  2602. assert b"10.0.0.100" in rewritten
  2603. def test_rewrite_ssdp_location_no_match(self, ssdp_proxy):
  2604. """Verify packet without Location header is returned unchanged."""
  2605. original_packet = b"NOTIFY * HTTP/1.1\r\nDevName.bambu.com: Test\r\n\r\n"
  2606. rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
  2607. # No Location header, but _rewrite_ssdp logs a warning and returns as-is
  2608. assert b"DevName.bambu.com: Test" in rewritten
  2609. def test_parse_ssdp_message(self, ssdp_proxy):
  2610. """Verify SSDP message parsing extracts headers."""
  2611. packet = (
  2612. b"NOTIFY * HTTP/1.1\r\n"
  2613. b"Location: 192.168.1.50\r\n"
  2614. b"DevName.bambu.com: TestPrinter\r\n"
  2615. b"DevModel.bambu.com: BL-P001\r\n"
  2616. b"\r\n"
  2617. )
  2618. headers = ssdp_proxy._parse_ssdp_message(packet)
  2619. assert headers["location"] == "192.168.1.50"
  2620. assert headers["devname.bambu.com"] == "TestPrinter"
  2621. assert headers["devmodel.bambu.com"] == "BL-P001"
  2622. class TestVirtualPrinterManagerDirectories:
  2623. """Tests for VirtualPrinterManager directory management."""
  2624. def test_ensure_base_directories_creates_subdirs(self, tmp_path):
  2625. """Verify _ensure_base_directories creates required base directories."""
  2626. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  2627. manager = VirtualPrinterManager()
  2628. manager._base_dir = tmp_path / "virtual_printer"
  2629. manager._ensure_base_directories()
  2630. assert (tmp_path / "virtual_printer").exists()
  2631. assert (tmp_path / "virtual_printer" / "uploads").exists()
  2632. assert (tmp_path / "virtual_printer" / "certs").exists()
  2633. def test_ensure_base_directories_handles_permission_error(self, tmp_path, caplog):
  2634. """Verify _ensure_base_directories logs error on permission failure."""
  2635. import logging
  2636. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  2637. manager = VirtualPrinterManager()
  2638. vp_dir = tmp_path / "virtual_printer"
  2639. manager._base_dir = vp_dir
  2640. original_mkdir = type(vp_dir).mkdir
  2641. def mock_mkdir(self, *args, **kwargs):
  2642. if "virtual_printer" in str(self):
  2643. raise PermissionError("Permission denied")
  2644. return original_mkdir(self, *args, **kwargs)
  2645. with caplog.at_level(logging.ERROR), patch.object(type(vp_dir), "mkdir", mock_mkdir):
  2646. manager._ensure_base_directories()
  2647. assert "Permission denied" in caplog.text
  2648. def test_instance_creates_per_vp_directories(self, tmp_path):
  2649. """Verify VirtualPrinterInstance creates per-VP upload and cert dirs."""
  2650. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2651. VirtualPrinterInstance(
  2652. vp_id=42,
  2653. name="Test",
  2654. mode="archive",
  2655. model="C11",
  2656. access_code="12345678",
  2657. serial_suffix="391800042",
  2658. base_dir=tmp_path,
  2659. )
  2660. assert (tmp_path / "uploads" / "42").exists()
  2661. assert (tmp_path / "uploads" / "42" / "cache").exists()
  2662. assert (tmp_path / "certs" / "42").exists()
  2663. class TestVirtualPrinterInstanceProxyMode:
  2664. """Tests for VirtualPrinterInstance proxy mode."""
  2665. @pytest.fixture
  2666. def proxy_instance(self, tmp_path):
  2667. """Create a proxy-mode VirtualPrinterInstance."""
  2668. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2669. return VirtualPrinterInstance(
  2670. vp_id=10,
  2671. name="ProxyTest",
  2672. mode="proxy",
  2673. model="C11",
  2674. access_code="",
  2675. serial_suffix="391800010",
  2676. target_printer_ip="192.168.1.100",
  2677. target_printer_serial="01P00A000000001",
  2678. base_dir=tmp_path,
  2679. )
  2680. def test_proxy_instance_properties(self, proxy_instance):
  2681. """Verify proxy instance stores config correctly."""
  2682. assert proxy_instance.is_proxy is True
  2683. assert proxy_instance.mode == "proxy"
  2684. assert proxy_instance.target_printer_ip == "192.168.1.100"
  2685. assert proxy_instance.target_printer_serial == "01P00A000000001"
  2686. def test_proxy_instance_does_not_require_access_code(self, proxy_instance):
  2687. """Verify proxy mode can have empty access code."""
  2688. assert proxy_instance.access_code == ""
  2689. def test_get_status_proxy_includes_proxy_fields(self, proxy_instance):
  2690. """Verify get_status includes proxy fields when proxy is active."""
  2691. mock_proxy = MagicMock()
  2692. mock_proxy.get_status.return_value = {
  2693. "running": True,
  2694. "ftp_port": 990,
  2695. "mqtt_port": 8883,
  2696. "ftp_connections": 1,
  2697. "mqtt_connections": 2,
  2698. "target_host": "192.168.1.100",
  2699. }
  2700. proxy_instance._proxy = mock_proxy
  2701. status = proxy_instance.get_status()
  2702. assert "proxy" in status
  2703. assert status["proxy"]["ftp_port"] == 990
  2704. assert status["proxy"]["mqtt_connections"] == 2
  2705. def test_proxy_instance_stores_remote_interface(self, tmp_path):
  2706. """Verify proxy instance stores remote_interface_ip."""
  2707. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2708. inst = VirtualPrinterInstance(
  2709. vp_id=11,
  2710. name="Proxy2",
  2711. mode="proxy",
  2712. model="C11",
  2713. access_code="",
  2714. serial_suffix="391800011",
  2715. target_printer_ip="192.168.1.100",
  2716. remote_interface_ip="10.0.0.50",
  2717. base_dir=tmp_path,
  2718. )
  2719. assert inst.remote_interface_ip == "10.0.0.50"
  2720. class TestVirtualPrinterInstanceIPOverride:
  2721. """Tests for remote_interface_ip and bind_ip on VirtualPrinterInstance."""
  2722. @pytest.fixture
  2723. def instance_with_remote_ip(self, tmp_path):
  2724. """Create an instance with remote_interface_ip set."""
  2725. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2726. return VirtualPrinterInstance(
  2727. vp_id=20,
  2728. name="IPTest",
  2729. mode="archive",
  2730. model="BL-P001",
  2731. access_code="12345678",
  2732. serial_suffix="391800020",
  2733. bind_ip="192.168.1.50",
  2734. remote_interface_ip="10.0.0.50",
  2735. base_dir=tmp_path,
  2736. )
  2737. def test_instance_stores_bind_ip(self, instance_with_remote_ip):
  2738. """Verify bind_ip is stored."""
  2739. assert instance_with_remote_ip.bind_ip == "192.168.1.50"
  2740. def test_instance_stores_remote_interface_ip(self, instance_with_remote_ip):
  2741. """Verify remote_interface_ip is stored."""
  2742. assert instance_with_remote_ip.remote_interface_ip == "10.0.0.50"
  2743. def test_generate_certificates_includes_remote_and_bind_ip(self, instance_with_remote_ip):
  2744. """Verify generate_certificates passes remote_interface_ip and bind_ip as SANs."""
  2745. with (
  2746. patch.object(instance_with_remote_ip._cert_service, "delete_printer_certificate"),
  2747. patch.object(
  2748. instance_with_remote_ip._cert_service,
  2749. "generate_certificates",
  2750. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  2751. ) as mock_gen,
  2752. ):
  2753. instance_with_remote_ip.generate_certificates()
  2754. mock_gen.assert_called_once_with(additional_ips=["10.0.0.50", "192.168.1.50"])
  2755. def test_generate_certificates_no_remote_ip(self, tmp_path):
  2756. """Verify generate_certificates passes only bind_ip when no remote_interface_ip."""
  2757. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2758. inst = VirtualPrinterInstance(
  2759. vp_id=21,
  2760. name="NoRemote",
  2761. mode="archive",
  2762. model="BL-P001",
  2763. access_code="12345678",
  2764. serial_suffix="391800021",
  2765. bind_ip="192.168.1.50",
  2766. base_dir=tmp_path,
  2767. )
  2768. with (
  2769. patch.object(inst._cert_service, "delete_printer_certificate"),
  2770. patch.object(
  2771. inst._cert_service,
  2772. "generate_certificates",
  2773. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  2774. ) as mock_gen,
  2775. ):
  2776. inst.generate_certificates()
  2777. mock_gen.assert_called_once_with(additional_ips=["192.168.1.50"])
  2778. def test_generate_certificates_no_ips(self, tmp_path):
  2779. """Verify generate_certificates passes None when no IPs configured."""
  2780. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2781. inst = VirtualPrinterInstance(
  2782. vp_id=22,
  2783. name="NoIPs",
  2784. mode="archive",
  2785. model="BL-P001",
  2786. access_code="12345678",
  2787. serial_suffix="391800022",
  2788. base_dir=tmp_path,
  2789. )
  2790. with (
  2791. patch.object(inst._cert_service, "delete_printer_certificate"),
  2792. patch.object(
  2793. inst._cert_service,
  2794. "generate_certificates",
  2795. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  2796. ) as mock_gen,
  2797. ):
  2798. inst.generate_certificates()
  2799. mock_gen.assert_called_once_with(additional_ips=None)
  2800. class TestBindServer:
  2801. """Tests for the BindServer (port 3002 bind/detect protocol)."""
  2802. @pytest.fixture
  2803. def bind_server(self):
  2804. """Create a BindServer instance."""
  2805. from backend.app.services.virtual_printer.bind_server import BindServer
  2806. return BindServer(
  2807. serial="01S00C000000001",
  2808. model="BL-P001",
  2809. name="Bambuddy",
  2810. )
  2811. def test_build_frame(self, bind_server):
  2812. """Verify frame format: 0xA5A5 + len(u16le) + JSON + 0xA7A7."""
  2813. payload = {"login": {"command": "detect"}}
  2814. frame = bind_server._build_frame(payload)
  2815. assert frame[:2] == b"\xa5\xa5"
  2816. assert frame[-2:] == b"\xa7\xa7"
  2817. # Length field is total message size
  2818. import struct
  2819. total_len = struct.unpack_from("<H", frame, 2)[0]
  2820. assert total_len == len(frame)
  2821. # JSON payload is between header and trailer
  2822. import json
  2823. json_bytes = frame[4:-2]
  2824. parsed = json.loads(json_bytes)
  2825. assert parsed == payload
  2826. def test_parse_frame_valid(self, bind_server):
  2827. """Verify valid frame parsing."""
  2828. frame = bind_server._build_frame({"login": {"command": "detect", "sequence_id": "20000"}})
  2829. result = bind_server._parse_frame(frame)
  2830. assert result is not None
  2831. assert result["login"]["command"] == "detect"
  2832. assert result["login"]["sequence_id"] == "20000"
  2833. def test_parse_frame_invalid_header(self, bind_server):
  2834. """Verify invalid header returns None."""
  2835. frame = b"\xb5\xb5\x10\x00" + b'{"login":{}}' + b"\xa7\xa7"
  2836. assert bind_server._parse_frame(frame) is None
  2837. def test_parse_frame_invalid_trailer(self, bind_server):
  2838. """Verify invalid trailer returns None."""
  2839. frame = b"\xa5\xa5\x10\x00" + b'{"login":{}}' + b"\xb7\xb7"
  2840. assert bind_server._parse_frame(frame) is None
  2841. def test_parse_frame_too_short(self, bind_server):
  2842. """Verify short data returns None."""
  2843. assert bind_server._parse_frame(b"\xa5\xa5\x00") is None
  2844. assert bind_server._parse_frame(b"") is None
  2845. def test_parse_frame_invalid_json(self, bind_server):
  2846. """Verify invalid JSON returns None."""
  2847. import struct
  2848. bad_json = b"not json"
  2849. total_len = 4 + len(bad_json) + 2
  2850. frame = b"\xa5\xa5" + struct.pack("<H", total_len) + bad_json + b"\xa7\xa7"
  2851. assert bind_server._parse_frame(frame) is None
  2852. def test_build_frame_roundtrip(self, bind_server):
  2853. """Verify build then parse roundtrip."""
  2854. original = {"login": {"bind": "free", "command": "detect", "id": "01S00C000000001"}}
  2855. frame = bind_server._build_frame(original)
  2856. parsed = bind_server._parse_frame(frame)
  2857. assert parsed == original
  2858. def test_bind_server_stores_config(self, bind_server):
  2859. """Verify config is stored correctly."""
  2860. assert bind_server.serial == "01S00C000000001"
  2861. assert bind_server.model == "BL-P001"
  2862. assert bind_server.name == "Bambuddy"
  2863. assert bind_server.version == "01.00.00.00"
  2864. def test_bind_server_custom_version(self):
  2865. """Verify custom firmware version is stored."""
  2866. from backend.app.services.virtual_printer.bind_server import BindServer
  2867. server = BindServer(
  2868. serial="01S00C000000001",
  2869. model="BL-P001",
  2870. name="Bambuddy",
  2871. version="01.09.00.10",
  2872. )
  2873. assert server.version == "01.09.00.10"
  2874. def test_bind_ports_includes_both(self):
  2875. """Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility."""
  2876. from backend.app.services.virtual_printer.bind_server import BIND_PORTS
  2877. assert 3000 in BIND_PORTS
  2878. assert 3002 in BIND_PORTS
  2879. def test_bind_server_initializes_empty_servers_list(self, bind_server):
  2880. """Verify bind server starts with empty servers list."""
  2881. assert bind_server._servers == []
  2882. assert bind_server._running is False
  2883. @pytest.mark.asyncio
  2884. async def test_start_server_creates_bind_server(self, tmp_path):
  2885. """Verify start_server creates BindServer with correct params."""
  2886. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2887. inst = VirtualPrinterInstance(
  2888. vp_id=99,
  2889. name="Bambuddy",
  2890. mode="archive",
  2891. model="BL-P001",
  2892. access_code="12345678",
  2893. serial_suffix="391800099",
  2894. bind_ip="192.168.1.50",
  2895. base_dir=tmp_path,
  2896. )
  2897. # Each mocked child service exposes a real asyncio.Event for the
  2898. # readiness barrier added in start_server (set on instantiation so
  2899. # the barrier returns immediately in tests).
  2900. ready_event = asyncio.Event()
  2901. ready_event.set()
  2902. def with_ready(*_args, **_kwargs):
  2903. child = MagicMock()
  2904. child.ready = ready_event
  2905. return child
  2906. with (
  2907. patch(
  2908. "backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer",
  2909. side_effect=with_ready,
  2910. ),
  2911. patch(
  2912. "backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer",
  2913. side_effect=with_ready,
  2914. ),
  2915. patch(
  2916. "backend.app.services.virtual_printer.manager.SimpleMQTTServer",
  2917. side_effect=with_ready,
  2918. ),
  2919. patch(
  2920. "backend.app.services.virtual_printer.manager.BindServer",
  2921. side_effect=with_ready,
  2922. ) as mock_bind_cls,
  2923. patch.object(inst._cert_service, "delete_printer_certificate"),
  2924. patch.object(
  2925. inst._cert_service,
  2926. "generate_certificates",
  2927. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  2928. ),
  2929. ):
  2930. await inst.start_server()
  2931. mock_bind_cls.assert_called_once_with(
  2932. serial=inst.serial,
  2933. model="BL-P001",
  2934. name="Bambuddy",
  2935. bind_address="192.168.1.50",
  2936. cert_path=Path("/tmp/cert.pem"), # nosec B108
  2937. key_path=Path("/tmp/key.pem"), # nosec B108
  2938. )
  2939. class TestResolveModelCodes:
  2940. """Tests for model code resolution (display name → SSDP code)."""
  2941. def test_display_name_to_model_code_maps_all_models(self):
  2942. """Verify reverse mapping covers all VIRTUAL_PRINTER_MODELS entries."""
  2943. from backend.app.services.virtual_printer.manager import DISPLAY_NAME_TO_MODEL_CODE, VIRTUAL_PRINTER_MODELS
  2944. for _code, display_name in VIRTUAL_PRINTER_MODELS.items():
  2945. assert display_name in DISPLAY_NAME_TO_MODEL_CODE
  2946. # For non-duplicate display names, should map back to a valid code
  2947. assert DISPLAY_NAME_TO_MODEL_CODE[display_name] in VIRTUAL_PRINTER_MODELS
  2948. def test_resolve_printer_model_with_ssdp_code(self):
  2949. """SSDP codes pass through unchanged."""
  2950. from backend.app.api.routes.virtual_printers import _resolve_printer_model
  2951. assert _resolve_printer_model("BL-P001") == "BL-P001"
  2952. assert _resolve_printer_model("O1D") == "O1D"
  2953. assert _resolve_printer_model("N2S") == "N2S"
  2954. def test_resolve_printer_model_with_display_name(self):
  2955. """Display names resolve to SSDP codes."""
  2956. from backend.app.api.routes.virtual_printers import _resolve_printer_model
  2957. assert _resolve_printer_model("X1C") == "BL-P001"
  2958. assert _resolve_printer_model("H2D") == "O1D"
  2959. assert _resolve_printer_model("A1") == "N2S"
  2960. assert _resolve_printer_model("P1S") == "C12"
  2961. def test_resolve_printer_model_with_none_or_unknown(self):
  2962. """None and unknown values return None."""
  2963. from backend.app.api.routes.virtual_printers import _resolve_printer_model
  2964. assert _resolve_printer_model(None) is None
  2965. assert _resolve_printer_model("UnknownModel") is None
  2966. class TestMqttIpRewrite:
  2967. """Tests for TLSProxy._rewrite_mqtt_ip() MQTT packet IP rewriting."""
  2968. @staticmethod
  2969. def _build_mqtt_publish(topic: str, payload: bytes) -> bytes:
  2970. """Build a minimal MQTT PUBLISH packet."""
  2971. # PUBLISH fixed header: type 3, no flags
  2972. topic_bytes = topic.encode("utf-8")
  2973. # Variable header: topic length (2 bytes) + topic
  2974. var_header = len(topic_bytes).to_bytes(2, "big") + topic_bytes
  2975. body = var_header + payload
  2976. # Encode remaining length
  2977. remaining = len(body)
  2978. header = bytearray([0x30]) # PUBLISH, QoS 0
  2979. while True:
  2980. encoded_byte = remaining % 128
  2981. remaining //= 128
  2982. if remaining > 0:
  2983. encoded_byte |= 0x80
  2984. header.append(encoded_byte)
  2985. if remaining == 0:
  2986. break
  2987. return bytes(header) + body
  2988. @staticmethod
  2989. def _build_mqtt_pingreq() -> bytes:
  2990. """Build an MQTT PINGREQ packet (2 bytes, no payload)."""
  2991. return b"\xc0\x00"
  2992. def test_rewrite_ip_in_publish(self):
  2993. """IP string in PUBLISH payload is rewritten."""
  2994. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  2995. payload = b'{"rtsp_url":"rtsps://192.168.1.100:322/live"}'
  2996. packet = self._build_mqtt_publish("device/status", payload)
  2997. result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray())
  2998. assert b"10.0.0.1" in result
  2999. assert b"192.168.1.100" not in result
  3000. def test_no_rewrite_when_ip_absent(self):
  3001. """Packets without the target IP are passed through unchanged."""
  3002. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  3003. payload = b'{"status":"idle"}'
  3004. packet = self._build_mqtt_publish("device/status", payload)
  3005. result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray())
  3006. assert result == packet
  3007. def test_non_publish_packets_unchanged(self):
  3008. """Non-PUBLISH packets (e.g. PINGREQ) are never rewritten."""
  3009. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  3010. pingreq = self._build_mqtt_pingreq()
  3011. result, buf = TLSProxy._rewrite_mqtt_ip(pingreq, b"192.168.1.100", b"10.0.0.1", bytearray())
  3012. assert result == pingreq
  3013. def test_rewrite_preserves_packet_framing(self):
  3014. """Rewritten packet has valid MQTT remaining length."""
  3015. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  3016. # Use IPs of different lengths to test length re-encoding
  3017. old_ip = b"192.168.255.133" # 15 bytes
  3018. new_ip = b"10.0.0.1" # 8 bytes
  3019. payload = b'{"ip":"192.168.255.133"}'
  3020. packet = self._build_mqtt_publish("device/status", payload)
  3021. result, buf = TLSProxy._rewrite_mqtt_ip(packet, old_ip, new_ip, bytearray())
  3022. # Parse the result to verify framing
  3023. assert result[0] == 0x30 # PUBLISH header byte
  3024. # Decode remaining length
  3025. pos = 1
  3026. remaining = 0
  3027. multiplier = 1
  3028. while True:
  3029. b = result[pos]
  3030. pos += 1
  3031. remaining += (b & 0x7F) * multiplier
  3032. multiplier *= 128
  3033. if (b & 0x80) == 0:
  3034. break
  3035. # Remaining length should match actual data
  3036. assert pos + remaining == len(result)
  3037. assert new_ip in result
  3038. def test_incomplete_packet_buffered(self):
  3039. """Incomplete packet at end of chunk is buffered for next call."""
  3040. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  3041. payload = b'{"ip":"192.168.1.100"}'
  3042. packet = self._build_mqtt_publish("device/status", payload)
  3043. # Split packet in the middle
  3044. half = len(packet) // 2
  3045. chunk1 = packet[:half]
  3046. chunk2 = packet[half:]
  3047. result1, buf = TLSProxy._rewrite_mqtt_ip(chunk1, b"192.168.1.100", b"10.0.0.1", bytearray())
  3048. # First chunk should be buffered (incomplete packet)
  3049. assert len(buf) > 0
  3050. result2, buf = TLSProxy._rewrite_mqtt_ip(chunk2, b"192.168.1.100", b"10.0.0.1", buf)
  3051. # Second chunk completes the packet, IP should be rewritten
  3052. combined = result1 + result2
  3053. assert b"10.0.0.1" in combined
  3054. assert b"192.168.1.100" not in combined
  3055. def test_multiple_packets_in_one_chunk(self):
  3056. """Multiple MQTT packets in a single chunk are all processed."""
  3057. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  3058. payload1 = b'{"ip":"192.168.1.100"}'
  3059. payload2 = b'{"other":"data"}'
  3060. packet1 = self._build_mqtt_publish("topic1", payload1)
  3061. packet2 = self._build_mqtt_publish("topic2", payload2)
  3062. combined = packet1 + packet2
  3063. result, buf = TLSProxy._rewrite_mqtt_ip(combined, b"192.168.1.100", b"10.0.0.1", bytearray())
  3064. assert b"10.0.0.1" in result
  3065. assert b"192.168.1.100" not in result
  3066. # Second packet should still be present
  3067. assert b"other" in result
  3068. def test_extra_replacements(self):
  3069. """Extra replacement pairs (e.g. integer IP) are also applied."""
  3070. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  3071. payload = b'{"net":{"info":[{"ip":2248124608}]}}'
  3072. packet = self._build_mqtt_publish("device/status", payload)
  3073. result, buf = TLSProxy._rewrite_mqtt_ip(
  3074. packet,
  3075. b"NOMATCH",
  3076. b"NOREPLACE",
  3077. bytearray(),
  3078. extra_replacements=[(b"2248124608", b"285190336")],
  3079. )
  3080. assert b"285190336" in result
  3081. assert b"2248124608" not in result
  3082. class TestIpToLeIntBytes:
  3083. """Tests for TLSProxy._ip_to_le_int_bytes() integer IP conversion."""
  3084. def test_converts_ip_to_le_int(self):
  3085. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  3086. assert TLSProxy._ip_to_le_int_bytes("192.168.255.133") == b"2248124608"
  3087. assert TLSProxy._ip_to_le_int_bytes("192.168.255.16") == b"285190336"
  3088. assert TLSProxy._ip_to_le_int_bytes("10.0.0.1") == b"16777226"
  3089. def test_roundtrip(self):
  3090. """Verify the integer converts back to the correct IP."""
  3091. import struct
  3092. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  3093. for ip in ["192.168.1.1", "10.0.0.1", "172.16.0.100", "192.168.255.133"]:
  3094. le_int = int(TLSProxy._ip_to_le_int_bytes(ip))
  3095. parts = ip.split(".")
  3096. expected = struct.unpack("<I", bytes(int(p) for p in parts))[0]
  3097. assert le_int == expected
  3098. class TestSSDPProxyName:
  3099. """Tests for SSDPProxy VP name rewriting."""
  3100. @pytest.fixture
  3101. def ssdp_proxy_with_name(self):
  3102. from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
  3103. return SSDPProxy(
  3104. local_interface_ip="192.168.1.100",
  3105. remote_interface_ip="10.0.0.100",
  3106. target_printer_ip="192.168.1.50",
  3107. name="H2D-1 Proxy",
  3108. )
  3109. @pytest.fixture
  3110. def ssdp_proxy_without_name(self):
  3111. from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
  3112. return SSDPProxy(
  3113. local_interface_ip="192.168.1.100",
  3114. remote_interface_ip="10.0.0.100",
  3115. target_printer_ip="192.168.1.50",
  3116. )
  3117. def test_rewrite_uses_configured_name(self, ssdp_proxy_with_name):
  3118. """When name is set, DevName is replaced entirely."""
  3119. 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"
  3120. rewritten = ssdp_proxy_with_name._rewrite_ssdp(packet)
  3121. assert b"DevName.bambu.com: H2D-1 Proxy" in rewritten
  3122. assert b"RealPrinter" not in rewritten
  3123. def test_rewrite_appends_proxy_without_name(self, ssdp_proxy_without_name):
  3124. """When no name is set, ' - Proxy' is appended to the real name."""
  3125. 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"
  3126. rewritten = ssdp_proxy_without_name._rewrite_ssdp(packet)
  3127. assert b"DevName.bambu.com: RealPrinter - Proxy" in rewritten
  3128. class TestVPProjectFileStashKey:
  3129. """Regression: `on_print_command` MUST stash slicer options under the
  3130. FTP filename (`data["file"]`, with extension), NOT under `filename`
  3131. (the slicer's `subtask_name`, bare).
  3132. #1780 root cause (real bundle, 2026-06-21): BambuStudio sends
  3133. `subtask_name = "Model_Name"` (bare) and `file = "Model_Name.gcode.3mf"`
  3134. (with extension). `_add_to_print_queue` looks up the stash under
  3135. `file_path.name` from the FTP receive side, which always has the
  3136. extension. If the stash uses `subtask_name`, lookup misses → every
  3137. captured slicer field (bed_leveling, flow_cali, vibration_cali,
  3138. layer_inspect, timelapse, nozzle_mapping) silently falls back to
  3139. settings defaults on every Bambu Studio "Send" upload.
  3140. `filename` (subtask_name) must still flow to `_schedule_finish_release`
  3141. untouched — push_status echoes it back as gcode_file / subtask_name and
  3142. the slicer matches against its own local subtask_name there. So
  3143. `on_print_command` keeps `filename` for state-feedback but derives the
  3144. stash key from `data["file"]`.
  3145. """
  3146. @pytest.fixture
  3147. def instance(self, tmp_path):
  3148. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  3149. return VirtualPrinterInstance(
  3150. vp_id=99,
  3151. name="StashKeyTest",
  3152. mode="queue",
  3153. model="O1C2",
  3154. access_code="12345678",
  3155. serial_suffix="999999999",
  3156. base_dir=tmp_path,
  3157. )
  3158. @pytest.mark.asyncio
  3159. async def test_stash_key_uses_file_field_not_subtask_name(self, instance):
  3160. """BambuStudio's real wire shape: `subtask_name` ≠ `file`.
  3161. on_print_command must stash under `data["file"]` so the FTP-side
  3162. `_add_to_print_queue` lookup matches.
  3163. """
  3164. # mqtt_server.py:_handle_publish hands the bare subtask_name as
  3165. # `filename` and the full print_data body as `data`. The FTP filename
  3166. # lives in `data["file"]`.
  3167. await instance.on_print_command(
  3168. "Filament_Track_Switch_Holder", # subtask_name (bare)
  3169. {
  3170. "command": "project_file",
  3171. "subtask_name": "Filament_Track_Switch_Holder",
  3172. "file": "Filament_Track_Switch_Holder.gcode.3mf",
  3173. "nozzle_mapping": [16, -1, -1, 1],
  3174. },
  3175. )
  3176. # Stash MUST be under the FTP filename, not the bare subtask_name.
  3177. # `_add_to_print_queue` does `_slicer_print_options.pop(file_path.name, None)`
  3178. # where file_path.name == "Filament_Track_Switch_Holder.gcode.3mf".
  3179. assert "Filament_Track_Switch_Holder.gcode.3mf" in instance._slicer_print_options
  3180. assert "Filament_Track_Switch_Holder" not in instance._slicer_print_options
  3181. # Body must carry nozzle_mapping verbatim.
  3182. stashed = instance._slicer_print_options["Filament_Track_Switch_Holder.gcode.3mf"]
  3183. assert stashed["nozzle_mapping"] == [16, -1, -1, 1]
  3184. @pytest.mark.asyncio
  3185. async def test_stash_key_falls_back_to_filename_when_file_absent(self, instance):
  3186. """Defensive fallback: a slicer that omits the `file` field entirely
  3187. (legacy / non-3MF) must fall back to `filename` (subtask_name), not
  3188. leave the stash unkeyed."""
  3189. await instance.on_print_command(
  3190. "BareName",
  3191. {
  3192. "command": "project_file",
  3193. "subtask_name": "BareName",
  3194. # no "file" field
  3195. },
  3196. )
  3197. assert "BareName" in instance._slicer_print_options
  3198. @pytest.mark.asyncio
  3199. async def test_stash_key_signals_event_under_file_key(self, instance):
  3200. """`_add_to_print_queue` registers a wait-event under `file_path.name`
  3201. when the slicer's project_file arrives late. on_print_command must
  3202. signal THAT event (keyed by the FTP filename), not one keyed by
  3203. subtask_name — else the waiter times out even though the stash is
  3204. present and addressable."""
  3205. import asyncio
  3206. ftp_filename = "Filament_Track_Switch_Holder.gcode.3mf"
  3207. event = asyncio.Event()
  3208. instance._slicer_print_options_events[ftp_filename] = event
  3209. await instance.on_print_command(
  3210. "Filament_Track_Switch_Holder", # bare subtask_name
  3211. {
  3212. "command": "project_file",
  3213. "subtask_name": "Filament_Track_Switch_Holder",
  3214. "file": ftp_filename,
  3215. },
  3216. )
  3217. # Event keyed by FTP filename must fire even though on_print_command
  3218. # was called with the bare subtask_name.
  3219. assert event.is_set()