test_virtual_printer.py 202 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161
  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 shutil
  7. import zipfile
  8. from pathlib import Path
  9. from types import SimpleNamespace
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. @pytest.fixture(autouse=True)
  13. def _fake_file_manager_store():
  14. """Uploads go to the File Manager (library_store); its own tests cover it.
  15. Here it stands in with file id 1 and the default folder settings, so these
  16. tests see what the virtual printer does around it.
  17. """
  18. stored = MagicMock(id=1, filename="upload.3mf", folder_id=None)
  19. with (
  20. patch(
  21. "backend.app.services.virtual_printer.library_store.store_upload",
  22. AsyncMock(return_value=(stored, False)),
  23. ) as store,
  24. patch(
  25. "backend.app.services.virtual_printer.library_store.upload_settings",
  26. AsyncMock(return_value=(None, False)),
  27. ),
  28. ):
  29. yield store
  30. def _write_3mf_with_filaments(file_path: Path, filaments: list[dict], plate_index: int = 1) -> None:
  31. """Build a minimal 3MF zip with `Metadata/slice_info.config` carrying the
  32. given per-slot filament entries. Each `filaments` dict needs `id`, `type`,
  33. `color`, `used_g`. Used by the #1188 VP queue-mode tests below."""
  34. filament_xml = "".join(
  35. f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}" '
  36. f'used_g="{f["used_g"]}" tray_info_idx="{f.get("tray_info_idx", "")}"/>'
  37. for f in filaments
  38. )
  39. config = (
  40. '<?xml version="1.0" encoding="utf-8"?>'
  41. "<config>"
  42. f'<plate><metadata key="index" value="{plate_index}"/>'
  43. f"{filament_xml}"
  44. "</plate>"
  45. "</config>"
  46. )
  47. with zipfile.ZipFile(file_path, "w") as zf:
  48. zf.writestr("Metadata/slice_info.config", config)
  49. # Plate gcode is referenced for plate-id detection in the VP path —
  50. # presence is enough; contents don't matter.
  51. zf.writestr(f"Metadata/plate_{plate_index}.gcode", "; gcode\n")
  52. class TestVirtualPrinterInstance:
  53. """Tests for VirtualPrinterInstance class."""
  54. @pytest.fixture(autouse=True)
  55. def _no_queue_announcement(self):
  56. """Its own query would take a result the session mocks hold for the test."""
  57. with patch("backend.app.services.queue_announcements.announce_queue_jobs_added", AsyncMock()):
  58. yield
  59. @pytest.fixture
  60. def instance(self, tmp_path):
  61. """Create a VirtualPrinterInstance with test defaults."""
  62. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  63. return VirtualPrinterInstance(
  64. vp_id=1,
  65. name="TestPrinter",
  66. mode="library",
  67. model="C11",
  68. access_code="12345678",
  69. serial_suffix="391800001",
  70. base_dir=tmp_path,
  71. )
  72. # ========================================================================
  73. # Tests for instance properties
  74. # ========================================================================
  75. def test_instance_stores_parameters(self, instance):
  76. """Verify constructor stores parameters correctly."""
  77. assert instance.id == 1
  78. assert instance.name == "TestPrinter"
  79. assert instance.mode == "library"
  80. assert instance.model == "C11"
  81. assert instance.access_code == "12345678"
  82. assert instance.serial_suffix == "391800001"
  83. def test_instance_serial_property(self, instance):
  84. """Verify serial is generated from model prefix + suffix."""
  85. # C11 = P1P, prefix = 01S00A
  86. assert instance.serial == "01S00A391800001"
  87. def test_instance_serial_x1c(self, tmp_path):
  88. """Verify X1C serial uses correct prefix."""
  89. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  90. inst = VirtualPrinterInstance(
  91. vp_id=2,
  92. name="X1C",
  93. mode="library",
  94. model="BL-P001",
  95. access_code="12345678",
  96. serial_suffix="391800002",
  97. base_dir=tmp_path,
  98. )
  99. assert inst.serial == "00M00A391800002"
  100. def test_instance_is_proxy_false(self, instance):
  101. """Verify is_proxy is False for non-proxy mode."""
  102. assert instance.is_proxy is False
  103. def test_instance_is_proxy_true(self, tmp_path):
  104. """Verify is_proxy is True for proxy mode."""
  105. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  106. inst = VirtualPrinterInstance(
  107. vp_id=3,
  108. name="Proxy",
  109. mode="proxy",
  110. model="C11",
  111. access_code="",
  112. serial_suffix="391800003",
  113. target_printer_ip="192.168.1.100",
  114. base_dir=tmp_path,
  115. )
  116. assert inst.is_proxy is True
  117. def test_instance_is_running_with_active_tasks(self, instance):
  118. """Verify is_running is True when tasks are active."""
  119. mock_task = MagicMock()
  120. mock_task.done.return_value = False
  121. instance._tasks = [mock_task]
  122. assert instance.is_running is True
  123. def test_instance_is_running_with_no_tasks(self, instance):
  124. """Verify is_running is False when no tasks."""
  125. assert instance.is_running is False
  126. def test_instance_creates_directories(self, instance, tmp_path):
  127. """Verify instance creates upload and cert directories."""
  128. assert (tmp_path / "uploads" / "1").exists()
  129. assert (tmp_path / "uploads" / "1" / "cache").exists()
  130. assert (tmp_path / "certs" / "1").exists()
  131. # ========================================================================
  132. # Tests for status
  133. # ========================================================================
  134. def test_get_status_returns_correct_format(self, instance):
  135. """Verify get_status returns expected fields."""
  136. instance._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")} # nosec B108
  137. mock_task = MagicMock(done=MagicMock(return_value=False))
  138. instance._tasks = [mock_task]
  139. status = instance.get_status()
  140. assert status["running"] is True
  141. assert status["pending_files"] == 1
  142. def test_get_status_not_running(self, instance):
  143. """Verify get_status when no tasks."""
  144. status = instance.get_status()
  145. assert status["running"] is False
  146. assert status["pending_files"] == 0
  147. @pytest.mark.asyncio
  148. @pytest.mark.parametrize("handler", ["_store_in_library", "_queue_file", "_add_to_print_queue"])
  149. async def test_a_file_that_cannot_be_handled_leaves_the_pending_count(self, instance, handler):
  150. """Without a database the file is dropped, and the status stops counting it."""
  151. file_path = Path("/tmp/test.3mf") # nosec B108
  152. instance._session_factory = None
  153. instance._pending_files = {file_path.name: file_path}
  154. await getattr(instance, handler)(file_path, "192.168.1.100")
  155. assert instance.get_status()["pending_files"] == 0
  156. # ========================================================================
  157. # Tests for file handling
  158. # ========================================================================
  159. @pytest.mark.asyncio
  160. async def test_on_file_received_adds_to_pending(self, instance):
  161. """Verify received file is added to pending list in review mode."""
  162. instance.mode = "review"
  163. file_path = Path("/tmp/test.3mf") # nosec B108
  164. with patch.object(instance, "_queue_file", new_callable=AsyncMock) as mock_queue:
  165. await instance.on_file_received(file_path, "192.168.1.100")
  166. assert "test.3mf" in instance._pending_files
  167. mock_queue.assert_called_once()
  168. @pytest.mark.asyncio
  169. async def test_on_file_received_archives_immediately(self, instance):
  170. """Verify file is archived in immediate mode."""
  171. file_path = Path("/tmp/test.3mf") # nosec B108
  172. with patch.object(instance, "_store_in_library", new_callable=AsyncMock) as mock_archive:
  173. await instance.on_file_received(file_path, "192.168.1.100")
  174. mock_archive.assert_called_once_with(file_path, "192.168.1.100", user_id=None)
  175. @pytest.mark.asyncio
  176. async def test_on_file_received_signals_FINISH_to_slicer(self, instance):
  177. """Regression #1280: when a slicer's Print flow uploads to a non-proxy VP,
  178. the VP must transition gcode_state PREPARE → FINISH so the slicer's
  179. in-flight-job lock releases. Going PREPARE → IDLE wedges Orca at
  180. "Downloading...(0%)" and blocks the next dispatch with "busy with
  181. another print job".
  182. Send-flow slicers don't watch the post-upload state, so this is a
  183. no-op behavior change for them.
  184. """
  185. instance.mode = "library"
  186. instance._mqtt = MagicMock()
  187. instance._mqtt.set_gcode_state = MagicMock()
  188. file_path = Path("/tmp/test.3mf") # nosec B108
  189. with patch.object(instance, "_store_in_library", new_callable=AsyncMock):
  190. await instance.on_file_received(file_path, "192.168.1.100")
  191. instance._mqtt.set_gcode_state.assert_called_once_with("FINISH", filename="test.3mf", prepare_percent="100")
  192. @pytest.mark.asyncio
  193. async def test_on_file_received_non_3mf_does_not_touch_state(self, instance):
  194. """Non-3MF uploads (e.g., a job's auxiliary files) must not transition
  195. the visible state — the slicer is only tracking the .3mf upload."""
  196. instance.mode = "library"
  197. instance._mqtt = MagicMock()
  198. instance._mqtt.set_gcode_state = MagicMock()
  199. file_path = Path("/tmp/test.gcode") # nosec B108
  200. with patch.object(instance, "_store_in_library", new_callable=AsyncMock):
  201. await instance.on_file_received(file_path, "192.168.1.100")
  202. instance._mqtt.set_gcode_state.assert_not_called()
  203. @pytest.mark.asyncio
  204. async def test_on_print_command_schedules_finish_release_non_proxy(self, instance):
  205. """#1658: Bambu Studio 2.7.x flipped the slicer's Send flow to
  206. FTP → FTP → MQTT project_file. Under that order the synthetic
  207. project_file ack overwrites the FINISH set by #1280 in
  208. ``on_file_received`` back to PREPARE, leaving the slicer's
  209. "Downloading" modal stuck forever. Re-firing FINISH a moment after
  210. the ack releases the modal.
  211. """
  212. instance.mode = "library"
  213. instance._mqtt = MagicMock()
  214. instance._mqtt.set_gcode_state = MagicMock()
  215. with patch.object(instance, "_delayed_finish_release", new_callable=AsyncMock) as mock_delayed:
  216. await instance.on_print_command("test.3mf", {"command": "project_file"})
  217. mock_delayed.assert_called_once()
  218. # First positional arg should be the filename; second is the delay seconds.
  219. args = mock_delayed.call_args.args
  220. assert args[0] == "test.3mf"
  221. assert isinstance(args[1], int | float)
  222. @pytest.mark.asyncio
  223. async def test_on_print_command_proxy_mode_does_not_reschedule_finish(self, instance):
  224. """Proxy mode hands push_status straight from the real printer through
  225. the bridge. Re-firing a synthetic FINISH would clobber a real
  226. PREPARE / RUNNING transition coming back from the printer, so the
  227. scheduler is exempt for proxy mode."""
  228. instance.mode = "proxy"
  229. instance._mqtt = MagicMock()
  230. with patch.object(instance, "_delayed_finish_release", new_callable=AsyncMock) as mock_delayed:
  231. await instance.on_print_command("test.3mf", {"command": "project_file"})
  232. mock_delayed.assert_not_called()
  233. @pytest.mark.asyncio
  234. async def test_on_print_command_no_mqtt_does_not_schedule(self, instance):
  235. """If the MQTT server isn't running yet (transient race during boot),
  236. the scheduler must skip silently — no AttributeError, no orphan task."""
  237. instance.mode = "queue"
  238. instance._mqtt = None
  239. # Should not raise.
  240. await instance.on_print_command("test.3mf", {"command": "project_file"})
  241. assert instance._finish_release_task is None
  242. @pytest.mark.asyncio
  243. async def test_schedule_finish_release_cancels_previous_timer(self, instance):
  244. """A slicer that fires project_file twice (e.g. retry after a transient
  245. FTP hiccup) must only result in one FINISH transition — the earlier
  246. in-flight timer is cancelled when the next one is scheduled."""
  247. instance.mode = "queue"
  248. instance._mqtt = MagicMock()
  249. instance._schedule_finish_release("first.3mf", delay=10.0)
  250. first_task = instance._finish_release_task
  251. assert first_task is not None
  252. instance._schedule_finish_release("second.3mf", delay=10.0)
  253. second_task = instance._finish_release_task
  254. assert second_task is not first_task
  255. # Give the loop one tick so the cancelled task settles.
  256. await asyncio.sleep(0)
  257. assert first_task.cancelled() or first_task.done()
  258. # Clean up the still-pending second task so the test doesn't leak it.
  259. second_task.cancel()
  260. try:
  261. await second_task
  262. except asyncio.CancelledError:
  263. pass
  264. @pytest.mark.asyncio
  265. async def test_delayed_finish_release_sets_finish_state(self, instance):
  266. """End-to-end: after the delay elapses, set_gcode_state is called with
  267. FINISH and prepare_percent=100, matching the wire-format the slicer's
  268. Print flow consumes to release "Downloading"."""
  269. instance._mqtt = MagicMock()
  270. instance._mqtt.set_gcode_state = MagicMock()
  271. await instance._delayed_finish_release("queued.3mf", delay=0.0)
  272. instance._mqtt.set_gcode_state.assert_called_once_with("FINISH", filename="queued.3mf", prepare_percent="100")
  273. @pytest.mark.asyncio
  274. async def test_on_print_command_no_filename_does_not_schedule(self, instance):
  275. """A project_file command without a subtask_name (defensive — real
  276. slicers always send one) must not schedule a no-op FINISH that would
  277. carry an empty filename on the next 1 Hz push."""
  278. instance.mode = "queue"
  279. instance._mqtt = MagicMock()
  280. with patch.object(instance, "_delayed_finish_release", new_callable=AsyncMock) as mock_delayed:
  281. await instance.on_print_command("", {"command": "project_file"})
  282. mock_delayed.assert_not_called()
  283. @pytest.mark.asyncio
  284. async def test_store_in_library_skips_non_3mf(self, instance, _fake_file_manager_store):
  285. """Verify non-3MF files are skipped and cleaned up."""
  286. instance._session_factory = MagicMock()
  287. instance._pending_files["verify_job"] = Path("/tmp/verify_job") # nosec B108
  288. with patch("pathlib.Path.unlink"):
  289. await instance._store_in_library(Path("/tmp/verify_job"), "192.168.1.100") # nosec B108
  290. assert "verify_job" not in instance._pending_files
  291. _fake_file_manager_store.assert_not_awaited()
  292. @pytest.mark.asyncio
  293. async def test_store_in_library_stores_in_the_vp_folder_and_broadcasts(self, tmp_path, _fake_file_manager_store):
  294. """Library mode: the upload goes to the File Manager folder the VP is
  295. set to, open File Manager pages are told, and the temp file goes."""
  296. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  297. mock_session_factory = MagicMock()
  298. mock_session_ctx = AsyncMock()
  299. mock_session_ctx.__aenter__ = AsyncMock(return_value=AsyncMock())
  300. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  301. mock_session_factory.return_value = mock_session_ctx
  302. inst = VirtualPrinterInstance(
  303. vp_id=30,
  304. name="LibraryMode",
  305. mode="library",
  306. model="C12",
  307. access_code="12345678",
  308. serial_suffix="391800030",
  309. base_dir=tmp_path,
  310. session_factory=mock_session_factory,
  311. )
  312. file_path = tmp_path / "test.3mf"
  313. file_path.write_bytes(b"fake3mf")
  314. inst._pending_files[file_path.name] = file_path
  315. with (
  316. patch(
  317. "backend.app.services.virtual_printer.library_store.upload_settings",
  318. AsyncMock(return_value=(7, False)),
  319. ),
  320. patch(
  321. "backend.app.core.websocket.ws_manager.send_library_files_changed",
  322. new_callable=AsyncMock,
  323. ) as mock_broadcast,
  324. ):
  325. await inst._store_in_library(file_path, "192.168.1.100")
  326. _fake_file_manager_store.assert_awaited_once()
  327. kwargs = _fake_file_manager_store.await_args.kwargs
  328. assert kwargs["source_path"] == file_path
  329. assert kwargs["filename"] == "test.3mf"
  330. assert kwargs["folder_id"] == 7
  331. mock_broadcast.assert_awaited_once()
  332. assert file_path.name not in inst._pending_files
  333. assert not file_path.exists()
  334. # ========================================================================
  335. # Tests for auto_dispatch
  336. # ========================================================================
  337. def test_auto_dispatch_defaults_to_true(self, tmp_path):
  338. """Verify auto_dispatch defaults to True when not specified."""
  339. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  340. inst = VirtualPrinterInstance(
  341. vp_id=10,
  342. name="DefaultDispatch",
  343. mode="queue",
  344. model="C11",
  345. access_code="12345678",
  346. serial_suffix="391800010",
  347. base_dir=tmp_path,
  348. )
  349. assert inst.auto_dispatch is True
  350. @pytest.mark.asyncio
  351. async def test_add_to_print_queue_with_auto_dispatch_on(self, tmp_path):
  352. """Verify queue items have manual_start=False when auto_dispatch=True."""
  353. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  354. mock_db = AsyncMock()
  355. added_items = []
  356. def capture_add(item):
  357. added_items.append(item)
  358. mock_db.add = MagicMock(side_effect=capture_add)
  359. mock_db.commit = AsyncMock()
  360. mock_session_factory = MagicMock()
  361. mock_session_ctx = AsyncMock()
  362. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  363. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  364. mock_session_factory.return_value = mock_session_ctx
  365. inst = VirtualPrinterInstance(
  366. vp_id=11,
  367. name="AutoDispatchOn",
  368. mode="queue",
  369. model="C11",
  370. access_code="12345678",
  371. serial_suffix="391800011",
  372. auto_dispatch=True,
  373. base_dir=tmp_path,
  374. session_factory=mock_session_factory,
  375. )
  376. # Create a temp 3mf file
  377. file_path = tmp_path / "test.3mf"
  378. file_path.write_bytes(b"fake3mf")
  379. with (
  380. patch(
  381. "backend.app.api.routes.settings.get_setting",
  382. new_callable=AsyncMock,
  383. return_value=None,
  384. ),
  385. ):
  386. await inst._add_to_print_queue(file_path, "192.168.1.100")
  387. assert len(added_items) == 1
  388. queue_item = added_items[0]
  389. assert queue_item.manual_start is False
  390. @pytest.mark.asyncio
  391. @pytest.mark.parametrize("remove_after_print", [False, True])
  392. async def test_add_to_print_queue_prints_the_file_manager_file(
  393. self, tmp_path, _fake_file_manager_store, remove_after_print
  394. ):
  395. """Queue mode: the upload goes to the VP's File Manager folder and the
  396. queue item prints that file; the archive is made when it prints.
  397. "Remove from File Manager once it prints" rides on the item."""
  398. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  399. added_items = []
  400. mock_db = AsyncMock()
  401. mock_db.add = MagicMock(side_effect=added_items.append)
  402. mock_session_ctx = AsyncMock()
  403. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  404. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  405. inst = VirtualPrinterInstance(
  406. vp_id=12,
  407. name="QueueToLibrary",
  408. mode="queue",
  409. model="C11",
  410. access_code="12345678",
  411. serial_suffix="391800012",
  412. base_dir=tmp_path,
  413. session_factory=MagicMock(return_value=mock_session_ctx),
  414. )
  415. file_path = tmp_path / "test.3mf"
  416. file_path.write_bytes(b"fake3mf")
  417. with (
  418. patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
  419. patch(
  420. "backend.app.services.virtual_printer.library_store.upload_settings",
  421. AsyncMock(return_value=(7, remove_after_print)),
  422. ),
  423. ):
  424. await inst._add_to_print_queue(file_path, "192.168.1.100")
  425. assert _fake_file_manager_store.await_args.kwargs["folder_id"] == 7
  426. assert len(added_items) == 1
  427. assert added_items[0].library_file_id == 1
  428. assert added_items[0].archive_id is None
  429. assert added_items[0].cleanup_library_after_dispatch is remove_after_print
  430. assert not file_path.exists()
  431. @pytest.mark.asyncio
  432. async def test_add_to_print_queue_broadcasts_library_files_changed(self, tmp_path):
  433. """#1282: VP queue-mode uploads must tell open pages so the new file
  434. shows up live. The upload is a File Manager file now, not an archive."""
  435. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  436. mock_db = AsyncMock()
  437. mock_db.add = MagicMock()
  438. mock_db.commit = AsyncMock()
  439. mock_session_factory = MagicMock()
  440. mock_session_ctx = AsyncMock()
  441. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  442. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  443. mock_session_factory.return_value = mock_session_ctx
  444. inst = VirtualPrinterInstance(
  445. vp_id=31,
  446. name="QueueBroadcast",
  447. mode="queue",
  448. model="C12",
  449. access_code="12345678",
  450. serial_suffix="391800031",
  451. auto_dispatch=True,
  452. base_dir=tmp_path,
  453. session_factory=mock_session_factory,
  454. )
  455. file_path = tmp_path / "test.3mf"
  456. file_path.write_bytes(b"fake3mf")
  457. with (
  458. patch(
  459. "backend.app.api.routes.settings.get_setting",
  460. new_callable=AsyncMock,
  461. return_value=None,
  462. ),
  463. patch(
  464. "backend.app.core.websocket.ws_manager.send_library_files_changed",
  465. new_callable=AsyncMock,
  466. ) as mock_broadcast,
  467. ):
  468. await inst._add_to_print_queue(file_path, "192.168.1.100")
  469. mock_broadcast.assert_awaited_once()
  470. @pytest.mark.asyncio
  471. async def test_add_to_print_queue_with_auto_dispatch_off(self, tmp_path):
  472. """Verify queue items have manual_start=True when auto_dispatch=False."""
  473. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  474. mock_db = AsyncMock()
  475. added_items = []
  476. def capture_add(item):
  477. added_items.append(item)
  478. mock_db.add = MagicMock(side_effect=capture_add)
  479. mock_db.commit = AsyncMock()
  480. mock_session_factory = MagicMock()
  481. mock_session_ctx = AsyncMock()
  482. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  483. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  484. mock_session_factory.return_value = mock_session_ctx
  485. inst = VirtualPrinterInstance(
  486. vp_id=12,
  487. name="AutoDispatchOff",
  488. mode="queue",
  489. model="C11",
  490. access_code="12345678",
  491. serial_suffix="391800012",
  492. auto_dispatch=False,
  493. base_dir=tmp_path,
  494. session_factory=mock_session_factory,
  495. )
  496. # Create a temp 3mf file
  497. file_path = tmp_path / "test.3mf"
  498. file_path.write_bytes(b"fake3mf")
  499. with (
  500. patch(
  501. "backend.app.api.routes.settings.get_setting",
  502. new_callable=AsyncMock,
  503. return_value=None,
  504. ),
  505. ):
  506. await inst._add_to_print_queue(file_path, "192.168.1.100")
  507. assert len(added_items) == 1
  508. queue_item = added_items[0]
  509. assert queue_item.manual_start is True
  510. @pytest.mark.asyncio
  511. async def test_add_to_print_queue_gcode_injection_on(self, tmp_path):
  512. """#1516: queue items opt into injection when the VP has gcode_injection=True."""
  513. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  514. mock_db = AsyncMock()
  515. added_items = []
  516. mock_db.add = MagicMock(side_effect=added_items.append)
  517. mock_db.commit = AsyncMock()
  518. mock_session_factory = MagicMock()
  519. mock_session_ctx = AsyncMock()
  520. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  521. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  522. mock_session_factory.return_value = mock_session_ctx
  523. inst = VirtualPrinterInstance(
  524. vp_id=13,
  525. name="InjectOn",
  526. mode="queue",
  527. model="C11",
  528. access_code="12345678",
  529. serial_suffix="391800013",
  530. gcode_injection=True,
  531. base_dir=tmp_path,
  532. session_factory=mock_session_factory,
  533. )
  534. file_path = tmp_path / "test.3mf"
  535. file_path.write_bytes(b"fake3mf")
  536. with (
  537. patch(
  538. "backend.app.api.routes.settings.get_setting",
  539. new_callable=AsyncMock,
  540. return_value=None,
  541. ),
  542. ):
  543. await inst._add_to_print_queue(file_path, "192.168.1.100")
  544. assert len(added_items) == 1
  545. assert added_items[0].gcode_injection is True
  546. @pytest.mark.asyncio
  547. async def test_add_to_print_queue_gcode_injection_off_by_default(self, tmp_path):
  548. """#1516: queue items do NOT inject when the VP leaves gcode_injection at its default."""
  549. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  550. mock_db = AsyncMock()
  551. added_items = []
  552. mock_db.add = MagicMock(side_effect=added_items.append)
  553. mock_db.commit = AsyncMock()
  554. mock_session_factory = MagicMock()
  555. mock_session_ctx = AsyncMock()
  556. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  557. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  558. mock_session_factory.return_value = mock_session_ctx
  559. inst = VirtualPrinterInstance(
  560. vp_id=14,
  561. name="InjectOff",
  562. mode="queue",
  563. model="C11",
  564. access_code="12345678",
  565. serial_suffix="391800014",
  566. base_dir=tmp_path,
  567. session_factory=mock_session_factory,
  568. )
  569. file_path = tmp_path / "test.3mf"
  570. file_path.write_bytes(b"fake3mf")
  571. with (
  572. patch(
  573. "backend.app.api.routes.settings.get_setting",
  574. new_callable=AsyncMock,
  575. return_value=None,
  576. ),
  577. ):
  578. await inst._add_to_print_queue(file_path, "192.168.1.100")
  579. assert len(added_items) == 1
  580. assert added_items[0].gcode_injection is False
  581. @pytest.mark.asyncio
  582. async def test_add_to_print_queue_uses_workflow_defaults_from_settings(self, tmp_path):
  583. """#1235: VP queue-mode constructed PrintQueueItem without specifying
  584. bed_levelling / flow_cali / vibration_cali / layer_inspect / timelapse,
  585. so SQLAlchemy applied the column-level defaults and ignored the user's
  586. workflow preferences entirely. Every print sent from the slicer to the
  587. VP came through with the OPPOSITE of what the workflow page said,
  588. forcing the user to edit each queue item by hand.
  589. """
  590. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  591. added_items = []
  592. mock_db = AsyncMock()
  593. mock_db.add = MagicMock(side_effect=added_items.append)
  594. mock_db.commit = AsyncMock()
  595. mock_session_factory = MagicMock()
  596. mock_session_ctx = AsyncMock()
  597. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  598. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  599. mock_session_factory.return_value = mock_session_ctx
  600. inst = VirtualPrinterInstance(
  601. vp_id=22,
  602. name="DefaultsTest",
  603. mode="queue",
  604. model="C12",
  605. access_code="12345678",
  606. serial_suffix="391800022",
  607. auto_dispatch=True,
  608. base_dir=tmp_path,
  609. session_factory=mock_session_factory,
  610. )
  611. file_path = tmp_path / "test.3mf"
  612. file_path.write_bytes(b"fake3mf")
  613. # The reporter set every workflow default to the OPPOSITE of the model's
  614. # column default. Pre-fix the column defaults won; with the fix the
  615. # settings values must flow through to the queue item exactly as stored.
  616. settings_map = {
  617. "virtual_printer_archive_name_source": None,
  618. # Legacy boolean-string rows still coerce (false->off, true->on).
  619. "default_bed_levelling": "false", # tri-state default: auto
  620. "default_flow_cali": "true", # tri-state default: auto
  621. "default_vibration_cali": "false", # model default: True
  622. "default_layer_inspect": "true", # model default: False
  623. "default_timelapse": "true", # model default: False
  624. }
  625. async def fake_get_setting(_db, key):
  626. return settings_map.get(key)
  627. with (
  628. patch(
  629. "backend.app.api.routes.settings.get_setting",
  630. new=fake_get_setting,
  631. ),
  632. ):
  633. await inst._add_to_print_queue(file_path, "192.168.1.100")
  634. assert len(added_items) == 1
  635. queue_item = added_items[0]
  636. assert queue_item.bed_levelling == "off", "default_bed_levelling=false must flow through"
  637. assert queue_item.flow_cali == "on", "default_flow_cali=true must flow through"
  638. assert queue_item.vibration_cali is False, "default_vibration_cali=false must flow through"
  639. assert queue_item.layer_inspect is True, "default_layer_inspect=true must flow through"
  640. assert queue_item.timelapse is True, "default_timelapse=true must flow through"
  641. @pytest.mark.asyncio
  642. async def test_add_to_print_queue_falls_back_to_schema_defaults_when_unset(self, tmp_path):
  643. """#1235 fallback: when no workflow setting is in the DB, the queue
  644. item should use the AppSettings (Pydantic) defaults — same values
  645. the user sees in the workflow page on a fresh install.
  646. """
  647. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  648. added_items = []
  649. mock_db = AsyncMock()
  650. mock_db.add = MagicMock(side_effect=added_items.append)
  651. mock_db.commit = AsyncMock()
  652. mock_session_factory = MagicMock()
  653. mock_session_ctx = AsyncMock()
  654. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  655. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  656. mock_session_factory.return_value = mock_session_ctx
  657. inst = VirtualPrinterInstance(
  658. vp_id=23,
  659. name="FreshInstallDefaults",
  660. mode="queue",
  661. model="C12",
  662. access_code="12345678",
  663. serial_suffix="391800023",
  664. auto_dispatch=True,
  665. base_dir=tmp_path,
  666. session_factory=mock_session_factory,
  667. )
  668. file_path = tmp_path / "test.3mf"
  669. file_path.write_bytes(b"fake3mf")
  670. with (
  671. patch(
  672. "backend.app.api.routes.settings.get_setting",
  673. new_callable=AsyncMock,
  674. return_value=None, # No settings → fall back to schema defaults
  675. ),
  676. ):
  677. await inst._add_to_print_queue(file_path, "192.168.1.100")
  678. assert len(added_items) == 1
  679. queue_item = added_items[0]
  680. # These must match the AppSettings (Pydantic) defaults in schemas/settings.py
  681. assert queue_item.bed_levelling == "auto"
  682. assert queue_item.flow_cali == "auto"
  683. assert queue_item.vibration_cali is True
  684. assert queue_item.layer_inspect is False
  685. assert queue_item.timelapse is False
  686. @pytest.mark.asyncio
  687. async def test_add_to_print_queue_inherits_slicer_print_options(self, tmp_path):
  688. """#1403: VP-queue items used to fall back to `default_timelapse` even
  689. though the slicer's MQTT `project_file` command carries the user's
  690. actual choice. Capture-via-`on_print_command` flow lets the user's
  691. slicer toggle reach the queue item.
  692. Settings here have timelapse OFF; the slicer's MQTT capture has it ON.
  693. After the fix the queue item must reflect the slicer's choice.
  694. """
  695. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  696. added_items = []
  697. mock_db = AsyncMock()
  698. mock_db.add = MagicMock(side_effect=added_items.append)
  699. mock_db.commit = AsyncMock()
  700. mock_session_factory = MagicMock()
  701. mock_session_ctx = AsyncMock()
  702. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  703. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  704. mock_session_factory.return_value = mock_session_ctx
  705. inst = VirtualPrinterInstance(
  706. vp_id=24,
  707. name="SlicerInherits",
  708. mode="queue",
  709. model="C12",
  710. access_code="12345678",
  711. serial_suffix="391800024",
  712. auto_dispatch=True,
  713. base_dir=tmp_path,
  714. session_factory=mock_session_factory,
  715. )
  716. file_path = tmp_path / "test.3mf"
  717. file_path.write_bytes(b"fake3mf")
  718. # Pre-populate the capture as if MQTT `project_file` arrived already.
  719. # Settings (below) deliberately have timelapse OFF — only the slicer
  720. # capture should drive the resulting queue item.
  721. await inst.on_print_command(
  722. file_path.name,
  723. {
  724. "command": "project_file",
  725. "timelapse": True,
  726. "bed_leveling": False, # Note: MQTT field is single-L `bed_leveling`
  727. "flow_cali": True,
  728. "vibration_cali": False,
  729. "layer_inspect": True,
  730. },
  731. )
  732. settings_map = {
  733. "virtual_printer_archive_name_source": None,
  734. "default_bed_levelling": "true",
  735. "default_flow_cali": "false",
  736. "default_vibration_cali": "true",
  737. "default_layer_inspect": "false",
  738. "default_timelapse": "false",
  739. }
  740. async def fake_get_setting(_db, key):
  741. return settings_map.get(key)
  742. with (
  743. patch(
  744. "backend.app.api.routes.settings.get_setting",
  745. new=fake_get_setting,
  746. ),
  747. ):
  748. await inst._add_to_print_queue(file_path, "192.168.1.100")
  749. assert len(added_items) == 1
  750. queue_item = added_items[0]
  751. assert queue_item.timelapse is True, "Slicer's timelapse=True must override settings.default_timelapse=False"
  752. assert queue_item.bed_levelling == "off", "Slicer's bed_leveling=False must override default_bed_levelling"
  753. assert queue_item.flow_cali == "on"
  754. assert queue_item.vibration_cali is False
  755. assert queue_item.layer_inspect is True
  756. # Capture is consumed — no lingering state for the next print of the same name.
  757. assert file_path.name not in inst._slicer_print_options
  758. @pytest.mark.asyncio
  759. async def test_add_to_print_queue_coerces_slicer_integer_zero_one(self, tmp_path):
  760. """#1403: H-family firmwares carry calibration flags as integers
  761. (0/1) rather than booleans. The capture must coerce both shapes so
  762. H-family-sliced jobs through the VP queue work the same as P1/X1.
  763. """
  764. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  765. added_items = []
  766. mock_db = AsyncMock()
  767. mock_db.add = MagicMock(side_effect=added_items.append)
  768. mock_db.commit = AsyncMock()
  769. mock_session_factory = MagicMock()
  770. mock_session_ctx = AsyncMock()
  771. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  772. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  773. mock_session_factory.return_value = mock_session_ctx
  774. inst = VirtualPrinterInstance(
  775. vp_id=25,
  776. name="SlicerIntegers",
  777. mode="queue",
  778. model="C12",
  779. access_code="12345678",
  780. serial_suffix="391800025",
  781. auto_dispatch=True,
  782. base_dir=tmp_path,
  783. session_factory=mock_session_factory,
  784. )
  785. file_path = tmp_path / "test.3mf"
  786. file_path.write_bytes(b"fake3mf")
  787. await inst.on_print_command(
  788. file_path.name,
  789. {"command": "project_file", "timelapse": 1, "bed_leveling": 0, "flow_cali": 1},
  790. )
  791. with (
  792. patch(
  793. "backend.app.api.routes.settings.get_setting",
  794. new_callable=AsyncMock,
  795. return_value=None,
  796. ),
  797. ):
  798. await inst._add_to_print_queue(file_path, "192.168.1.100")
  799. assert len(added_items) == 1
  800. queue_item = added_items[0]
  801. assert queue_item.timelapse is True, "integer 1 must coerce to True"
  802. assert queue_item.bed_levelling == "off", "integer 0 must coerce to off"
  803. assert queue_item.flow_cali == "on"
  804. @pytest.mark.asyncio
  805. async def test_add_to_print_queue_captures_slicer_auto_from_int_companion(self, tmp_path):
  806. """The slicer's tri-state rides on the int companion (auto_bed_leveling /
  807. extrude_cali_flag). When the slicer picks "Auto" it sends bed_leveling
  808. false + auto_bed_leveling 2; the VP must record "auto", not "off".
  809. """
  810. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  811. added_items = []
  812. mock_db = AsyncMock()
  813. mock_db.add = MagicMock(side_effect=added_items.append)
  814. mock_db.commit = AsyncMock()
  815. mock_session_factory = MagicMock()
  816. mock_session_ctx = AsyncMock()
  817. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  818. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  819. mock_session_factory.return_value = mock_session_ctx
  820. inst = VirtualPrinterInstance(
  821. vp_id=26,
  822. name="SlicerAuto",
  823. mode="queue",
  824. model="C12",
  825. access_code="12345678",
  826. serial_suffix="391800026",
  827. auto_dispatch=True,
  828. base_dir=tmp_path,
  829. session_factory=mock_session_factory,
  830. )
  831. file_path = tmp_path / "test.3mf"
  832. file_path.write_bytes(b"fake3mf")
  833. await inst.on_print_command(
  834. file_path.name,
  835. {
  836. "command": "project_file",
  837. "bed_leveling": False,
  838. "auto_bed_leveling": 2,
  839. "flow_cali": False,
  840. "extrude_cali_flag": 2,
  841. },
  842. )
  843. with (
  844. patch(
  845. "backend.app.api.routes.settings.get_setting",
  846. new_callable=AsyncMock,
  847. return_value=None,
  848. ),
  849. ):
  850. await inst._add_to_print_queue(file_path, "192.168.1.100")
  851. assert len(added_items) == 1
  852. queue_item = added_items[0]
  853. assert queue_item.bed_levelling == "auto", "auto_bed_leveling=2 must record 'auto'"
  854. assert queue_item.flow_cali == "auto", "extrude_cali_flag=2 must record 'auto'"
  855. @pytest.mark.asyncio
  856. async def test_add_to_print_queue_populates_required_filament_types(self, tmp_path):
  857. """#1188: VP queue-mode used to create PrintQueueItems with no
  858. filament fields, so the scheduler fell through to model-only matching
  859. and dispatched onto whatever printer was free regardless of loaded
  860. colour. ``required_filament_types`` is populated unconditionally
  861. (cheap, helps the scheduler validate type even without
  862. ``force_color_match``) — pin that contract here."""
  863. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  864. added_items = []
  865. mock_db = AsyncMock()
  866. mock_db.add = MagicMock(side_effect=added_items.append)
  867. mock_db.commit = AsyncMock()
  868. mock_session_factory = MagicMock()
  869. mock_session_ctx = AsyncMock()
  870. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  871. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  872. mock_session_factory.return_value = mock_session_ctx
  873. inst = VirtualPrinterInstance(
  874. vp_id=21,
  875. name="Reqs",
  876. mode="queue",
  877. model="C12",
  878. access_code="12345678",
  879. serial_suffix="391800021",
  880. auto_dispatch=True,
  881. queue_force_color_match=False, # off → only required_filament_types
  882. base_dir=tmp_path,
  883. session_factory=mock_session_factory,
  884. )
  885. file_path = tmp_path / "multi.3mf"
  886. _write_3mf_with_filaments(
  887. file_path,
  888. [
  889. {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "12.3"},
  890. {"id": "2", "type": "PETG", "color": "#000000", "used_g": "4.5"},
  891. # used_g=0 → not actually consumed by this plate, must be ignored
  892. {"id": "3", "type": "ABS", "color": "#FF0000", "used_g": "0"},
  893. ],
  894. plate_index=1,
  895. )
  896. with (
  897. patch(
  898. "backend.app.api.routes.settings.get_setting",
  899. new_callable=AsyncMock,
  900. return_value=None,
  901. ),
  902. ):
  903. await inst._add_to_print_queue(file_path, "192.168.1.100")
  904. assert len(added_items) == 1
  905. queue_item = added_items[0]
  906. # Type-only fallback always populated. Sorted, deduped, no zero-use ABS.
  907. assert queue_item.required_filament_types is not None
  908. assert json.loads(queue_item.required_filament_types) == ["PETG", "PLA"]
  909. # Setting off → no force_color_match overrides leaked.
  910. assert queue_item.filament_overrides is None
  911. @pytest.mark.asyncio
  912. async def test_add_to_print_queue_force_color_match_writes_overrides(self, tmp_path):
  913. """#1188 core fix: when the per-VP ``queue_force_color_match`` toggle
  914. is on, every consumed slot lands as a ``filament_overrides`` entry
  915. with ``force_color_match: true``. This is the field the scheduler
  916. keys on (``print_scheduler.py:512``) — without it, slot-by-slot
  917. type+color matching never runs."""
  918. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  919. added_items = []
  920. mock_db = AsyncMock()
  921. mock_db.add = MagicMock(side_effect=added_items.append)
  922. mock_db.commit = AsyncMock()
  923. mock_session_factory = MagicMock()
  924. mock_session_ctx = AsyncMock()
  925. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  926. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  927. mock_session_factory.return_value = mock_session_ctx
  928. inst = VirtualPrinterInstance(
  929. vp_id=22,
  930. name="ForceColor",
  931. mode="queue",
  932. model="C12",
  933. access_code="12345678",
  934. serial_suffix="391800022",
  935. auto_dispatch=True,
  936. queue_force_color_match=True, # on
  937. base_dir=tmp_path,
  938. session_factory=mock_session_factory,
  939. )
  940. file_path = tmp_path / "forced.3mf"
  941. _write_3mf_with_filaments(
  942. file_path,
  943. [
  944. {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "10.0"},
  945. {"id": "2", "type": "PLA", "color": "#FF00FF", "used_g": "5.0"},
  946. ],
  947. plate_index=1,
  948. )
  949. with (
  950. patch(
  951. "backend.app.api.routes.settings.get_setting",
  952. new_callable=AsyncMock,
  953. return_value=None,
  954. ),
  955. ):
  956. await inst._add_to_print_queue(file_path, "192.168.1.100")
  957. assert len(added_items) == 1
  958. queue_item = added_items[0]
  959. assert queue_item.filament_overrides is not None
  960. overrides = json.loads(queue_item.filament_overrides)
  961. assert overrides == [
  962. {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "", "force_color_match": True},
  963. {"slot_id": 2, "type": "PLA", "color": "#FF00FF", "tray_info_idx": "", "force_color_match": True},
  964. ]
  965. # required_filament_types still populated alongside overrides.
  966. assert json.loads(queue_item.required_filament_types) == ["PLA"]
  967. @pytest.mark.asyncio
  968. async def test_add_to_print_queue_force_color_match_carries_tray_info_idx(self, tmp_path):
  969. """#2650: the force override must carry the 3MF's ``tray_info_idx`` so the
  970. scheduler can tell Bambu PLA variants apart (Basic GFA00 / Matte GFA01 /
  971. Silk GFA06) — they all report ``tray_type == "PLA"`` with the same colour,
  972. so type+colour alone dispatches onto the wrong variant."""
  973. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  974. added_items = []
  975. mock_db = AsyncMock()
  976. mock_db.add = MagicMock(side_effect=added_items.append)
  977. mock_db.commit = AsyncMock()
  978. mock_session_factory = MagicMock()
  979. mock_session_ctx = AsyncMock()
  980. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  981. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  982. mock_session_factory.return_value = mock_session_ctx
  983. inst = VirtualPrinterInstance(
  984. vp_id=24,
  985. name="Variant",
  986. mode="queue",
  987. model="C12",
  988. access_code="12345678",
  989. serial_suffix="391800024",
  990. auto_dispatch=True,
  991. queue_force_color_match=True,
  992. base_dir=tmp_path,
  993. session_factory=mock_session_factory,
  994. )
  995. file_path = tmp_path / "variant.3mf"
  996. _write_3mf_with_filaments(
  997. file_path,
  998. [
  999. # White PLA Matte — same colour as Basic/Silk, distinguished only by idx.
  1000. {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "10.0", "tray_info_idx": "GFA01"},
  1001. ],
  1002. plate_index=1,
  1003. )
  1004. with (
  1005. patch(
  1006. "backend.app.api.routes.settings.get_setting",
  1007. new_callable=AsyncMock,
  1008. return_value=None,
  1009. ),
  1010. ):
  1011. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1012. assert len(added_items) == 1
  1013. overrides = json.loads(added_items[0].filament_overrides)
  1014. assert overrides == [
  1015. {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA01", "force_color_match": True},
  1016. ]
  1017. @pytest.mark.asyncio
  1018. async def test_add_to_print_queue_force_color_match_skips_when_3mf_unparseable(self, tmp_path):
  1019. """A malformed or fake-bytes 3MF must not crash the upload path —
  1020. we just write the queue item with no filament fields and let the
  1021. scheduler fall back to model-only matching (the pre-#1188 default).
  1022. Regression guard for the existing fake-bytes happy-path tests."""
  1023. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1024. added_items = []
  1025. mock_db = AsyncMock()
  1026. mock_db.add = MagicMock(side_effect=added_items.append)
  1027. mock_db.commit = AsyncMock()
  1028. mock_session_factory = MagicMock()
  1029. mock_session_ctx = AsyncMock()
  1030. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1031. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1032. mock_session_factory.return_value = mock_session_ctx
  1033. inst = VirtualPrinterInstance(
  1034. vp_id=23,
  1035. name="Unparseable",
  1036. mode="queue",
  1037. model="C12",
  1038. access_code="12345678",
  1039. serial_suffix="391800023",
  1040. auto_dispatch=True,
  1041. queue_force_color_match=True,
  1042. base_dir=tmp_path,
  1043. session_factory=mock_session_factory,
  1044. )
  1045. file_path = tmp_path / "bad.3mf"
  1046. file_path.write_bytes(b"not a real 3mf zip")
  1047. with (
  1048. patch(
  1049. "backend.app.api.routes.settings.get_setting",
  1050. new_callable=AsyncMock,
  1051. return_value=None,
  1052. ),
  1053. ):
  1054. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1055. assert len(added_items) == 1
  1056. queue_item = added_items[0]
  1057. # No filament data extractable → both fields stay None (graceful
  1058. # fallback to model-only scheduling).
  1059. assert queue_item.required_filament_types is None
  1060. assert queue_item.filament_overrides is None
  1061. # ========================================================================
  1062. # Tests for archive_name_source setting (#1152)
  1063. # ========================================================================
  1064. # ========================================================================
  1065. # Tests for failure-path cleanup (#audit-R2-1)
  1066. # ========================================================================
  1067. #
  1068. # All three file handlers (_store_in_library, _queue_file, _add_to_print_queue)
  1069. # previously only popped _pending_files and unlinked the temp file on the
  1070. # success branch. Failure paths leaked the marker (blocking same-name
  1071. # retries via the FTP layer) and the temp file on disk. The cleanup must
  1072. # ALWAYS run, even when archival / queue insert raises.
  1073. @pytest.mark.asyncio
  1074. async def test_store_in_library_failure_path_pops_pending_and_unlinks(self, tmp_path):
  1075. """When storing in the File Manager raises, `_pending_files[filename]` must still
  1076. be popped and the temp file must be unlinked. Otherwise the FTP layer's
  1077. same-name retry guard would silently reject the slicer's next attempt
  1078. and the upload_dir would accumulate ghost files."""
  1079. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1080. mock_db = AsyncMock()
  1081. mock_session_factory = MagicMock()
  1082. mock_session_ctx = AsyncMock()
  1083. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1084. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1085. mock_session_factory.return_value = mock_session_ctx
  1086. inst = VirtualPrinterInstance(
  1087. vp_id=40,
  1088. name="LibraryFailCleanup",
  1089. mode="library",
  1090. model="C12",
  1091. access_code="12345678",
  1092. serial_suffix="391800040",
  1093. base_dir=tmp_path,
  1094. session_factory=mock_session_factory,
  1095. )
  1096. file_path = tmp_path / "cleanup-archive.3mf"
  1097. file_path.write_bytes(b"fake3mf")
  1098. inst._pending_files[file_path.name] = file_path
  1099. with patch(
  1100. "backend.app.services.virtual_printer.library_store.store_upload",
  1101. new_callable=AsyncMock,
  1102. side_effect=RuntimeError("store blew up"),
  1103. ):
  1104. await inst._store_in_library(file_path, "192.168.1.100")
  1105. assert file_path.name not in inst._pending_files
  1106. assert not file_path.exists()
  1107. # Personal access codes (#1430): the user the FTP sign-in named.
  1108. @staticmethod
  1109. def _mock_factory(added=None):
  1110. mock_db = AsyncMock()
  1111. mock_db.add = MagicMock(side_effect=(added.append if added is not None else None))
  1112. ctx = AsyncMock()
  1113. ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1114. ctx.__aexit__ = AsyncMock(return_value=False)
  1115. return MagicMock(return_value=ctx)
  1116. def _inst(self, tmp_path, mode, **kwargs):
  1117. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1118. return VirtualPrinterInstance(
  1119. vp_id=50,
  1120. name="Codes",
  1121. mode=mode,
  1122. model="C11",
  1123. access_code="12345678",
  1124. serial_suffix="391800050",
  1125. base_dir=tmp_path,
  1126. session_factory=self._mock_factory(kwargs.pop("added", None)),
  1127. **kwargs,
  1128. )
  1129. @pytest.mark.asyncio
  1130. async def test_library_mode_stores_the_file_as_the_senders(self, tmp_path, _fake_file_manager_store):
  1131. inst = self._inst(tmp_path, "library")
  1132. file_path = tmp_path / "plate.3mf"
  1133. file_path.write_bytes(b"fake3mf")
  1134. await inst.on_file_received(file_path, "192.168.1.100", user_id=7)
  1135. assert _fake_file_manager_store.await_args.kwargs["owner_id"] == 7
  1136. @pytest.mark.asyncio
  1137. async def test_review_mode_records_the_sender(self, tmp_path):
  1138. added = []
  1139. inst = self._inst(tmp_path, "review", added=added)
  1140. file_path = tmp_path / "plate.3mf"
  1141. file_path.write_bytes(b"fake3mf")
  1142. await inst.on_file_received(file_path, "192.168.1.100", user_id=7)
  1143. assert added[0].uploaded_by_id == 7
  1144. @pytest.mark.asyncio
  1145. @pytest.mark.parametrize(("review_required", "manual_start"), [(False, False), (True, True)])
  1146. async def test_queue_mode_jobs_are_the_senders_and_wait_for_review_if_theirs_must(
  1147. self, tmp_path, _fake_file_manager_store, review_required, manual_start
  1148. ):
  1149. added = []
  1150. inst = self._inst(tmp_path, "queue", added=added, auto_dispatch=True)
  1151. file_path = tmp_path / "plate.3mf"
  1152. file_path.write_bytes(b"fake3mf")
  1153. with (
  1154. patch("backend.app.api.routes.settings.get_setting", new_callable=AsyncMock, return_value=None),
  1155. patch("backend.app.core.auth.queue_review_required_for", return_value=review_required),
  1156. ):
  1157. await inst._add_to_print_queue(file_path, "192.168.1.100", user_id=7)
  1158. assert _fake_file_manager_store.await_args.kwargs["owner_id"] == 7
  1159. assert added[0].created_by_id == 7
  1160. assert added[0].manual_start is manual_start
  1161. @pytest.mark.asyncio
  1162. async def test_a_personal_code_is_looked_up_for_this_vps_mode_and_target(self, tmp_path):
  1163. inst = self._inst(tmp_path, "queue", target_printer_id=3)
  1164. lookup = AsyncMock(return_value=MagicMock(id=7))
  1165. with patch("backend.app.services.virtual_printer.user_codes.user_for_code", lookup):
  1166. assert await inst.authenticate_user_code("87654321") == 7
  1167. assert lookup.await_args.kwargs == {"mode": "queue", "target_printer_id": 3}
  1168. @pytest.mark.asyncio
  1169. async def test_no_personal_codes_for_a_proxy_or_when_the_lookup_fails(self, tmp_path):
  1170. proxy = self._inst(tmp_path, "proxy")
  1171. lookup = AsyncMock(return_value=MagicMock(id=7))
  1172. with patch("backend.app.services.virtual_printer.user_codes.user_for_code", lookup):
  1173. assert await proxy.authenticate_user_code("87654321") is None
  1174. lookup.assert_not_awaited()
  1175. broken = self._inst(tmp_path, "library")
  1176. with patch(
  1177. "backend.app.services.virtual_printer.user_codes.user_for_code", AsyncMock(side_effect=RuntimeError("db"))
  1178. ):
  1179. assert await broken.authenticate_user_code("87654321") is None
  1180. @pytest.mark.asyncio
  1181. async def test_queue_file_records_the_virtual_printer(self, tmp_path):
  1182. """Review mode: the pending upload names the VP it came in on, so
  1183. approving it stores the file in that VP's File Manager folder."""
  1184. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1185. added = []
  1186. mock_db = AsyncMock()
  1187. mock_db.add = MagicMock(side_effect=added.append)
  1188. mock_session_ctx = AsyncMock()
  1189. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1190. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1191. inst = VirtualPrinterInstance(
  1192. vp_id=42,
  1193. name="Review",
  1194. mode="review",
  1195. model="C12",
  1196. access_code="12345678",
  1197. serial_suffix="391800042",
  1198. base_dir=tmp_path,
  1199. session_factory=MagicMock(return_value=mock_session_ctx),
  1200. )
  1201. file_path = tmp_path / "review.3mf"
  1202. file_path.write_bytes(b"fake3mf")
  1203. await inst._queue_file(file_path, "192.168.1.100")
  1204. # The slicer sends the next plate under the same name, to the same place.
  1205. file_path.write_bytes(b"other3mf")
  1206. await inst._queue_file(file_path, "192.168.1.100")
  1207. assert len(added) == 2
  1208. assert added[0].virtual_printer_id == 42
  1209. assert [a.filename for a in added] == ["review.3mf", "review.3mf"]
  1210. # Each waits in a folder of its own, so neither replaced the other.
  1211. first, second = Path(added[0].file_path), Path(added[1].file_path)
  1212. assert first != second
  1213. assert first.read_bytes() == b"fake3mf"
  1214. assert second.read_bytes() == b"other3mf"
  1215. assert not file_path.exists()
  1216. @pytest.mark.asyncio
  1217. async def test_queue_file_failure_path_pops_pending_and_unlinks(self, tmp_path):
  1218. """Same invariant for _queue_file: a DB error during PendingUpload
  1219. insert must not leak the in-flight marker or the temp file."""
  1220. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1221. mock_db = AsyncMock()
  1222. # Commit raises — emulating a DB connectivity error.
  1223. mock_db.add = MagicMock()
  1224. mock_db.commit = AsyncMock(side_effect=RuntimeError("db unreachable"))
  1225. mock_session_factory = MagicMock()
  1226. mock_session_ctx = AsyncMock()
  1227. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1228. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1229. mock_session_factory.return_value = mock_session_ctx
  1230. inst = VirtualPrinterInstance(
  1231. vp_id=41,
  1232. name="QueueFailCleanup",
  1233. mode="review",
  1234. model="C12",
  1235. access_code="12345678",
  1236. serial_suffix="391800041",
  1237. base_dir=tmp_path,
  1238. session_factory=mock_session_factory,
  1239. )
  1240. file_path = tmp_path / "cleanup-queue.3mf"
  1241. file_path.write_bytes(b"fake3mf")
  1242. inst._pending_files[file_path.name] = file_path
  1243. await inst._queue_file(file_path, "192.168.1.100")
  1244. assert file_path.name not in inst._pending_files
  1245. assert not file_path.exists()
  1246. @pytest.mark.asyncio
  1247. async def test_add_to_print_queue_failure_path_pops_pending_and_unlinks(self, tmp_path):
  1248. """Same invariant for _add_to_print_queue: a DB error or archive
  1249. failure must not leak the in-flight marker or the temp file."""
  1250. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1251. mock_db = AsyncMock()
  1252. mock_db.add = MagicMock()
  1253. mock_db.commit = AsyncMock()
  1254. mock_db.execute = AsyncMock(side_effect=RuntimeError("queue insert blew up"))
  1255. mock_session_factory = MagicMock()
  1256. mock_session_ctx = AsyncMock()
  1257. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1258. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1259. mock_session_factory.return_value = mock_session_ctx
  1260. inst = VirtualPrinterInstance(
  1261. vp_id=42,
  1262. name="DispatchFailCleanup",
  1263. mode="queue",
  1264. model="C12",
  1265. access_code="12345678",
  1266. serial_suffix="391800042",
  1267. auto_dispatch=True,
  1268. base_dir=tmp_path,
  1269. session_factory=mock_session_factory,
  1270. )
  1271. file_path = tmp_path / "cleanup-dispatch.3mf"
  1272. file_path.write_bytes(b"fake3mf")
  1273. inst._pending_files[file_path.name] = file_path
  1274. with patch(
  1275. "backend.app.api.routes.settings.get_setting",
  1276. new_callable=AsyncMock,
  1277. return_value=None,
  1278. ):
  1279. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1280. assert file_path.name not in inst._pending_files
  1281. assert not file_path.exists()
  1282. # ========================================================================
  1283. # Test for position=MAX+1 (audit-R2)
  1284. # ========================================================================
  1285. @pytest.mark.asyncio
  1286. async def test_add_to_print_queue_position_picks_max_plus_one(self, tmp_path):
  1287. """VP-queue items previously got hardcoded `position=1`, colliding
  1288. with existing items at position 1 and producing non-deterministic
  1289. execution order. Now the position is chosen by `MAX(position)+1`
  1290. against the target queue, matching the canonical `POST /print-queue/`
  1291. path."""
  1292. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1293. # Capture the inserted PrintQueueItem so we can assert on .position.
  1294. added_items: list = []
  1295. class _RecordingDb:
  1296. def __init__(self):
  1297. self.add = lambda item: added_items.append(item)
  1298. self.commit = AsyncMock()
  1299. async def execute(self, query): # noqa: ARG002
  1300. """Return a stub result whose `.scalar()` reports the existing
  1301. MAX(position) for the target. Returning 7 means the new item
  1302. should land at 8."""
  1303. result = MagicMock()
  1304. result.scalar = MagicMock(return_value=7)
  1305. return result
  1306. mock_db = _RecordingDb()
  1307. mock_session_factory = MagicMock()
  1308. mock_session_ctx = AsyncMock()
  1309. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1310. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1311. mock_session_factory.return_value = mock_session_ctx
  1312. inst = VirtualPrinterInstance(
  1313. vp_id=43,
  1314. name="PositionMaxPlusOne",
  1315. mode="queue",
  1316. model="C12",
  1317. access_code="12345678",
  1318. serial_suffix="391800043",
  1319. target_printer_id=99,
  1320. auto_dispatch=True,
  1321. base_dir=tmp_path,
  1322. session_factory=mock_session_factory,
  1323. )
  1324. file_path = tmp_path / "next-position.3mf"
  1325. file_path.write_bytes(b"fake3mf")
  1326. with (
  1327. patch(
  1328. "backend.app.api.routes.settings.get_setting",
  1329. new_callable=AsyncMock,
  1330. return_value=None,
  1331. ),
  1332. patch(
  1333. "backend.app.core.websocket.ws_manager.send_library_files_changed",
  1334. new_callable=AsyncMock,
  1335. ),
  1336. ):
  1337. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1338. # One queue item was added.
  1339. assert len(added_items) == 1
  1340. queue_item = added_items[0]
  1341. # Position = max(7) + 1 = 8 — NOT the legacy hardcoded 1.
  1342. assert queue_item.position == 8
  1343. @pytest.mark.asyncio
  1344. async def test_add_to_print_queue_multi_plate_send_all_enqueues_one_per_plate(self, tmp_path):
  1345. """#1733: BambuStudio / OrcaSlicer "Send All" of a multi-plate project
  1346. uploads ONE 3MF containing every plate. Pre-fix only the first plate
  1347. index was extracted and one queue item was created; plates 2..N were
  1348. silently dropped. Post-fix every `<plate>` block in `slice_info.config`
  1349. produces its own PrintQueueItem with the correct ``plate_id``, sharing
  1350. the same backing archive, with consecutive positions for plate-order
  1351. execution.
  1352. """
  1353. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1354. added_items: list = []
  1355. class _RecordingDb:
  1356. def __init__(self):
  1357. # Capture inserted items as they're added; assign a fake .id
  1358. # on flush so the manager's logger doesn't see None.
  1359. self._next_id = 1000
  1360. def _add(item):
  1361. added_items.append(item)
  1362. self.add = _add
  1363. self.commit = AsyncMock()
  1364. async def execute(self, query): # noqa: ARG002
  1365. """Return MAX(position) = 0 so plate items land at 1, 2, 3."""
  1366. result = MagicMock()
  1367. result.scalar = MagicMock(return_value=0)
  1368. return result
  1369. async def flush(self):
  1370. # Mimic the FK populate so queue_item.id is available after add().
  1371. for item in added_items:
  1372. if getattr(item, "id", None) is None:
  1373. item.id = self._next_id
  1374. self._next_id += 1
  1375. mock_db = _RecordingDb()
  1376. mock_session_factory = MagicMock()
  1377. mock_session_ctx = AsyncMock()
  1378. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1379. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1380. mock_session_factory.return_value = mock_session_ctx
  1381. inst = VirtualPrinterInstance(
  1382. vp_id=44,
  1383. name="MultiPlateSendAll",
  1384. mode="queue",
  1385. model="O1D", # H2D — matches the live VP H2D-1 Proxy in #1733
  1386. access_code="12345678",
  1387. serial_suffix="391800044",
  1388. target_printer_id=1,
  1389. auto_dispatch=False, # manual_start, mirrors the live VP
  1390. base_dir=tmp_path,
  1391. session_factory=mock_session_factory,
  1392. )
  1393. # Build a 3MF with three plates baked into slice_info.config —
  1394. # mirrors what BambuStudio / OrcaSlicer's "Send All" puts on the wire.
  1395. file_path = tmp_path / "Cube.gcode.3mf"
  1396. _write_3mf_with_filaments(
  1397. file_path, [{"id": 1, "type": "PLA", "color": "#000000", "used_g": "15.61"}], plate_index=1
  1398. )
  1399. # Append plate 2 and 3 blocks to slice_info.config to mimic Send All.
  1400. with zipfile.ZipFile(file_path, "r") as zf:
  1401. existing = zf.read("Metadata/slice_info.config").decode()
  1402. # Inject two additional <plate> blocks (indices 2 and 3) inside <config>.
  1403. multi_plate_config = existing.replace(
  1404. "</config>",
  1405. (
  1406. '<plate><metadata key="index" value="2"/>'
  1407. '<filament id="2" type="PETG" color="#FB0207" used_g="14.45"/>'
  1408. "</plate>"
  1409. '<plate><metadata key="index" value="3"/>'
  1410. '<filament id="3" type="PLA" color="#FFFFFF" used_g="12.10"/>'
  1411. "</plate>"
  1412. "</config>"
  1413. ),
  1414. )
  1415. # Repack the zip with the expanded slice_info.config.
  1416. import io as _io
  1417. buf = _io.BytesIO()
  1418. with zipfile.ZipFile(file_path, "r") as src, zipfile.ZipFile(buf, "w") as dst:
  1419. for name in src.namelist():
  1420. if name == "Metadata/slice_info.config":
  1421. dst.writestr(name, multi_plate_config)
  1422. else:
  1423. dst.writestr(name, src.read(name))
  1424. # Plate-2 and plate-3 gcode payloads so `extract_filament_requirements`
  1425. # has something to read for each — contents irrelevant, presence matters.
  1426. dst.writestr("Metadata/plate_2.gcode", "; plate 2 gcode\n")
  1427. dst.writestr("Metadata/plate_3.gcode", "; plate 3 gcode\n")
  1428. file_path.write_bytes(buf.getvalue())
  1429. with (
  1430. patch(
  1431. "backend.app.api.routes.settings.get_setting",
  1432. new_callable=AsyncMock,
  1433. return_value=None,
  1434. ),
  1435. patch(
  1436. "backend.app.core.websocket.ws_manager.send_library_files_changed",
  1437. new_callable=AsyncMock,
  1438. ),
  1439. ):
  1440. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1441. # Three queue items, one per plate, with the correct plate_id and
  1442. # consecutive positions starting at MAX(position)+1 = 1.
  1443. assert len(added_items) == 3, f"Expected 3 queue items for 3-plate Send All, got {len(added_items)}"
  1444. plate_ids = [q.plate_id for q in added_items]
  1445. assert plate_ids == [1, 2, 3], f"plate_ids should preserve slice_info order, got {plate_ids}"
  1446. positions = [q.position for q in added_items]
  1447. assert positions == [1, 2, 3], f"positions should be consecutive, got {positions}"
  1448. library_file_ids = {q.library_file_id for q in added_items}
  1449. assert library_file_ids == {1}, f"All queue items must print the one File Manager file, got {library_file_ids}"
  1450. assert {q.archive_id for q in added_items} == {None}, "the archive is made when a plate prints"
  1451. # auto_dispatch=False on the VP → every item is manual_start.
  1452. assert all(q.manual_start for q in added_items)
  1453. @pytest.mark.asyncio
  1454. async def test_add_to_print_queue_captures_nozzle_mapping(self, tmp_path):
  1455. """#1780: BambuStudio's project_file for H2C rack-swap (O1C2) sends
  1456. per-filament physical nozzle position IDs in `nozzle_mapping`. VP
  1457. intake must store it as a JSON string on the queue item so the
  1458. dispatcher can replay it. Without this the H2C firmware falls back
  1459. to "last matching nozzle" auto-pick and ignores the user's slicer
  1460. choice.
  1461. """
  1462. import json as _json
  1463. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1464. added_items = []
  1465. mock_db = AsyncMock()
  1466. mock_db.add = MagicMock(side_effect=added_items.append)
  1467. mock_db.commit = AsyncMock()
  1468. mock_session_factory = MagicMock()
  1469. mock_session_ctx = AsyncMock()
  1470. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1471. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1472. mock_session_factory.return_value = mock_session_ctx
  1473. inst = VirtualPrinterInstance(
  1474. # The slicer's nozzle positions are kept only for a VP bound to one printer.
  1475. target_printer_id=7,
  1476. vp_id=42,
  1477. name="H2CRack",
  1478. mode="queue",
  1479. model="O1C2",
  1480. access_code="12345678",
  1481. serial_suffix="391800042",
  1482. base_dir=tmp_path,
  1483. session_factory=mock_session_factory,
  1484. )
  1485. file_path = tmp_path / "test.3mf"
  1486. file_path.write_bytes(b"fake3mf")
  1487. # Pre-populate as if BS's project_file arrived. Wire shape matches
  1488. # BambuStudio's PrintJob params: nozzle_mapping = 32-entry array of
  1489. # per-filament physical nozzle position IDs (verified via H2C wire
  1490. # capture). The slicer-side `nozzles_info` field that the original
  1491. # #1780 attempt also looked for was never actually sent — it has
  1492. # been dropped from the capture path entirely.
  1493. await inst.on_print_command(
  1494. file_path.name,
  1495. {
  1496. "command": "project_file",
  1497. "nozzle_mapping": [16, -1, -1, 1, -1, -1, -1, -1],
  1498. },
  1499. )
  1500. with (
  1501. patch(
  1502. "backend.app.api.routes.settings.get_setting",
  1503. new_callable=AsyncMock,
  1504. return_value=None,
  1505. ),
  1506. ):
  1507. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1508. assert len(added_items) == 1
  1509. item = added_items[0]
  1510. assert item.nozzle_mapping is not None
  1511. assert _json.loads(item.nozzle_mapping) == [16, -1, -1, 1, -1, -1, -1, -1]
  1512. @pytest.mark.asyncio
  1513. async def test_add_to_print_queue_no_nozzle_mapping_when_slicer_omits(self, tmp_path):
  1514. """#1780: every model other than O1C2 sends no nozzle_mapping — the
  1515. queue item must carry NULL, not an empty list. NULL is what the
  1516. dispatch layer keys off of to skip the injection entirely on non-
  1517. rack-swap printers.
  1518. """
  1519. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1520. added_items = []
  1521. mock_db = AsyncMock()
  1522. mock_db.add = MagicMock(side_effect=added_items.append)
  1523. mock_db.commit = AsyncMock()
  1524. mock_session_factory = MagicMock()
  1525. mock_session_ctx = AsyncMock()
  1526. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1527. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1528. mock_session_factory.return_value = mock_session_ctx
  1529. inst = VirtualPrinterInstance(
  1530. vp_id=43,
  1531. name="NotH2C",
  1532. mode="queue",
  1533. model="C11",
  1534. access_code="12345678",
  1535. serial_suffix="391800043",
  1536. base_dir=tmp_path,
  1537. session_factory=mock_session_factory,
  1538. )
  1539. file_path = tmp_path / "test.3mf"
  1540. file_path.write_bytes(b"fake3mf")
  1541. # X1C-style slicer command — no nozzle fields.
  1542. await inst.on_print_command(
  1543. file_path.name,
  1544. {"command": "project_file", "timelapse": False, "bed_leveling": True},
  1545. )
  1546. with (
  1547. patch(
  1548. "backend.app.api.routes.settings.get_setting",
  1549. new_callable=AsyncMock,
  1550. return_value=None,
  1551. ),
  1552. ):
  1553. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1554. assert len(added_items) == 1
  1555. item = added_items[0]
  1556. assert item.nozzle_mapping is None
  1557. @pytest.mark.asyncio
  1558. async def test_add_to_print_queue_ignores_ams_mapping_when_toggle_off(self, tmp_path):
  1559. """With the per-VP `save_ams_mapping` toggle off, the slicer's
  1560. AMS-slot pick must be ignored entirely — neither stamped on the queue
  1561. item nor persisted to the archive.
  1562. Taking the slicer's pick makes `_ensure_ams_mapping` return early, so
  1563. `_compute_ams_mapping_for_printer` never runs — and that's where
  1564. `prefer_lowest_filament`, its AMS-filament-backup gate (#1766) and the
  1565. inventory-remain overrides live. Honouring it unconditionally would
  1566. retire all of that for every existing queue-mode VP on upgrade, so it
  1567. is opt-in like every other queue-mode behaviour toggle (#2700 review).
  1568. """
  1569. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1570. added_items = []
  1571. mock_db = AsyncMock()
  1572. mock_db.add = MagicMock(side_effect=added_items.append)
  1573. mock_db.commit = AsyncMock()
  1574. mock_session_factory = MagicMock()
  1575. mock_session_ctx = AsyncMock()
  1576. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1577. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1578. mock_session_factory.return_value = mock_session_ctx
  1579. inst = VirtualPrinterInstance(
  1580. vp_id=44,
  1581. name="AMSMappingOff",
  1582. mode="queue",
  1583. model="X1C",
  1584. access_code="12345678",
  1585. serial_suffix="391800044",
  1586. base_dir=tmp_path,
  1587. session_factory=mock_session_factory,
  1588. save_ams_mapping=False,
  1589. target_printer_id=7,
  1590. )
  1591. file_path = tmp_path / "test.3mf"
  1592. file_path.write_bytes(b"fake3mf")
  1593. await inst.on_print_command(
  1594. file_path.name,
  1595. {"command": "project_file", "ams_mapping": [4, -1, 12, -1]},
  1596. )
  1597. with (
  1598. patch(
  1599. "backend.app.api.routes.settings.get_setting",
  1600. new_callable=AsyncMock,
  1601. return_value=None,
  1602. ),
  1603. ):
  1604. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1605. assert len(added_items) == 1
  1606. assert added_items[0].ams_mapping is None
  1607. assert added_items[0].slicer_ams_mapping is None
  1608. @pytest.mark.asyncio
  1609. async def test_add_to_print_queue_persists_ams_mapping_to_archive_when_toggle_on(self, tmp_path):
  1610. """With the per-VP `save_ams_mapping` toggle on, the slicer's AMS
  1611. pick must also be kept on the queue item, so the archive its print
  1612. creates gets it as `extra_data.slicer_ams_mapping` and a later
  1613. reprint can reuse the exact physical spool.
  1614. """
  1615. import json as _json
  1616. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1617. added_items = []
  1618. mock_db = AsyncMock()
  1619. mock_db.add = MagicMock(side_effect=added_items.append)
  1620. mock_db.commit = AsyncMock()
  1621. mock_session_factory = MagicMock()
  1622. mock_session_ctx = AsyncMock()
  1623. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1624. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1625. mock_session_factory.return_value = mock_session_ctx
  1626. inst = VirtualPrinterInstance(
  1627. vp_id=45,
  1628. name="AMSMappingOn",
  1629. mode="queue",
  1630. model="X1C",
  1631. access_code="12345678",
  1632. serial_suffix="391800045",
  1633. base_dir=tmp_path,
  1634. session_factory=mock_session_factory,
  1635. save_ams_mapping=True,
  1636. target_printer_id=7,
  1637. )
  1638. file_path = tmp_path / "test.3mf"
  1639. file_path.write_bytes(b"fake3mf")
  1640. await inst.on_print_command(
  1641. file_path.name,
  1642. {"command": "project_file", "ams_mapping": [4, -1, 12, -1]},
  1643. )
  1644. with (
  1645. patch(
  1646. "backend.app.api.routes.settings.get_setting",
  1647. new_callable=AsyncMock,
  1648. return_value=None,
  1649. ),
  1650. ):
  1651. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1652. assert len(added_items) == 1
  1653. assert _json.loads(added_items[0].ams_mapping) == [4, -1, 12, -1]
  1654. assert json.loads(added_items[0].slicer_ams_mapping) == {"mapping": [4, -1, 12, -1], "printer_id": 7}
  1655. assert added_items[0].printer_id == 7 # the printer the pick was made against
  1656. @pytest.mark.asyncio
  1657. async def test_add_to_print_queue_ignores_ams_mapping_for_model_based_vp(self, tmp_path):
  1658. """A model-based VP (`target_printer_id=None`, dispatched later by the
  1659. scheduler to whichever printer matches) has no MQTT bridge to a real
  1660. printer, so the slicer has no live AMS layout to resolve tray IDs
  1661. against. Whatever it sends must be ignored on both the queue item and
  1662. the archive — trusting it would dispatch the eventually-chosen
  1663. printer onto a tray resolved against nothing (#2700 review).
  1664. """
  1665. import json as _json
  1666. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1667. added_items = []
  1668. mock_db = AsyncMock()
  1669. mock_db.add = MagicMock(side_effect=added_items.append)
  1670. mock_db.commit = AsyncMock()
  1671. mock_session_factory = MagicMock()
  1672. mock_session_ctx = AsyncMock()
  1673. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1674. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1675. mock_session_factory.return_value = mock_session_ctx
  1676. inst = VirtualPrinterInstance(
  1677. vp_id=451,
  1678. name="AMSMappingModelBased",
  1679. mode="queue",
  1680. model="X1C",
  1681. access_code="12345678",
  1682. serial_suffix="391800451",
  1683. base_dir=tmp_path,
  1684. session_factory=mock_session_factory,
  1685. save_ams_mapping=True,
  1686. target_printer_id=None,
  1687. )
  1688. file_path = tmp_path / "test.3mf"
  1689. file_path.write_bytes(b"fake3mf")
  1690. await inst.on_print_command(
  1691. file_path.name,
  1692. {"command": "project_file", "ams_mapping": [4, -1, 12, -1]},
  1693. )
  1694. with (
  1695. patch(
  1696. "backend.app.api.routes.settings.get_setting",
  1697. new_callable=AsyncMock,
  1698. return_value=None,
  1699. ),
  1700. ):
  1701. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1702. assert len(added_items) == 1
  1703. assert added_items[0].ams_mapping is None
  1704. assert added_items[0].slicer_ams_mapping is None
  1705. @pytest.mark.asyncio
  1706. async def test_add_to_print_queue_ignores_unresolved_ams_mapping_sentinel(self, tmp_path):
  1707. """#2589: an all -1 `ams_mapping` means the slicer's own race lost —
  1708. every slot unresolved. Must be dropped (queue item AND archive),
  1709. never trusted over a fresh scheduler-computed mapping.
  1710. """
  1711. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1712. added_items = []
  1713. mock_db = AsyncMock()
  1714. mock_db.add = MagicMock(side_effect=added_items.append)
  1715. mock_db.commit = AsyncMock()
  1716. mock_session_factory = MagicMock()
  1717. mock_session_ctx = AsyncMock()
  1718. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1719. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1720. mock_session_factory.return_value = mock_session_ctx
  1721. inst = VirtualPrinterInstance(
  1722. vp_id=46,
  1723. name="AMSMappingSentinel",
  1724. mode="queue",
  1725. model="X1C",
  1726. access_code="12345678",
  1727. serial_suffix="391800046",
  1728. base_dir=tmp_path,
  1729. session_factory=mock_session_factory,
  1730. save_ams_mapping=True,
  1731. target_printer_id=7,
  1732. )
  1733. file_path = tmp_path / "test.3mf"
  1734. file_path.write_bytes(b"fake3mf")
  1735. await inst.on_print_command(
  1736. file_path.name,
  1737. {"command": "project_file", "ams_mapping": [-1, -1, -1, -1]},
  1738. )
  1739. with (
  1740. patch(
  1741. "backend.app.api.routes.settings.get_setting",
  1742. new_callable=AsyncMock,
  1743. return_value=None,
  1744. ),
  1745. ):
  1746. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1747. assert len(added_items) == 1
  1748. assert added_items[0].ams_mapping is None
  1749. assert added_items[0].slicer_ams_mapping is None
  1750. @pytest.mark.asyncio
  1751. async def test_add_to_print_queue_force_color_match_keeps_mapping_off_the_queue_item(self, tmp_path):
  1752. """`Force color match` and `Save AMS mapping` both on: the archive
  1753. still gets the slicer's pick (that's what the toggle promises for
  1754. later reprints), but THIS dispatch does not.
  1755. Force color match's only effect on a fixed-printer item is via the
  1756. per-slot `filament_overrides`, which are consumed inside
  1757. `_compute_ams_mapping_for_printer` — the exact function a stored
  1758. mapping makes `_ensure_ams_mapping` skip. Leaving the mapping on the
  1759. item would silently retire the user's explicit strictness (#2700
  1760. review).
  1761. """
  1762. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1763. added_items = []
  1764. mock_db = AsyncMock()
  1765. mock_db.add = MagicMock(side_effect=added_items.append)
  1766. mock_db.flush = AsyncMock()
  1767. mock_db.commit = AsyncMock()
  1768. mock_session_factory = MagicMock()
  1769. mock_session_ctx = AsyncMock()
  1770. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1771. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1772. mock_session_factory.return_value = mock_session_ctx
  1773. inst = VirtualPrinterInstance(
  1774. vp_id=47,
  1775. name="AMSMappingVsForceColor",
  1776. mode="queue",
  1777. model="X1C",
  1778. access_code="12345678",
  1779. serial_suffix="391800047",
  1780. base_dir=tmp_path,
  1781. session_factory=mock_session_factory,
  1782. save_ams_mapping=True,
  1783. queue_force_color_match=True,
  1784. target_printer_id=7,
  1785. )
  1786. file_path = tmp_path / "test.3mf"
  1787. file_path.write_bytes(b"fake3mf")
  1788. await inst.on_print_command(
  1789. file_path.name,
  1790. {"command": "project_file", "ams_mapping": [4, -1, 12, -1]},
  1791. )
  1792. with (
  1793. patch(
  1794. "backend.app.api.routes.settings.get_setting",
  1795. new_callable=AsyncMock,
  1796. return_value=None,
  1797. ),
  1798. ):
  1799. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1800. assert len(added_items) == 1
  1801. assert added_items[0].ams_mapping is None
  1802. assert json.loads(added_items[0].slicer_ams_mapping) == {"mapping": [4, -1, 12, -1], "printer_id": 7}
  1803. assert added_items[0].printer_id == 7 # the printer the pick was made against
  1804. @pytest.mark.asyncio
  1805. async def test_add_to_print_queue_drops_ams_mapping_too_short_for_the_plate(self, tmp_path, monkeypatch):
  1806. """A slicer mapping shorter than the plate's highest slot id can't
  1807. address that plate's own slots, and `_ensure_ams_mapping` would keep it
  1808. anyway (it only rejects an all-unresolved mapping). Drop it for that
  1809. plate so the scheduler computes one from live AMS state instead.
  1810. The mapping is indexed by the 3MF's file-global slot ids, so the plate
  1811. that fits keeps it — only the one that doesn't falls back (#2700
  1812. review).
  1813. """
  1814. import json as _json
  1815. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1816. added_items = []
  1817. mock_db = AsyncMock()
  1818. mock_db.add = MagicMock(side_effect=added_items.append)
  1819. mock_db.flush = AsyncMock()
  1820. mock_db.commit = AsyncMock()
  1821. mock_db.execute = AsyncMock()
  1822. mock_db.execute.return_value.scalar.return_value = None
  1823. mock_session_factory = MagicMock()
  1824. mock_session_ctx = AsyncMock()
  1825. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1826. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1827. mock_session_factory.return_value = mock_session_ctx
  1828. inst = VirtualPrinterInstance(
  1829. vp_id=48,
  1830. name="AMSMappingShort",
  1831. mode="queue",
  1832. model="X1C",
  1833. access_code="12345678",
  1834. serial_suffix="391800048",
  1835. base_dir=tmp_path,
  1836. session_factory=mock_session_factory,
  1837. save_ams_mapping=True,
  1838. target_printer_id=7,
  1839. )
  1840. file_path = tmp_path / "test.3mf"
  1841. file_path.write_bytes(b"fake3mf")
  1842. monkeypatch.setattr(inst, "_extract_plate_ids", lambda _p: [1, 2])
  1843. # Plate 1 prints slots 1-2 (fits the 2-entry mapping); plate 2 also
  1844. # prints slot 4, which the mapping cannot address.
  1845. def fake_reqs(_path, plate_id):
  1846. if plate_id == 1:
  1847. return [
  1848. {"slot_id": 1, "type": "PLA", "color": "#FF0000"},
  1849. {"slot_id": 2, "type": "PLA", "color": "#00FF00"},
  1850. ]
  1851. return [
  1852. {"slot_id": 1, "type": "PLA", "color": "#FF0000"},
  1853. {"slot_id": 4, "type": "PLA", "color": "#0000FF"},
  1854. ]
  1855. monkeypatch.setattr(
  1856. "backend.app.services.filament_requirements.extract_filament_requirements",
  1857. fake_reqs,
  1858. )
  1859. await inst.on_print_command(
  1860. file_path.name,
  1861. {"command": "project_file", "ams_mapping": [4, 12]},
  1862. )
  1863. with (
  1864. patch(
  1865. "backend.app.api.routes.settings.get_setting",
  1866. new_callable=AsyncMock,
  1867. return_value=None,
  1868. ),
  1869. ):
  1870. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1871. assert len(added_items) == 2
  1872. assert _json.loads(added_items[0].ams_mapping) == [4, 12]
  1873. assert added_items[1].ams_mapping is None
  1874. # The archive keeps the mapping either way — it is the file's, not one
  1875. # plate's, and a reprint of the plate that fits still wants it.
  1876. assert json.loads(added_items[0].slicer_ams_mapping)["mapping"] == [4, 12]
  1877. @pytest.mark.asyncio
  1878. async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch):
  1879. """#1780 × #1697/#1188: a multi-plate Send All from BS must stamp the
  1880. same nozzle_mapping on every plate's queue item, not only the first.
  1881. Mirrors the per-plate stamping for gcode_injection,
  1882. filament_overrides, etc.
  1883. """
  1884. import json as _json
  1885. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1886. added_items = []
  1887. mock_db = AsyncMock()
  1888. mock_db.add = MagicMock(side_effect=added_items.append)
  1889. mock_db.flush = AsyncMock()
  1890. mock_db.commit = AsyncMock()
  1891. mock_db.execute = AsyncMock()
  1892. mock_db.execute.return_value.scalar.return_value = None
  1893. mock_session_factory = MagicMock()
  1894. mock_session_ctx = AsyncMock()
  1895. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1896. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1897. mock_session_factory.return_value = mock_session_ctx
  1898. inst = VirtualPrinterInstance(
  1899. # The slicer's nozzle positions are kept only for a VP bound to one printer.
  1900. target_printer_id=7,
  1901. vp_id=44,
  1902. name="H2CMultiPlate",
  1903. mode="queue",
  1904. model="O1C2",
  1905. access_code="12345678",
  1906. serial_suffix="391800044",
  1907. base_dir=tmp_path,
  1908. session_factory=mock_session_factory,
  1909. )
  1910. file_path = tmp_path / "test.3mf"
  1911. file_path.write_bytes(b"fake3mf")
  1912. # Force 3 plates so the queue loop runs three times.
  1913. monkeypatch.setattr(inst, "_extract_plate_ids", lambda _p: [1, 2, 3])
  1914. await inst.on_print_command(
  1915. file_path.name,
  1916. {
  1917. "command": "project_file",
  1918. "nozzle_mapping": [16, 0],
  1919. },
  1920. )
  1921. with (
  1922. patch(
  1923. "backend.app.api.routes.settings.get_setting",
  1924. new_callable=AsyncMock,
  1925. return_value=None,
  1926. ),
  1927. ):
  1928. await inst._add_to_print_queue(file_path, "192.168.1.100")
  1929. assert len(added_items) == 3
  1930. for item in added_items:
  1931. assert _json.loads(item.nozzle_mapping) == [16, 0]
  1932. @pytest.mark.asyncio
  1933. async def test_on_print_command_late_mqtt_retroactively_stamps_queue_item(self, tmp_path):
  1934. """#1780 round 3: Bambu Studio's MQTT project_file can arrive AFTER
  1935. `_add_to_print_queue` already gave up waiting (observed at 2.085 s
  1936. on H2C wireless setups). The queue item was committed with settings
  1937. defaults; the slicer's nozzle_mapping + workflow flags must be
  1938. patched onto it when MQTT lands, otherwise the H2C firmware falls
  1939. back to auto-pick.
  1940. """
  1941. import json as _json
  1942. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  1943. added_items: list = []
  1944. mock_db = AsyncMock()
  1945. mock_db.add = MagicMock(
  1946. side_effect=lambda item: (added_items.append(item), setattr(item, "id", 100 + len(added_items)))[0]
  1947. )
  1948. async def _flush():
  1949. # added_items[-1].id was set by `add`; nothing else to do.
  1950. return None
  1951. mock_db.flush = AsyncMock(side_effect=_flush)
  1952. mock_db.commit = AsyncMock()
  1953. # First execute() call (the position-max SELECT inside _add_to_print_queue)
  1954. # returns None; second (the eligible-pending SELECT in
  1955. # _restamp_recent_queue_item) returns the committed queue id; third
  1956. # (the UPDATE) is fire-and-forget.
  1957. position_max_result = MagicMock()
  1958. position_max_result.scalar = MagicMock(return_value=None)
  1959. select_pending_result = MagicMock()
  1960. select_pending_result.all = MagicMock(return_value=[(101, None, None)])
  1961. update_result = MagicMock()
  1962. mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
  1963. mock_session_factory = MagicMock()
  1964. mock_session_ctx = AsyncMock()
  1965. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  1966. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  1967. mock_session_factory.return_value = mock_session_ctx
  1968. inst = VirtualPrinterInstance(
  1969. # The slicer's nozzle positions are kept only for a VP bound to one printer.
  1970. target_printer_id=7,
  1971. vp_id=99,
  1972. name="LateMQTT",
  1973. mode="queue",
  1974. model="O1C2",
  1975. access_code="12345678",
  1976. serial_suffix="391800099",
  1977. base_dir=tmp_path,
  1978. session_factory=mock_session_factory,
  1979. )
  1980. # MQTT server presence enables the wait_for path; we don't actually
  1981. # use any methods on it.
  1982. inst._mqtt = MagicMock()
  1983. file_path = tmp_path / "test.3mf"
  1984. file_path.write_bytes(b"fake3mf")
  1985. # 1. _add_to_print_queue runs WITHOUT a prior on_print_command —
  1986. # the wait_for times out (settings-default fallback) and the
  1987. # queue item is committed.
  1988. with (
  1989. patch(
  1990. "backend.app.api.routes.settings.get_setting",
  1991. new_callable=AsyncMock,
  1992. return_value=None,
  1993. ),
  1994. # Shorten the wait so the test isn't slow.
  1995. patch(
  1996. "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
  1997. 0.05,
  1998. ),
  1999. ):
  2000. await inst._add_to_print_queue(file_path, "192.168.1.100")
  2001. assert len(added_items) == 1
  2002. assert added_items[0].nozzle_mapping is None # MQTT was never received
  2003. assert file_path.name in inst._recent_queue_items
  2004. # 2. MQTT project_file arrives AFTER the wait expired — must
  2005. # retroactively patch the queue item.
  2006. await inst.on_print_command(
  2007. file_path.name,
  2008. {
  2009. "command": "project_file",
  2010. "file": file_path.name,
  2011. "nozzle_mapping": [16, -1, -1, 1],
  2012. "timelapse": True,
  2013. "bed_leveling": False,
  2014. },
  2015. )
  2016. # The UPDATE call is the third execute. Inspect its values.
  2017. update_call = mock_db.execute.await_args_list[2]
  2018. update_stmt = update_call.args[0]
  2019. compiled = update_stmt.compile(compile_kwargs={"literal_binds": False})
  2020. params = dict(compiled.params)
  2021. assert _json.loads(params["nozzle_mapping"]) == [16, -1, -1, 1]
  2022. assert params["timelapse"] is True
  2023. assert params["bed_levelling"] == "off" # MQTT bed_leveling → column bed_levelling (tri-state)
  2024. # Recent-queue tracking dict is cleared after the patch.
  2025. assert file_path.name not in inst._recent_queue_items
  2026. @pytest.mark.asyncio
  2027. async def test_on_print_command_late_mqtt_retroactively_stamps_archive_ams_mapping(self, tmp_path):
  2028. """Same #1780-round-3 race as the test above, but for `ams_mapping`
  2029. specifically: the archive was already created (with no
  2030. `slicer_ams_mapping`, since the slicer's pick hadn't arrived yet)
  2031. before this late MQTT lands. With the toggle on and a fixed target
  2032. printer, the archive must be retroactively patched too — otherwise a
  2033. reprint from this archive would never see the badge or the Mapping
  2034. button, even though the queue item itself dispatches correctly.
  2035. """
  2036. import json as _json
  2037. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2038. added_items: list = []
  2039. mock_db = AsyncMock()
  2040. mock_db.add = MagicMock(
  2041. side_effect=lambda item: (added_items.append(item), setattr(item, "id", 100 + len(added_items)))[0]
  2042. )
  2043. mock_db.flush = AsyncMock()
  2044. mock_db.commit = AsyncMock()
  2045. mock_archive_row = MagicMock()
  2046. mock_archive_row.id = 55
  2047. mock_archive_row.extra_data = None
  2048. position_max_result = MagicMock()
  2049. position_max_result.scalar = MagicMock(return_value=None)
  2050. select_pending_result = MagicMock()
  2051. # (queue_item_id, archive_id) — matches the id set by db.add above.
  2052. select_pending_result.all = MagicMock(return_value=[(101, 55, None)])
  2053. update_result = MagicMock()
  2054. select_archives_result = MagicMock()
  2055. select_archives_result.scalars = MagicMock(
  2056. return_value=MagicMock(all=MagicMock(return_value=[mock_archive_row]))
  2057. )
  2058. mock_db.execute = AsyncMock(
  2059. side_effect=[
  2060. position_max_result,
  2061. select_pending_result,
  2062. update_result,
  2063. update_result,
  2064. select_archives_result,
  2065. ]
  2066. )
  2067. mock_session_factory = MagicMock()
  2068. mock_session_ctx = AsyncMock()
  2069. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  2070. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  2071. mock_session_factory.return_value = mock_session_ctx
  2072. inst = VirtualPrinterInstance(
  2073. vp_id=98,
  2074. name="LateMQTTArchivePatch",
  2075. mode="queue",
  2076. model="X1C",
  2077. access_code="12345678",
  2078. serial_suffix="391800098",
  2079. base_dir=tmp_path,
  2080. session_factory=mock_session_factory,
  2081. save_ams_mapping=True,
  2082. target_printer_id=7,
  2083. )
  2084. inst._mqtt = MagicMock()
  2085. file_path = tmp_path / "test.3mf"
  2086. file_path.write_bytes(b"fake3mf")
  2087. with (
  2088. patch(
  2089. "backend.app.api.routes.settings.get_setting",
  2090. new_callable=AsyncMock,
  2091. return_value=None,
  2092. ),
  2093. patch(
  2094. "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
  2095. 0.05,
  2096. ),
  2097. ):
  2098. await inst._add_to_print_queue(file_path, "192.168.1.100")
  2099. assert len(added_items) == 1
  2100. assert added_items[0].ams_mapping is None # MQTT was never received in time
  2101. # MQTT project_file arrives late, carrying the slicer's AMS pick.
  2102. await inst.on_print_command(
  2103. file_path.name,
  2104. {
  2105. "command": "project_file",
  2106. "file": file_path.name,
  2107. "ams_mapping": [4, -1, 12, -1],
  2108. },
  2109. )
  2110. # 3rd execute() is the queue-item UPDATE, 4th keeps the pick on the
  2111. # item for its archive, 5th is the SELECT of archives already made.
  2112. update_call = mock_db.execute.await_args_list[2]
  2113. compiled = update_call.args[0].compile(compile_kwargs={"literal_binds": False})
  2114. assert _json.loads(dict(compiled.params)["ams_mapping"]) == [4, -1, 12, -1]
  2115. kept = mock_db.execute.await_args_list[3].args[0].compile(compile_kwargs={"literal_binds": False})
  2116. assert _json.loads(dict(kept.params)["slicer_ams_mapping"]) == {"mapping": [4, -1, 12, -1], "printer_id": 7}
  2117. assert mock_archive_row.extra_data == {
  2118. "slicer_ams_mapping": {"mapping": [4, -1, 12, -1], "printer_id": 7},
  2119. }
  2120. @pytest.mark.asyncio
  2121. async def test_on_print_command_late_mqtt_skips_archive_patch_for_model_based_vp(self, tmp_path):
  2122. """The archive patch above must not fire for a model-based VP
  2123. (`target_printer_id=None`) either — same rationale as the immediate
  2124. path: no fixed printer means no live AMS layout to have resolved the
  2125. late mapping against.
  2126. """
  2127. import json as _json
  2128. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2129. added_items: list = []
  2130. mock_db = AsyncMock()
  2131. mock_db.add = MagicMock(
  2132. side_effect=lambda item: (added_items.append(item), setattr(item, "id", 200 + len(added_items)))[0]
  2133. )
  2134. mock_db.flush = AsyncMock()
  2135. mock_db.commit = AsyncMock()
  2136. position_max_result = MagicMock()
  2137. position_max_result.scalar = MagicMock(return_value=None)
  2138. select_pending_result = MagicMock()
  2139. select_pending_result.all = MagicMock(return_value=[(201, 65, None)])
  2140. update_result = MagicMock()
  2141. # No 4th execute() expected — the archive patch must be skipped
  2142. # entirely, so only 3 calls should ever happen.
  2143. mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
  2144. mock_session_factory = MagicMock()
  2145. mock_session_ctx = AsyncMock()
  2146. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  2147. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  2148. mock_session_factory.return_value = mock_session_ctx
  2149. inst = VirtualPrinterInstance(
  2150. vp_id=97,
  2151. name="LateMQTTModelBased",
  2152. mode="queue",
  2153. model="X1C",
  2154. access_code="12345678",
  2155. serial_suffix="391800097",
  2156. base_dir=tmp_path,
  2157. session_factory=mock_session_factory,
  2158. save_ams_mapping=True,
  2159. target_printer_id=None,
  2160. )
  2161. inst._mqtt = MagicMock()
  2162. file_path = tmp_path / "test.3mf"
  2163. file_path.write_bytes(b"fake3mf")
  2164. with (
  2165. patch(
  2166. "backend.app.api.routes.settings.get_setting",
  2167. new_callable=AsyncMock,
  2168. return_value=None,
  2169. ),
  2170. patch(
  2171. "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
  2172. 0.05,
  2173. ),
  2174. ):
  2175. await inst._add_to_print_queue(file_path, "192.168.1.100")
  2176. await inst.on_print_command(
  2177. file_path.name,
  2178. {
  2179. "command": "project_file",
  2180. "file": file_path.name,
  2181. "ams_mapping": [4, -1, 12, -1],
  2182. # An unrelated field so `patch` isn't empty and the UPDATE
  2183. # actually runs — isolates the assertion to "ams_mapping was
  2184. # excluded" rather than "nothing happened at all".
  2185. "timelapse": True,
  2186. },
  2187. )
  2188. # 3rd execute() is the queue-item UPDATE — ams_mapping must be absent
  2189. # from it. No 4th execute() (the archive SELECT) should follow.
  2190. assert mock_db.execute.await_count == 3
  2191. update_call = mock_db.execute.await_args_list[2]
  2192. compiled = update_call.args[0].compile(compile_kwargs={"literal_binds": False})
  2193. assert "ams_mapping" not in dict(compiled.params)
  2194. assert dict(compiled.params)["timelapse"] is True
  2195. @pytest.mark.asyncio
  2196. async def test_on_print_command_late_mqtt_force_color_match_archives_but_does_not_stamp(self, tmp_path):
  2197. """The late path splits the same way the immediate one does when
  2198. `Force color match` is on: the archive still records the slicer's pick
  2199. for later reprints, but it must not reach the queue item, or the
  2200. overrides the user asked for would never be applied (#2700 review).
  2201. """
  2202. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2203. added_items: list = []
  2204. mock_db = AsyncMock()
  2205. mock_db.add = MagicMock(
  2206. side_effect=lambda item: (added_items.append(item), setattr(item, "id", 300 + len(added_items)))[0]
  2207. )
  2208. mock_db.flush = AsyncMock()
  2209. mock_db.commit = AsyncMock()
  2210. mock_archive_row = MagicMock()
  2211. mock_archive_row.id = 75
  2212. mock_archive_row.extra_data = None
  2213. position_max_result = MagicMock()
  2214. position_max_result.scalar = MagicMock(return_value=None)
  2215. select_pending_result = MagicMock()
  2216. select_pending_result.all = MagicMock(return_value=[(301, 75, None)])
  2217. update_result = MagicMock()
  2218. select_archives_result = MagicMock()
  2219. select_archives_result.scalars = MagicMock(
  2220. return_value=MagicMock(all=MagicMock(return_value=[mock_archive_row]))
  2221. )
  2222. mock_db.execute = AsyncMock(
  2223. side_effect=[
  2224. position_max_result,
  2225. select_pending_result,
  2226. update_result,
  2227. update_result,
  2228. select_archives_result,
  2229. ]
  2230. )
  2231. mock_session_factory = MagicMock()
  2232. mock_session_ctx = AsyncMock()
  2233. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  2234. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  2235. mock_session_factory.return_value = mock_session_ctx
  2236. inst = VirtualPrinterInstance(
  2237. vp_id=96,
  2238. name="LateMQTTForceColor",
  2239. mode="queue",
  2240. model="X1C",
  2241. access_code="12345678",
  2242. serial_suffix="391800096",
  2243. base_dir=tmp_path,
  2244. session_factory=mock_session_factory,
  2245. save_ams_mapping=True,
  2246. queue_force_color_match=True,
  2247. target_printer_id=7,
  2248. )
  2249. inst._mqtt = MagicMock()
  2250. file_path = tmp_path / "test.3mf"
  2251. file_path.write_bytes(b"fake3mf")
  2252. with (
  2253. patch(
  2254. "backend.app.api.routes.settings.get_setting",
  2255. new_callable=AsyncMock,
  2256. return_value=None,
  2257. ),
  2258. patch(
  2259. "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
  2260. 0.05,
  2261. ),
  2262. ):
  2263. await inst._add_to_print_queue(file_path, "192.168.1.100")
  2264. await inst.on_print_command(
  2265. file_path.name,
  2266. {
  2267. "command": "project_file",
  2268. "file": file_path.name,
  2269. "ams_mapping": [4, -1, 12, -1],
  2270. # Keeps the UPDATE non-empty so the assertion below is "the
  2271. # mapping was excluded", not "nothing ran".
  2272. "timelapse": True,
  2273. },
  2274. )
  2275. update_call = mock_db.execute.await_args_list[2]
  2276. compiled = update_call.args[0].compile(compile_kwargs={"literal_binds": False})
  2277. assert "ams_mapping" not in dict(compiled.params)
  2278. assert dict(compiled.params)["timelapse"] is True
  2279. # Kept for the archive all the same: Force color match only decides
  2280. # how this print is matched.
  2281. kept = mock_db.execute.await_args_list[3].args[0].compile(compile_kwargs={"literal_binds": False})
  2282. assert json.loads(dict(kept.params)["slicer_ams_mapping"]) == {"mapping": [4, -1, 12, -1], "printer_id": 7}
  2283. assert mock_archive_row.extra_data == {
  2284. "slicer_ams_mapping": {"mapping": [4, -1, 12, -1], "printer_id": 7},
  2285. }
  2286. @pytest.mark.asyncio
  2287. async def test_on_print_command_late_mqtt_ignores_ams_mapping_when_toggle_off(self, tmp_path):
  2288. """Toggle off: the late path must not stamp the queue item either, or
  2289. the opt-in would leak in through the #1780 race window.
  2290. """
  2291. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2292. added_items: list = []
  2293. mock_db = AsyncMock()
  2294. mock_db.add = MagicMock(
  2295. side_effect=lambda item: (added_items.append(item), setattr(item, "id", 400 + len(added_items)))[0]
  2296. )
  2297. mock_db.flush = AsyncMock()
  2298. mock_db.commit = AsyncMock()
  2299. position_max_result = MagicMock()
  2300. position_max_result.scalar = MagicMock(return_value=None)
  2301. select_pending_result = MagicMock()
  2302. select_pending_result.all = MagicMock(return_value=[(401, 85, None)])
  2303. update_result = MagicMock()
  2304. # No 4th execute(): with nothing to save there is no archive SELECT.
  2305. mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
  2306. mock_session_factory = MagicMock()
  2307. mock_session_ctx = AsyncMock()
  2308. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  2309. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  2310. mock_session_factory.return_value = mock_session_ctx
  2311. inst = VirtualPrinterInstance(
  2312. vp_id=95,
  2313. name="LateMQTTToggleOff",
  2314. mode="queue",
  2315. model="X1C",
  2316. access_code="12345678",
  2317. serial_suffix="391800095",
  2318. base_dir=tmp_path,
  2319. session_factory=mock_session_factory,
  2320. save_ams_mapping=False,
  2321. target_printer_id=7,
  2322. )
  2323. inst._mqtt = MagicMock()
  2324. file_path = tmp_path / "test.3mf"
  2325. file_path.write_bytes(b"fake3mf")
  2326. with (
  2327. patch(
  2328. "backend.app.api.routes.settings.get_setting",
  2329. new_callable=AsyncMock,
  2330. return_value=None,
  2331. ),
  2332. patch(
  2333. "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
  2334. 0.05,
  2335. ),
  2336. ):
  2337. await inst._add_to_print_queue(file_path, "192.168.1.100")
  2338. await inst.on_print_command(
  2339. file_path.name,
  2340. {
  2341. "command": "project_file",
  2342. "file": file_path.name,
  2343. "ams_mapping": [4, -1, 12, -1],
  2344. "timelapse": True,
  2345. },
  2346. )
  2347. assert mock_db.execute.await_count == 3
  2348. update_call = mock_db.execute.await_args_list[2]
  2349. compiled = update_call.args[0].compile(compile_kwargs={"literal_binds": False})
  2350. assert "ams_mapping" not in dict(compiled.params)
  2351. assert dict(compiled.params)["timelapse"] is True
  2352. @pytest.mark.asyncio
  2353. async def test_add_to_print_queue_catches_mqtt_stashed_post_wait_timeout(self, tmp_path):
  2354. """The actual race-window scenario: wait_for times out, then MQTT
  2355. arrives and stashes options AFTER the wait but BEFORE the
  2356. post-commit re-check. The post-commit pop must catch it.
  2357. """
  2358. import json as _json
  2359. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2360. added_items: list = []
  2361. mock_db = AsyncMock()
  2362. mock_db.add = MagicMock(
  2363. side_effect=lambda item: (added_items.append(item), setattr(item, "id", 300 + len(added_items)))[0]
  2364. )
  2365. mock_db.flush = AsyncMock()
  2366. mock_db.commit = AsyncMock()
  2367. position_max_result = MagicMock()
  2368. position_max_result.scalar = MagicMock(return_value=None)
  2369. select_pending_result = MagicMock()
  2370. select_pending_result.all = MagicMock(return_value=[(301, None, None)])
  2371. update_result = MagicMock()
  2372. mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
  2373. mock_session_factory = MagicMock()
  2374. mock_session_ctx = AsyncMock()
  2375. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  2376. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  2377. mock_session_factory.return_value = mock_session_ctx
  2378. inst = VirtualPrinterInstance(
  2379. # The slicer's nozzle positions are kept only for a VP bound to one printer.
  2380. target_printer_id=7,
  2381. vp_id=96,
  2382. name="RaceCommitYield",
  2383. mode="queue",
  2384. model="O1C2",
  2385. access_code="12345678",
  2386. serial_suffix="391800096",
  2387. base_dir=tmp_path,
  2388. session_factory=mock_session_factory,
  2389. )
  2390. inst._mqtt = MagicMock()
  2391. file_path = tmp_path / "test.3mf"
  2392. file_path.write_bytes(b"fake3mf")
  2393. # Stash MQTT data on the FIRST commit (simulating MQTT arrival
  2394. # during _add_to_print_queue's commit yield); _restamp also calls
  2395. # db.commit later, so we one-shot the side effect.
  2396. commit_calls = {"n": 0}
  2397. async def _delayed_stash(*_args, **_kwargs):
  2398. commit_calls["n"] += 1
  2399. if commit_calls["n"] == 1:
  2400. inst._slicer_print_options[file_path.name] = {
  2401. "command": "project_file",
  2402. "file": file_path.name,
  2403. "nozzle_mapping": [0, 16, -1, -1],
  2404. "timelapse": False,
  2405. }
  2406. return None
  2407. mock_db.commit = AsyncMock(side_effect=_delayed_stash)
  2408. with (
  2409. patch(
  2410. "backend.app.api.routes.settings.get_setting",
  2411. new_callable=AsyncMock,
  2412. return_value=None,
  2413. ),
  2414. patch(
  2415. "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
  2416. 0.05,
  2417. ),
  2418. ):
  2419. await inst._add_to_print_queue(file_path, "192.168.1.100")
  2420. # Queue item INSERTed with defaults (wait timed out, no slicer_opts).
  2421. assert len(added_items) == 1
  2422. # But the post-commit pop caught the late stash and applied the
  2423. # slicer nozzle_mapping via _restamp's UPDATE.
  2424. update_call = mock_db.execute.await_args_list[2]
  2425. update_stmt = update_call.args[0]
  2426. compiled = update_stmt.compile(compile_kwargs={"literal_binds": False})
  2427. params = dict(compiled.params)
  2428. assert _json.loads(params["nozzle_mapping"]) == [0, 16, -1, -1]
  2429. assert params["timelapse"] is False
  2430. # _recent_queue_items entry was consumed by the post-commit
  2431. # _restamp call.
  2432. assert file_path.name not in inst._recent_queue_items
  2433. # And the stash is empty.
  2434. assert file_path.name not in inst._slicer_print_options
  2435. @pytest.mark.asyncio
  2436. async def test_on_print_command_late_mqtt_skips_already_dispatched_item(self, tmp_path):
  2437. """Once the scheduler has picked the queue item up (status != pending),
  2438. the retroactive patch is a no-op — racing the dispatcher would be
  2439. unsafe.
  2440. """
  2441. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2442. mock_db = AsyncMock()
  2443. # The eligible-pending SELECT returns nothing — item is no longer pending.
  2444. empty_result = MagicMock()
  2445. empty_result.all = MagicMock(return_value=[])
  2446. mock_db.execute = AsyncMock(return_value=empty_result)
  2447. mock_db.commit = AsyncMock()
  2448. mock_session_factory = MagicMock()
  2449. mock_session_ctx = AsyncMock()
  2450. mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
  2451. mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
  2452. mock_session_factory.return_value = mock_session_ctx
  2453. inst = VirtualPrinterInstance(
  2454. # The slicer's nozzle positions are kept only for a VP bound to one printer.
  2455. target_printer_id=7,
  2456. vp_id=98,
  2457. name="LateMQTTDispatched",
  2458. mode="queue",
  2459. model="O1C2",
  2460. access_code="12345678",
  2461. serial_suffix="391800098",
  2462. base_dir=tmp_path,
  2463. session_factory=mock_session_factory,
  2464. )
  2465. inst._mqtt = MagicMock()
  2466. # Pre-seed the recent-queue dict — pretend _add_to_print_queue just
  2467. # committed item id 42.
  2468. inst._recent_queue_items["test.3mf"] = ([42], 1_000_000.0)
  2469. # Drive _restamp via on_print_command on the late-MQTT path.
  2470. with patch("backend.app.services.virtual_printer.manager.time.monotonic", return_value=1_000_001.0):
  2471. await inst.on_print_command(
  2472. "test.3mf",
  2473. {
  2474. "command": "project_file",
  2475. "file": "test.3mf",
  2476. "nozzle_mapping": [16, -1],
  2477. },
  2478. )
  2479. # No UPDATE was issued — only the eligibility SELECT ran.
  2480. assert mock_db.execute.await_count == 1
  2481. mock_db.commit.assert_not_awaited()
  2482. assert "test.3mf" not in inst._recent_queue_items
  2483. class TestExtractSlicerAmsMappingJson:
  2484. """Unit tests for `_extract_slicer_ams_mapping_json`, the pure-function
  2485. parser that pulls the slicer's own live-resolved AMS-slot pick out of a
  2486. captured project_file MQTT payload (see docstring in manager.py)."""
  2487. def _extract(self, data):
  2488. from backend.app.services.virtual_printer.manager import _extract_slicer_ams_mapping_json
  2489. return _extract_slicer_ams_mapping_json(data, "[test]")
  2490. def test_missing_field_returns_none(self):
  2491. assert self._extract({}) is None
  2492. def test_valid_int_list_returns_json(self):
  2493. import json as _json
  2494. result = self._extract({"ams_mapping": [4, -1, 12, -1]})
  2495. assert result is not None
  2496. assert _json.loads(result) == [4, -1, 12, -1]
  2497. def test_stringified_json_is_parsed(self):
  2498. import json as _json
  2499. result = self._extract({"ams_mapping": "[0, 1, 2]"})
  2500. assert result is not None
  2501. assert _json.loads(result) == [0, 1, 2]
  2502. def test_unparseable_string_returns_none(self):
  2503. assert self._extract({"ams_mapping": "not json"}) is None
  2504. def test_non_list_value_returns_none(self):
  2505. assert self._extract({"ams_mapping": 42}) is None
  2506. def test_empty_list_returns_none(self):
  2507. assert self._extract({"ams_mapping": []}) is None
  2508. def test_non_int_entries_return_none(self):
  2509. assert self._extract({"ams_mapping": [1, "two", 3]}) is None
  2510. def test_2589_all_unresolved_sentinel_returns_none(self):
  2511. """#2589: every slot -1 means the slicer's own resolution race
  2512. lost — never trust this over a fresh live computation."""
  2513. assert self._extract({"ams_mapping": [-1, -1, -1, -1]}) is None
  2514. def test_partially_resolved_mapping_is_kept(self):
  2515. import json as _json
  2516. # Only some slots resolved is still meaningful — keep it.
  2517. result = self._extract({"ams_mapping": [-1, 4, -1, -1]})
  2518. assert result is not None
  2519. assert _json.loads(result) == [-1, 4, -1, -1]
  2520. class TestVirtualPrinterManager:
  2521. """Tests for VirtualPrinterManager orchestrator."""
  2522. @pytest.fixture
  2523. def manager(self):
  2524. """Create a VirtualPrinterManager instance."""
  2525. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  2526. return VirtualPrinterManager()
  2527. def test_manager_starts_empty(self, manager):
  2528. """Verify manager starts with no instances."""
  2529. assert len(manager._instances) == 0
  2530. assert manager.is_enabled is False
  2531. def test_manager_get_status_empty(self, manager):
  2532. """Verify get_status returns disabled state when no instances."""
  2533. status = manager.get_status()
  2534. assert status["enabled"] is False
  2535. assert status["running"] is False
  2536. assert status["mode"] == "library"
  2537. def test_manager_is_enabled_with_instance(self, manager, tmp_path):
  2538. """Verify is_enabled is True when instances exist."""
  2539. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2540. inst = VirtualPrinterInstance(
  2541. vp_id=1,
  2542. name="Test",
  2543. mode="library",
  2544. model="C11",
  2545. access_code="12345678",
  2546. serial_suffix="391800001",
  2547. base_dir=tmp_path,
  2548. )
  2549. manager._instances[1] = inst
  2550. assert manager.is_enabled is True
  2551. @pytest.mark.asyncio
  2552. async def test_manager_remove_instance_server(self, manager, tmp_path):
  2553. """Verify remove_instance stops and removes a server-mode instance."""
  2554. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2555. inst = VirtualPrinterInstance(
  2556. vp_id=1,
  2557. name="Test",
  2558. mode="library",
  2559. model="C11",
  2560. access_code="12345678",
  2561. serial_suffix="391800001",
  2562. base_dir=tmp_path,
  2563. )
  2564. inst.stop_server = AsyncMock()
  2565. manager._instances[1] = inst
  2566. await manager.remove_instance(1)
  2567. assert 1 not in manager._instances
  2568. inst.stop_server.assert_called_once()
  2569. @pytest.mark.asyncio
  2570. async def test_manager_remove_instance_proxy(self, manager, tmp_path):
  2571. """Verify remove_instance stops proxy-mode instance."""
  2572. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2573. inst = VirtualPrinterInstance(
  2574. vp_id=2,
  2575. name="Proxy",
  2576. mode="proxy",
  2577. model="C11",
  2578. access_code="",
  2579. serial_suffix="391800002",
  2580. target_printer_ip="192.168.1.100",
  2581. base_dir=tmp_path,
  2582. )
  2583. inst.stop_proxy = AsyncMock()
  2584. manager._instances[2] = inst
  2585. await manager.remove_instance(2)
  2586. assert 2 not in manager._instances
  2587. inst.stop_proxy.assert_called_once()
  2588. def test_manager_get_status_with_instance(self, manager, tmp_path):
  2589. """Verify legacy get_status returns first instance data."""
  2590. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2591. inst = VirtualPrinterInstance(
  2592. vp_id=1,
  2593. name="Bambuddy",
  2594. mode="library",
  2595. model="C11",
  2596. access_code="12345678",
  2597. serial_suffix="391800001",
  2598. base_dir=tmp_path,
  2599. )
  2600. mock_task = MagicMock(done=MagicMock(return_value=False))
  2601. inst._tasks = [mock_task]
  2602. inst._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")} # nosec B108
  2603. manager._instances[1] = inst
  2604. status = manager.get_status()
  2605. assert status["enabled"] is True
  2606. assert status["running"] is True
  2607. assert status["mode"] == "library"
  2608. assert status["name"] == "Bambuddy"
  2609. assert status["serial"] == "01S00A391800001"
  2610. assert status["model"] == "C11"
  2611. assert status["model_name"] == "P1P"
  2612. assert status["pending_files"] == 1
  2613. @pytest.mark.asyncio
  2614. async def test_manager_stop_all(self, manager, tmp_path):
  2615. """Verify stop_all removes all instances."""
  2616. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2617. for i in range(1, 3):
  2618. inst = VirtualPrinterInstance(
  2619. vp_id=i,
  2620. name=f"VP{i}",
  2621. mode="library",
  2622. model="C11",
  2623. access_code="12345678",
  2624. serial_suffix=f"39180000{i}",
  2625. base_dir=tmp_path,
  2626. )
  2627. inst.stop_server = AsyncMock()
  2628. manager._instances[i] = inst
  2629. await manager.stop_all()
  2630. assert len(manager._instances) == 0
  2631. # ========================================================================
  2632. # Tests for sync_from_db config change detection
  2633. # ========================================================================
  2634. def _make_db_vp(self, **overrides):
  2635. """Create a mock VirtualPrinter DB object."""
  2636. defaults = {
  2637. "id": 1,
  2638. "name": "TestVP",
  2639. "enabled": True,
  2640. "mode": "library",
  2641. "model": "C11",
  2642. "access_code": "12345678",
  2643. "serial_suffix": "391800001",
  2644. "bind_ip": "",
  2645. "remote_interface_ip": "",
  2646. "target_printer_id": None,
  2647. "auto_dispatch": True,
  2648. "tailscale_disabled": True, # Opt-in default (#1070 UX fix)
  2649. "queue_force_color_match": False, # default — must be explicit so MagicMock truthiness doesn't trip the change detector
  2650. "save_ams_mapping": False, # same reason as above
  2651. "gcode_injection": False, # same reason as above
  2652. "position": 0,
  2653. }
  2654. defaults.update(overrides)
  2655. vp = MagicMock()
  2656. for k, v in defaults.items():
  2657. setattr(vp, k, v)
  2658. return vp
  2659. def _setup_sync_mocks(self, manager, enabled_vps, tmp_path):
  2660. """Wire up session_factory mock for sync_from_db."""
  2661. mock_result = MagicMock()
  2662. mock_result.scalars.return_value.all.return_value = enabled_vps
  2663. mock_db = AsyncMock()
  2664. mock_db.execute = AsyncMock(return_value=mock_result)
  2665. mock_db.__aenter__ = AsyncMock(return_value=mock_db)
  2666. mock_db.__aexit__ = AsyncMock(return_value=False)
  2667. manager._session_factory = MagicMock(return_value=mock_db)
  2668. manager._base_dir = tmp_path
  2669. @pytest.mark.asyncio
  2670. async def test_sync_from_db_restarts_on_mode_change(self, manager, tmp_path):
  2671. """Verify sync_from_db restarts VP when mode changes."""
  2672. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2673. inst = VirtualPrinterInstance(
  2674. vp_id=1,
  2675. name="TestVP",
  2676. mode="library",
  2677. model="C11",
  2678. access_code="12345678",
  2679. serial_suffix="391800001",
  2680. base_dir=tmp_path,
  2681. )
  2682. inst.stop_server = AsyncMock()
  2683. manager._instances[1] = inst
  2684. # DB says mode changed to "review"
  2685. db_vp = self._make_db_vp(mode="review")
  2686. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  2687. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  2688. # Patch VirtualPrinterInstance to prevent actual start
  2689. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  2690. mock_new = MagicMock()
  2691. mock_new.start_server = AsyncMock()
  2692. MockInst.return_value = mock_new
  2693. await manager.sync_from_db()
  2694. mock_remove.assert_called_once_with(1)
  2695. @pytest.mark.asyncio
  2696. async def test_sync_from_db_restarts_on_access_code_change(self, manager, tmp_path):
  2697. """Verify sync_from_db restarts VP when access_code changes."""
  2698. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2699. inst = VirtualPrinterInstance(
  2700. vp_id=1,
  2701. name="TestVP",
  2702. mode="library",
  2703. model="C11",
  2704. access_code="12345678",
  2705. serial_suffix="391800001",
  2706. base_dir=tmp_path,
  2707. )
  2708. inst.stop_server = AsyncMock()
  2709. manager._instances[1] = inst
  2710. db_vp = self._make_db_vp(access_code="newcode99")
  2711. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  2712. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  2713. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  2714. mock_new = MagicMock()
  2715. mock_new.start_server = AsyncMock()
  2716. MockInst.return_value = mock_new
  2717. await manager.sync_from_db()
  2718. mock_remove.assert_called_once_with(1)
  2719. @pytest.mark.asyncio
  2720. async def test_sync_from_db_skips_unchanged_instance(self, manager, tmp_path):
  2721. """Verify sync_from_db does NOT restart when config is identical."""
  2722. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2723. inst = VirtualPrinterInstance(
  2724. vp_id=1,
  2725. name="TestVP",
  2726. mode="library",
  2727. model="C11",
  2728. access_code="12345678",
  2729. serial_suffix="391800001",
  2730. base_dir=tmp_path,
  2731. )
  2732. manager._instances[1] = inst
  2733. # DB matches running config exactly
  2734. db_vp = self._make_db_vp()
  2735. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  2736. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  2737. await manager.sync_from_db()
  2738. mock_remove.assert_not_called()
  2739. @pytest.mark.asyncio
  2740. async def test_sync_from_db_restarts_on_bind_ip_change(self, manager, tmp_path):
  2741. """Verify sync_from_db restarts VP when bind_ip changes."""
  2742. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2743. inst = VirtualPrinterInstance(
  2744. vp_id=1,
  2745. name="TestVP",
  2746. mode="library",
  2747. model="C11",
  2748. access_code="12345678",
  2749. serial_suffix="391800001",
  2750. bind_ip="192.168.1.10",
  2751. base_dir=tmp_path,
  2752. )
  2753. inst.stop_server = AsyncMock()
  2754. manager._instances[1] = inst
  2755. db_vp = self._make_db_vp(bind_ip="192.168.1.20")
  2756. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  2757. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  2758. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  2759. mock_new = MagicMock()
  2760. mock_new.start_server = AsyncMock()
  2761. MockInst.return_value = mock_new
  2762. await manager.sync_from_db()
  2763. mock_remove.assert_called_once_with(1)
  2764. @pytest.mark.asyncio
  2765. async def test_sync_from_db_restarts_on_model_change(self, manager, tmp_path):
  2766. """Verify sync_from_db restarts VP when model changes."""
  2767. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2768. inst = VirtualPrinterInstance(
  2769. vp_id=1,
  2770. name="TestVP",
  2771. mode="library",
  2772. model="C11",
  2773. access_code="12345678",
  2774. serial_suffix="391800001",
  2775. base_dir=tmp_path,
  2776. )
  2777. inst.stop_server = AsyncMock()
  2778. manager._instances[1] = inst
  2779. db_vp = self._make_db_vp(model="C12")
  2780. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  2781. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  2782. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  2783. mock_new = MagicMock()
  2784. mock_new.start_server = AsyncMock()
  2785. MockInst.return_value = mock_new
  2786. await manager.sync_from_db()
  2787. mock_remove.assert_called_once_with(1)
  2788. @pytest.mark.asyncio
  2789. async def test_sync_from_db_does_not_restart_on_tailscale_toggle(self, manager, tmp_path):
  2790. """Flipping tailscale_disabled is purely informational — must NOT trigger a restart.
  2791. Cert provisioning was removed; the toggle only governs whether the VP card surfaces
  2792. the host's Tailscale IP/FQDN to the user. No service needs to reload, so changing
  2793. it through sync_from_db should leave any running instance untouched.
  2794. """
  2795. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2796. inst = VirtualPrinterInstance(
  2797. vp_id=1,
  2798. name="TestVP",
  2799. mode="library",
  2800. model="C11",
  2801. access_code="12345678",
  2802. serial_suffix="391800001",
  2803. base_dir=tmp_path,
  2804. )
  2805. inst.stop_server = AsyncMock()
  2806. manager._instances[1] = inst
  2807. db_vp = self._make_db_vp(tailscale_disabled=True)
  2808. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  2809. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  2810. await manager.sync_from_db()
  2811. mock_remove.assert_not_called()
  2812. @pytest.mark.asyncio
  2813. async def test_sync_from_db_restarts_on_gcode_injection_toggle(self, manager, tmp_path):
  2814. """Toggling gcode_injection in the DB must restart the running instance.
  2815. Without this, the in-memory ``self.gcode_injection`` keeps its old value
  2816. and ``_add_to_print_queue`` stamps the stale flag on every new queue
  2817. item — so disabling injection in the UI silently has no effect until
  2818. the process restarts.
  2819. """
  2820. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2821. inst = VirtualPrinterInstance(
  2822. vp_id=1,
  2823. name="TestVP",
  2824. mode="library",
  2825. model="C11",
  2826. access_code="12345678",
  2827. serial_suffix="391800001",
  2828. gcode_injection=True,
  2829. base_dir=tmp_path,
  2830. )
  2831. inst.stop_server = AsyncMock()
  2832. manager._instances[1] = inst
  2833. db_vp = self._make_db_vp(gcode_injection=False)
  2834. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  2835. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  2836. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  2837. mock_new = MagicMock()
  2838. mock_new.start_server = AsyncMock()
  2839. MockInst.return_value = mock_new
  2840. await manager.sync_from_db()
  2841. mock_remove.assert_called_once_with(1)
  2842. def _setup_proxy_sync_mocks(
  2843. self, manager, enabled_vps, tmp_path, printer_ip="192.168.1.50", serial="0948AB012345678"
  2844. ):
  2845. """Like _setup_sync_mocks, but the target printer lookup returns a real IP and serial."""
  2846. vps_result = MagicMock()
  2847. vps_result.scalars.return_value.all.return_value = enabled_vps
  2848. printer_result = MagicMock()
  2849. printer_result.scalar_one_or_none.return_value = SimpleNamespace(ip_address=printer_ip, serial_number=serial)
  2850. async def execute(stmt):
  2851. entity = stmt.column_descriptions[0]["entity"]
  2852. return printer_result if entity.__name__ == "Printer" else vps_result
  2853. mock_db = AsyncMock()
  2854. mock_db.execute = AsyncMock(side_effect=execute)
  2855. mock_db.__aenter__ = AsyncMock(return_value=mock_db)
  2856. mock_db.__aexit__ = AsyncMock(return_value=False)
  2857. manager._session_factory = MagicMock(return_value=mock_db)
  2858. manager._base_dir = tmp_path
  2859. @pytest.mark.asyncio
  2860. async def test_sync_from_db_leaves_a_running_proxy_vp_alone(self, manager, tmp_path):
  2861. """A proxy VP that sync started must survive the next sync unchanged.
  2862. The proxy branch built its instance without the row's target printer
  2863. id, so every later sync saw a change and restarted every proxy VP,
  2864. dropping the slicer's connection, whenever any VP or printer was saved.
  2865. The queue-mode toggles are set away from their defaults, as on a VP
  2866. switched from queue mode to proxy, which keeps them in its row.
  2867. """
  2868. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2869. db_vp = self._make_db_vp(
  2870. mode="proxy",
  2871. model="O1D",
  2872. target_printer_id=7,
  2873. bind_ip="10.0.0.5",
  2874. auto_dispatch=False,
  2875. queue_force_color_match=True,
  2876. save_ams_mapping=True,
  2877. gcode_injection=True,
  2878. )
  2879. self._setup_proxy_sync_mocks(manager, [db_vp], tmp_path)
  2880. with (
  2881. patch.object(VirtualPrinterInstance, "start_proxy", new_callable=AsyncMock) as mock_start,
  2882. patch.object(VirtualPrinterInstance, "stop_proxy", new_callable=AsyncMock) as mock_stop,
  2883. ):
  2884. await manager.sync_from_db()
  2885. assert mock_start.await_count == 1
  2886. assert manager._instances[1].target_printer_id == 7
  2887. await manager.sync_from_db()
  2888. assert mock_start.await_count == 1
  2889. mock_stop.assert_not_awaited()
  2890. @pytest.mark.asyncio
  2891. async def test_sync_from_db_restarts_a_proxy_vp_on_target_ip_change(self, manager, tmp_path):
  2892. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2893. db_vp = self._make_db_vp(mode="proxy", model="O1D", target_printer_id=7, bind_ip="10.0.0.5")
  2894. self._setup_proxy_sync_mocks(manager, [db_vp], tmp_path)
  2895. with (
  2896. patch.object(VirtualPrinterInstance, "start_proxy", new_callable=AsyncMock) as mock_start,
  2897. patch.object(VirtualPrinterInstance, "stop_proxy", new_callable=AsyncMock) as mock_stop,
  2898. ):
  2899. await manager.sync_from_db()
  2900. self._setup_proxy_sync_mocks(manager, [db_vp], tmp_path, printer_ip="192.168.1.77")
  2901. await manager.sync_from_db()
  2902. mock_stop.assert_awaited_once()
  2903. assert mock_start.await_count == 2
  2904. assert manager._instances[1].target_printer_ip == "192.168.1.77"
  2905. @pytest.mark.asyncio
  2906. async def test_sync_from_db_restarts_a_proxy_vp_on_rename(self, manager, tmp_path):
  2907. """The name is advertised to the slicer (SSDP, bind), so a rename only
  2908. reaches the slicer through a restart."""
  2909. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2910. db_vp = self._make_db_vp(mode="proxy", model="O1D", target_printer_id=7, bind_ip="10.0.0.5")
  2911. self._setup_proxy_sync_mocks(manager, [db_vp], tmp_path)
  2912. with (
  2913. patch.object(VirtualPrinterInstance, "start_proxy", new_callable=AsyncMock) as mock_start,
  2914. patch.object(VirtualPrinterInstance, "stop_proxy", new_callable=AsyncMock) as mock_stop,
  2915. ):
  2916. await manager.sync_from_db()
  2917. db_vp.name = "Workshop H2D"
  2918. await manager.sync_from_db()
  2919. mock_stop.assert_awaited_once()
  2920. assert mock_start.await_count == 2
  2921. assert manager._instances[1].name == "Workshop H2D"
  2922. @pytest.mark.asyncio
  2923. async def test_sync_from_db_restarts_on_rename(self, manager, tmp_path):
  2924. """Same for a server-mode VP: the running SSDP and bind servers keep
  2925. advertising the old name until the instance restarts."""
  2926. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  2927. inst = VirtualPrinterInstance(
  2928. vp_id=1,
  2929. name="TestVP",
  2930. mode="library",
  2931. model="C11",
  2932. access_code="12345678",
  2933. serial_suffix="391800001",
  2934. base_dir=tmp_path,
  2935. )
  2936. manager._instances[1] = inst
  2937. db_vp = self._make_db_vp(name="Renamed VP")
  2938. self._setup_sync_mocks(manager, [db_vp], tmp_path)
  2939. with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
  2940. with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
  2941. mock_new = MagicMock()
  2942. mock_new.start_server = AsyncMock()
  2943. MockInst.return_value = mock_new
  2944. await manager.sync_from_db()
  2945. mock_remove.assert_called_once_with(1)
  2946. class TestFTPSession:
  2947. """Tests for FTP session handling."""
  2948. @pytest.fixture
  2949. def mock_reader(self):
  2950. """Create a mock StreamReader."""
  2951. reader = AsyncMock()
  2952. return reader
  2953. @pytest.fixture
  2954. def mock_writer(self):
  2955. """Create a mock StreamWriter."""
  2956. writer = MagicMock()
  2957. writer.get_extra_info = MagicMock(return_value=("192.168.1.100", 12345))
  2958. writer.write = MagicMock()
  2959. writer.drain = AsyncMock()
  2960. writer.close = MagicMock()
  2961. writer.wait_closed = AsyncMock()
  2962. writer.is_closing = MagicMock(return_value=False)
  2963. return writer
  2964. @pytest.fixture
  2965. def ssl_context(self):
  2966. """Create a mock SSL context."""
  2967. return MagicMock()
  2968. @pytest.fixture
  2969. def session(self, mock_reader, mock_writer, ssl_context, tmp_path):
  2970. """Create an FTPSession instance."""
  2971. from backend.app.services.virtual_printer.ftp_server import FTPSession
  2972. return FTPSession(
  2973. reader=mock_reader,
  2974. writer=mock_writer,
  2975. upload_dir=tmp_path,
  2976. access_code="12345678",
  2977. ssl_context=ssl_context,
  2978. on_file_received=None,
  2979. )
  2980. # ========================================================================
  2981. # Tests for authentication
  2982. # ========================================================================
  2983. @pytest.mark.asyncio
  2984. async def test_user_command_accepts_bblp(self, session):
  2985. """Verify USER command accepts bblp user."""
  2986. await session.cmd_USER("bblp")
  2987. assert session.username == "bblp"
  2988. @pytest.mark.asyncio
  2989. async def test_pass_command_authenticates(self, session):
  2990. """Verify PASS command authenticates with correct code."""
  2991. session.username = "bblp"
  2992. await session.cmd_PASS("12345678")
  2993. assert session.authenticated is True
  2994. @pytest.mark.asyncio
  2995. async def test_pass_command_rejects_wrong_code(self, session):
  2996. """Verify PASS command rejects wrong access code."""
  2997. session.username = "bblp"
  2998. await session.cmd_PASS("wrongcode")
  2999. assert session.authenticated is False
  3000. # ========================================================================
  3001. # Tests for FTP commands
  3002. # ========================================================================
  3003. @pytest.mark.asyncio
  3004. async def test_syst_command(self, session):
  3005. """Verify SYST returns UNIX type."""
  3006. await session.cmd_SYST("")
  3007. session.writer.write.assert_called()
  3008. call_args = session.writer.write.call_args[0][0].decode()
  3009. assert "215" in call_args
  3010. assert "UNIX" in call_args
  3011. @pytest.mark.asyncio
  3012. async def test_pwd_command_requires_auth(self, session):
  3013. """Verify PWD requires authentication."""
  3014. session.authenticated = False
  3015. await session.cmd_PWD("")
  3016. call_args = session.writer.write.call_args[0][0].decode()
  3017. assert "530" in call_args
  3018. @pytest.mark.asyncio
  3019. async def test_pwd_command_when_authenticated(self, session):
  3020. """Verify PWD returns root directory when authenticated."""
  3021. session.authenticated = True
  3022. await session.cmd_PWD("")
  3023. call_args = session.writer.write.call_args[0][0].decode()
  3024. assert "257" in call_args
  3025. @pytest.mark.asyncio
  3026. async def test_type_command_sets_binary(self, session):
  3027. """Verify TYPE I sets binary mode."""
  3028. session.authenticated = True
  3029. await session.cmd_TYPE("I")
  3030. assert session.transfer_type == "I"
  3031. @pytest.mark.asyncio
  3032. async def test_pbsz_command(self, session):
  3033. """Verify PBSZ returns success."""
  3034. await session.cmd_PBSZ("0")
  3035. call_args = session.writer.write.call_args[0][0].decode()
  3036. assert "200" in call_args
  3037. @pytest.mark.asyncio
  3038. async def test_prot_command_accepts_p(self, session):
  3039. """Verify PROT P is accepted."""
  3040. await session.cmd_PROT("P")
  3041. call_args = session.writer.write.call_args[0][0].decode()
  3042. assert "200" in call_args
  3043. @pytest.mark.asyncio
  3044. async def test_quit_command(self, session):
  3045. """Verify QUIT sends goodbye and raises CancelledError."""
  3046. with pytest.raises(asyncio.CancelledError):
  3047. await session.cmd_QUIT("")
  3048. class TestSSDPServer:
  3049. """Tests for Virtual Printer SSDP server."""
  3050. @pytest.fixture
  3051. def ssdp_server(self):
  3052. """Create a VirtualPrinterSSDPServer instance."""
  3053. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  3054. return VirtualPrinterSSDPServer(
  3055. serial="TEST123",
  3056. name="TestPrinter",
  3057. model="BL-P001",
  3058. )
  3059. # ========================================================================
  3060. # Tests for SSDP response
  3061. # ========================================================================
  3062. def test_build_notify_message(self, ssdp_server):
  3063. """Verify NOTIFY packet contains required headers."""
  3064. # Set a known IP for testing
  3065. ssdp_server._local_ip = "192.168.1.100"
  3066. message = ssdp_server._build_notify_message()
  3067. assert b"NOTIFY" in message
  3068. assert b"DevName.bambu.com: TestPrinter" in message
  3069. assert b"USN: TEST123" in message
  3070. def test_build_response_message(self, ssdp_server):
  3071. """Verify response packet contains required headers."""
  3072. # Set a known IP for testing
  3073. ssdp_server._local_ip = "192.168.1.100"
  3074. message = ssdp_server._build_response_message()
  3075. assert b"HTTP/1.1 200 OK" in message
  3076. assert b"DevName.bambu.com: TestPrinter" in message
  3077. assert b"USN: TEST123" in message
  3078. def test_ssdp_server_uses_correct_model(self, ssdp_server):
  3079. """Verify SSDP server uses the provided model."""
  3080. ssdp_server._local_ip = "192.168.1.100"
  3081. message = ssdp_server._build_notify_message()
  3082. assert b"DevModel.bambu.com: BL-P001" in message
  3083. # ========================================================================
  3084. # Tests for advertise_ip parameter
  3085. # ========================================================================
  3086. def test_advertise_ip_sets_local_ip(self):
  3087. """Verify advertise_ip overrides auto-detection."""
  3088. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  3089. server = VirtualPrinterSSDPServer(
  3090. serial="TEST123",
  3091. name="TestPrinter",
  3092. model="BL-P001",
  3093. advertise_ip="10.0.0.50",
  3094. )
  3095. assert server._local_ip == "10.0.0.50"
  3096. def test_advertise_ip_empty_string_uses_auto_detect(self):
  3097. """Verify empty advertise_ip falls back to auto-detection."""
  3098. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  3099. server = VirtualPrinterSSDPServer(
  3100. serial="TEST123",
  3101. name="TestPrinter",
  3102. model="BL-P001",
  3103. advertise_ip="",
  3104. )
  3105. assert server._local_ip is None
  3106. def test_advertise_ip_in_notify_message(self):
  3107. """Verify NOTIFY message uses the advertise_ip."""
  3108. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  3109. server = VirtualPrinterSSDPServer(
  3110. serial="TEST123",
  3111. name="TestPrinter",
  3112. model="BL-P001",
  3113. advertise_ip="10.0.0.50",
  3114. )
  3115. message = server._build_notify_message()
  3116. assert b"Location: 10.0.0.50" in message
  3117. def test_advertise_ip_in_response_message(self):
  3118. """Verify M-SEARCH response uses the advertise_ip."""
  3119. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  3120. server = VirtualPrinterSSDPServer(
  3121. serial="TEST123",
  3122. name="TestPrinter",
  3123. model="BL-P001",
  3124. advertise_ip="10.0.0.50",
  3125. )
  3126. message = server._build_response_message()
  3127. assert b"Location: 10.0.0.50" in message
  3128. def test_default_no_advertise_ip(self):
  3129. """Verify default constructor has None local_ip (auto-detect)."""
  3130. from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
  3131. server = VirtualPrinterSSDPServer()
  3132. assert server._local_ip is None
  3133. class TestCertificateService:
  3134. """Tests for TLS certificate generation."""
  3135. @pytest.fixture
  3136. def cert_service(self, tmp_path):
  3137. """Create a CertificateService instance."""
  3138. from backend.app.services.virtual_printer.certificate import CertificateService
  3139. return CertificateService(cert_dir=tmp_path, serial="TEST123")
  3140. def test_generate_certificates(self, cert_service, tmp_path):
  3141. """Verify certificates are generated correctly."""
  3142. cert_path, key_path = cert_service.generate_certificates()
  3143. assert cert_path.exists()
  3144. assert key_path.exists()
  3145. # Verify certificate content
  3146. cert_content = cert_path.read_text()
  3147. assert "BEGIN CERTIFICATE" in cert_content
  3148. key_content = key_path.read_text()
  3149. assert "BEGIN" in key_content and "KEY" in key_content
  3150. # --- CA identity (#3014) ------------------------------------------------
  3151. #
  3152. # A slicer trust store is a flat list of certificates, and OpenSSL resolves
  3153. # an issuer by Subject DN: the first CA whose DN matches is the only one
  3154. # tried. While every Bambuddy install signed as plain "Virtual Printer CA",
  3155. # importing the CAs of two instances broke whichever landed second in the
  3156. # file — with the same generic connection error an unimported CA produces.
  3157. @staticmethod
  3158. def _install(base, serial):
  3159. """Build a CertificateService laid out the way the manager lays one out."""
  3160. from backend.app.services.virtual_printer.certificate import CertificateService
  3161. return CertificateService(cert_dir=base / "0", serial=serial, shared_ca_dir=base)
  3162. @staticmethod
  3163. def _plant_legacy_ca(ca_dir):
  3164. """Write a pre-#3014 CA: plain common name, no SubjectKeyIdentifier."""
  3165. from datetime import datetime, timedelta, timezone
  3166. from cryptography import x509
  3167. from cryptography.hazmat.primitives import hashes, serialization
  3168. from cryptography.hazmat.primitives.asymmetric import rsa
  3169. from cryptography.x509.oid import NameOID
  3170. key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
  3171. name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Virtual Printer CA")])
  3172. now = datetime.now(timezone.utc)
  3173. cert = (
  3174. x509.CertificateBuilder()
  3175. .subject_name(name)
  3176. .issuer_name(name)
  3177. .public_key(key.public_key())
  3178. .serial_number(x509.random_serial_number())
  3179. .not_valid_before(now)
  3180. .not_valid_after(now + timedelta(days=7300))
  3181. .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True)
  3182. .sign(key, hashes.SHA256())
  3183. )
  3184. ca_dir.mkdir(parents=True, exist_ok=True)
  3185. (ca_dir / "bbl_ca.key").write_bytes(
  3186. key.private_bytes(
  3187. encoding=serialization.Encoding.PEM,
  3188. format=serialization.PrivateFormat.TraditionalOpenSSL,
  3189. encryption_algorithm=serialization.NoEncryption(),
  3190. )
  3191. )
  3192. (ca_dir / "bbl_ca.crt").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
  3193. return cert
  3194. def test_ca_common_name_is_unique_per_install(self, tmp_path):
  3195. """Two installs must not produce CAs that share a Subject DN."""
  3196. from cryptography import x509
  3197. from backend.app.services.virtual_printer.certificate import CA_COMMON_NAME_PREFIX
  3198. cas = []
  3199. for name in ("a", "b"):
  3200. service = self._install(tmp_path / name, "TEST123")
  3201. service.generate_certificates()
  3202. cas.append(x509.load_pem_x509_certificate(service.ca_cert_path.read_bytes()))
  3203. subjects = [ca.subject.rfc4514_string() for ca in cas]
  3204. assert all(s.startswith(f"CN={CA_COMMON_NAME_PREFIX} ") for s in subjects)
  3205. assert subjects[0] != subjects[1]
  3206. def test_ca_key_identifier_is_carried_into_the_printer_certificate(self, cert_service):
  3207. """The CA advertises a key id and the leaf names it as its authority."""
  3208. from cryptography import x509
  3209. cert_path, _ = cert_service.generate_certificates()
  3210. leaf = x509.load_pem_x509_certificate(cert_path.read_bytes())
  3211. ca = x509.load_pem_x509_certificate(cert_service.ca_cert_path.read_bytes())
  3212. ca_skid = ca.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value
  3213. leaf_akid = leaf.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier).value
  3214. assert leaf_akid.key_identifier == ca_skid.digest
  3215. def test_certificates_tolerate_a_client_clock_that_is_behind(self, cert_service):
  3216. """Both certificates start a day back: one valid from the second it was
  3217. made was refused by a slicer whose clock was a minute behind ("-1")."""
  3218. from datetime import datetime, timedelta, timezone
  3219. from cryptography import x509
  3220. cert_path, _ = cert_service.generate_certificates()
  3221. now = datetime.now(timezone.utc)
  3222. ca = x509.load_pem_x509_certificate(cert_service.ca_cert_path.read_bytes())
  3223. leaf = x509.load_pem_x509_certificate(cert_path.read_bytes())
  3224. for cert in (ca, leaf):
  3225. assert cert.not_valid_before_utc <= now - timedelta(hours=23)
  3226. assert leaf.not_valid_before_utc >= ca.not_valid_before_utc
  3227. def test_printer_certificate_never_starts_before_an_existing_ca(self, tmp_path):
  3228. """A CA made less than a day ago, without the backdating, still bounds its leaf."""
  3229. from cryptography import x509
  3230. planted = self._plant_legacy_ca(tmp_path)
  3231. service = self._install(tmp_path, "TEST123")
  3232. cert_path, _ = service.generate_certificates()
  3233. leaf = x509.load_pem_x509_certificate(cert_path.read_bytes())
  3234. assert leaf.not_valid_before_utc == planted.not_valid_before_utc
  3235. def test_existing_ca_is_reused_and_its_certificate_shape_is_unchanged(self, tmp_path):
  3236. """An install that already has a CA keeps it — nothing to re-import.
  3237. Its printer certificate also stays exactly as it was: no authority key
  3238. identifier, because the CA it was signed by advertises none.
  3239. """
  3240. from cryptography import x509
  3241. from cryptography.hazmat.primitives import hashes
  3242. planted = self._plant_legacy_ca(tmp_path)
  3243. service = self._install(tmp_path, "TEST123")
  3244. cert_path, _ = service.generate_certificates()
  3245. ca = x509.load_pem_x509_certificate(service.ca_cert_path.read_bytes())
  3246. assert ca.fingerprint(hashes.SHA256()) == planted.fingerprint(hashes.SHA256())
  3247. assert ca.subject.rfc4514_string() == "CN=Virtual Printer CA"
  3248. leaf = x509.load_pem_x509_certificate(cert_path.read_bytes())
  3249. with pytest.raises(x509.ExtensionNotFound):
  3250. leaf.extensions.get_extension_for_class(x509.AuthorityKeyIdentifier)
  3251. @pytest.mark.skipif(shutil.which("openssl") is None, reason="needs the openssl binary")
  3252. def test_two_installs_verify_from_a_single_trust_store(self, tmp_path):
  3253. """The reported symptom: both CAs imported, both chains must verify."""
  3254. import subprocess
  3255. leaves = []
  3256. bundle = b""
  3257. for name, serial in (("a", "00M09A391800001"), ("b", "01P00A391800002")):
  3258. service = self._install(tmp_path / name, serial)
  3259. cert_path, _ = service.generate_certificates()
  3260. # The per-VP file is a chain (leaf + CA); OpenSSL reads the leaf first.
  3261. leaves.append(cert_path)
  3262. bundle += service.ca_cert_path.read_bytes()
  3263. bundle_path = tmp_path / "trust_store.pem"
  3264. bundle_path.write_bytes(bundle)
  3265. for leaf in leaves:
  3266. result = subprocess.run(
  3267. ["openssl", "verify", "-CAfile", str(bundle_path), str(leaf)],
  3268. capture_output=True,
  3269. text=True,
  3270. )
  3271. assert result.returncode == 0, result.stdout + result.stderr
  3272. class TestBindServer:
  3273. """Tests for BindServer (port 3002 bind/detect protocol)."""
  3274. @pytest.fixture
  3275. def bind_server(self):
  3276. """Create a BindServer instance."""
  3277. from backend.app.services.virtual_printer.bind_server import BindServer
  3278. return BindServer(
  3279. serial="09400A391800001",
  3280. model="O1D",
  3281. name="Bambuddy",
  3282. )
  3283. def test_build_frame(self, bind_server):
  3284. """Verify frame building produces correct format."""
  3285. payload = {"login": {"command": "detect"}}
  3286. frame = bind_server._build_frame(payload)
  3287. # Header: 0xA5A5
  3288. assert frame[:2] == b"\xa5\xa5"
  3289. # Trailer: 0xA7A7
  3290. assert frame[-2:] == b"\xa7\xa7"
  3291. # Length field is total message size (LE uint16)
  3292. import struct
  3293. total_len = struct.unpack_from("<H", frame, 2)[0]
  3294. assert total_len == len(frame)
  3295. # JSON payload is between header and trailer
  3296. import json
  3297. json_bytes = frame[4:-2]
  3298. parsed = json.loads(json_bytes)
  3299. assert parsed == payload
  3300. def test_parse_frame_valid(self, bind_server):
  3301. """Verify valid frame parsing extracts JSON correctly."""
  3302. import json
  3303. import struct
  3304. payload = {"login": {"command": "detect", "sequence_id": "20000"}}
  3305. json_bytes = json.dumps(payload, separators=(",", ":")).encode()
  3306. total_len = 4 + len(json_bytes) + 2
  3307. frame = b"\xa5\xa5" + struct.pack("<H", total_len) + json_bytes + b"\xa7\xa7"
  3308. result = bind_server._parse_frame(frame)
  3309. assert result is not None
  3310. assert result["login"]["command"] == "detect"
  3311. assert result["login"]["sequence_id"] == "20000"
  3312. def test_parse_frame_invalid_header(self, bind_server):
  3313. """Verify invalid header returns None."""
  3314. result = bind_server._parse_frame(b"\xbb\xbb\x06\x00{}\xa7\xa7")
  3315. assert result is None
  3316. def test_parse_frame_invalid_trailer(self, bind_server):
  3317. """Verify invalid trailer returns None."""
  3318. result = bind_server._parse_frame(b"\xa5\xa5\x06\x00{}\xbb\xbb")
  3319. assert result is None
  3320. def test_parse_frame_too_short(self, bind_server):
  3321. """Verify short data returns None."""
  3322. result = bind_server._parse_frame(b"\xa5\xa5\x00")
  3323. assert result is None
  3324. def test_parse_frame_invalid_json(self, bind_server):
  3325. """Verify invalid JSON returns None."""
  3326. import struct
  3327. bad_json = b"not json"
  3328. total_len = 4 + len(bad_json) + 2
  3329. frame = b"\xa5\xa5" + struct.pack("<H", total_len) + bad_json + b"\xa7\xa7"
  3330. result = bind_server._parse_frame(frame)
  3331. assert result is None
  3332. def test_build_frame_roundtrip(self, bind_server):
  3333. """Verify build_frame output can be parsed back."""
  3334. payload = {
  3335. "login": {
  3336. "bind": "free",
  3337. "command": "detect",
  3338. "connect": "lan",
  3339. "dev_cap": 1,
  3340. "id": "09400A391800001",
  3341. "model": "O1D",
  3342. "name": "Bambuddy",
  3343. "sequence_id": 3021,
  3344. "version": "01.00.00.00",
  3345. }
  3346. }
  3347. frame = bind_server._build_frame(payload)
  3348. parsed = bind_server._parse_frame(frame)
  3349. assert parsed is not None
  3350. assert parsed["login"]["id"] == "09400A391800001"
  3351. assert parsed["login"]["model"] == "O1D"
  3352. assert parsed["login"]["name"] == "Bambuddy"
  3353. assert parsed["login"]["bind"] == "free"
  3354. def test_bind_server_stores_config(self, bind_server):
  3355. """Verify bind server stores serial, model, name."""
  3356. assert bind_server.serial == "09400A391800001"
  3357. assert bind_server.model == "O1D"
  3358. assert bind_server.name == "Bambuddy"
  3359. assert bind_server.version == "01.00.00.00"
  3360. def test_bind_server_custom_version(self):
  3361. """Verify custom firmware version is stored."""
  3362. from backend.app.services.virtual_printer.bind_server import BindServer
  3363. server = BindServer(
  3364. serial="TEST123",
  3365. model="C13",
  3366. name="Test",
  3367. version="02.03.04.05",
  3368. )
  3369. assert server.version == "02.03.04.05"
  3370. def test_bind_ports_constant(self):
  3371. """Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility."""
  3372. from backend.app.services.virtual_printer.bind_server import BIND_PORTS
  3373. assert 3000 in BIND_PORTS
  3374. assert 3002 in BIND_PORTS
  3375. def test_bind_server_initializes_empty_servers_list(self, bind_server):
  3376. """Verify bind server starts with empty servers list."""
  3377. assert bind_server._servers == []
  3378. assert bind_server._running is False
  3379. class TestSlicerProxyManager:
  3380. """Tests for SlicerProxyManager (proxy mode)."""
  3381. @pytest.fixture
  3382. def proxy_manager(self, tmp_path):
  3383. """Create a SlicerProxyManager instance."""
  3384. from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
  3385. # Create dummy cert files
  3386. cert_path = tmp_path / "cert.pem"
  3387. key_path = tmp_path / "key.pem"
  3388. cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
  3389. # Split string to avoid pre-commit hook false positive on test data
  3390. key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
  3391. return SlicerProxyManager(
  3392. target_host="192.168.1.100",
  3393. cert_path=cert_path,
  3394. key_path=key_path,
  3395. )
  3396. def test_proxy_manager_initializes_ports(self, proxy_manager):
  3397. """Verify proxy manager has correct port constants."""
  3398. # FTP proxy uses privileged port 990 to match what Bambu Studio expects
  3399. assert proxy_manager.LOCAL_FTP_PORT == 990
  3400. assert proxy_manager.LOCAL_MQTT_PORT == 8883
  3401. assert proxy_manager.PRINTER_FTP_PORT == 990
  3402. assert proxy_manager.PRINTER_MQTT_PORT == 8883
  3403. assert proxy_manager.PRINTER_FILE_TRANSFER_PORT == 6000
  3404. assert proxy_manager.PRINTER_RTSP_PORT == 322
  3405. # Auxiliary ports: undocumented proprietary ports for A1/P1S etc.
  3406. assert proxy_manager.PRINTER_AUX_PORTS == [2024, 2025, 2026]
  3407. # Bind ports: both 3000 and 3002 for slicer compatibility
  3408. assert proxy_manager.PRINTER_BIND_PORTS == [3000, 3002]
  3409. # FTP data port range for transparent EPSV proxying
  3410. assert proxy_manager.FTP_DATA_PORT_MIN == 50000
  3411. assert proxy_manager.FTP_DATA_PORT_MAX == 50100
  3412. def test_proxy_manager_stores_target_host(self, proxy_manager):
  3413. """Verify proxy manager stores target host."""
  3414. assert proxy_manager.target_host == "192.168.1.100"
  3415. def test_get_status_before_start(self, proxy_manager):
  3416. """Verify get_status returns zeros before start."""
  3417. status = proxy_manager.get_status()
  3418. assert status["running"] is False
  3419. assert status["ftp_connections"] == 0
  3420. assert status["mqtt_connections"] == 0
  3421. @pytest.mark.asyncio
  3422. async def test_proxy_start_creates_transparent_proxies(self, tmp_path):
  3423. """Verify start() uses TCPProxy for FTP/FileTransfer/RTSP and TLSProxy only for MQTT.
  3424. The transparent proxy architecture preserves end-to-end TLS between
  3425. slicer and printer for all protocols except MQTT, which must be
  3426. TLS-terminated to rewrite the printer's IP in MQTT payloads.
  3427. """
  3428. from unittest.mock import patch
  3429. from backend.app.services.virtual_printer.tcp_proxy import (
  3430. SlicerProxyManager,
  3431. TCPProxy,
  3432. TLSProxy,
  3433. )
  3434. cert_path = tmp_path / "cert.pem"
  3435. key_path = tmp_path / "key.pem"
  3436. cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
  3437. key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
  3438. mgr = SlicerProxyManager(
  3439. target_host="192.168.1.100",
  3440. cert_path=cert_path,
  3441. key_path=key_path,
  3442. bind_address="10.0.0.1",
  3443. )
  3444. # Mock asyncio.create_task and asyncio.gather to prevent actual
  3445. # server start. Close every coroutine handed to create_task (gather
  3446. # only sees the stand-in tasks) — otherwise the ~110
  3447. # run_with_logging() coros built inside start() are garbage-collected
  3448. # unfinalized and surface later as PytestUnraisableExceptionWarning at
  3449. # random in other tests.
  3450. def _close_and_stand_in(coro, **_):
  3451. coro.close()
  3452. return MagicMock()
  3453. with (
  3454. patch("asyncio.create_task", side_effect=_close_and_stand_in),
  3455. patch("asyncio.gather", new=AsyncMock()),
  3456. patch.object(SlicerProxyManager, "_log_activity"),
  3457. ):
  3458. await mgr.start()
  3459. # FTP, FileTransfer, RTSP should be TCPProxy (transparent)
  3460. assert isinstance(mgr._ftp_proxy, TCPProxy), "FTP should be TCPProxy (transparent)"
  3461. assert isinstance(mgr._file_transfer_proxy, TCPProxy), "FileTransfer should be TCPProxy"
  3462. assert isinstance(mgr._rtsp_proxy, TCPProxy), "RTSP should be TCPProxy"
  3463. # MQTT should be TLSProxy (TLS-terminated for IP rewriting)
  3464. assert isinstance(mgr._mqtt_proxy, TLSProxy), "MQTT should be TLSProxy (TLS-terminated)"
  3465. # Auxiliary ports (2024-2026) should be TCPProxy (transparent)
  3466. assert len(mgr._aux_proxies) == 3, "Should have 3 aux port proxies"
  3467. for ap in mgr._aux_proxies:
  3468. assert isinstance(ap, TCPProxy), "Aux proxies should be TCPProxy"
  3469. assert mgr._aux_proxies[0].listen_port == 2024
  3470. assert mgr._aux_proxies[0].target_port == 2024
  3471. assert mgr._aux_proxies[2].listen_port == 2026
  3472. # FTP data ports should be pre-created as TCPProxy instances
  3473. assert len(mgr._ftp_data_proxies) == 101 # 50000-50100 inclusive
  3474. for dp in mgr._ftp_data_proxies:
  3475. assert isinstance(dp, TCPProxy), "FTP data proxies should be TCPProxy"
  3476. # Verify FTP data proxies target the same port on the printer
  3477. first_dp = mgr._ftp_data_proxies[0]
  3478. assert first_dp.listen_port == 50000
  3479. assert first_dp.target_port == 50000
  3480. assert first_dp.target_host == "192.168.1.100"
  3481. last_dp = mgr._ftp_data_proxies[-1]
  3482. assert last_dp.listen_port == 50100
  3483. assert last_dp.target_port == 50100
  3484. def test_proxy_manager_mqtt_has_ip_rewriting(self, tmp_path):
  3485. """Verify MQTT proxy is configured with IP rewriting when bind_address is set."""
  3486. from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
  3487. cert_path = tmp_path / "cert.pem"
  3488. key_path = tmp_path / "key.pem"
  3489. cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
  3490. key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
  3491. mgr = SlicerProxyManager(
  3492. target_host="192.168.1.100",
  3493. cert_path=cert_path,
  3494. key_path=key_path,
  3495. bind_address="10.0.0.1",
  3496. )
  3497. # Before start, proxies are None — verify constructor stores rewrite config
  3498. assert mgr.bind_address == "10.0.0.1"
  3499. assert mgr.target_host == "192.168.1.100"
  3500. class TestSSDPProxy:
  3501. """Tests for SSDPProxy (cross-network SSDP relay)."""
  3502. @pytest.fixture
  3503. def ssdp_proxy(self):
  3504. """Create an SSDPProxy instance."""
  3505. from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
  3506. return SSDPProxy(
  3507. local_interface_ip="192.168.1.100",
  3508. remote_interface_ip="10.0.0.100",
  3509. target_printer_ip="192.168.1.50",
  3510. )
  3511. def test_ssdp_proxy_stores_interface_ips(self, ssdp_proxy):
  3512. """Verify SSDPProxy stores interface IPs correctly."""
  3513. assert ssdp_proxy.local_interface_ip == "192.168.1.100"
  3514. assert ssdp_proxy.remote_interface_ip == "10.0.0.100"
  3515. assert ssdp_proxy.target_printer_ip == "192.168.1.50"
  3516. def test_rewrite_ssdp_location(self, ssdp_proxy):
  3517. """Verify SSDP Location header is rewritten to remote interface IP."""
  3518. original_packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: TestPrinter\r\n\r\n"
  3519. rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
  3520. # Location should be changed to remote interface IP
  3521. assert b"Location: 10.0.0.100" in rewritten
  3522. assert b"Location: 192.168.1.50" not in rewritten
  3523. # Other headers should be preserved
  3524. assert b"DevName.bambu.com: TestPrinter" in rewritten
  3525. def test_rewrite_ssdp_location_case_insensitive(self, ssdp_proxy):
  3526. """Verify SSDP Location rewrite is case insensitive."""
  3527. original_packet = b"NOTIFY * HTTP/1.1\r\nlocation: 192.168.1.50\r\n\r\n"
  3528. rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
  3529. assert b"10.0.0.100" in rewritten
  3530. def test_rewrite_ssdp_location_no_match(self, ssdp_proxy):
  3531. """Verify packet without Location header is returned unchanged."""
  3532. original_packet = b"NOTIFY * HTTP/1.1\r\nDevName.bambu.com: Test\r\n\r\n"
  3533. rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
  3534. # No Location header, but _rewrite_ssdp logs a warning and returns as-is
  3535. assert b"DevName.bambu.com: Test" in rewritten
  3536. def test_parse_ssdp_message(self, ssdp_proxy):
  3537. """Verify SSDP message parsing extracts headers."""
  3538. packet = (
  3539. b"NOTIFY * HTTP/1.1\r\n"
  3540. b"Location: 192.168.1.50\r\n"
  3541. b"DevName.bambu.com: TestPrinter\r\n"
  3542. b"DevModel.bambu.com: BL-P001\r\n"
  3543. b"\r\n"
  3544. )
  3545. headers = ssdp_proxy._parse_ssdp_message(packet)
  3546. assert headers["location"] == "192.168.1.50"
  3547. assert headers["devname.bambu.com"] == "TestPrinter"
  3548. assert headers["devmodel.bambu.com"] == "BL-P001"
  3549. class TestVirtualPrinterManagerDirectories:
  3550. """Tests for VirtualPrinterManager directory management."""
  3551. def test_ensure_base_directories_creates_subdirs(self, tmp_path):
  3552. """Verify _ensure_base_directories creates required base directories."""
  3553. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  3554. manager = VirtualPrinterManager()
  3555. manager._base_dir = tmp_path / "virtual_printer"
  3556. manager._ensure_base_directories()
  3557. assert (tmp_path / "virtual_printer").exists()
  3558. assert (tmp_path / "virtual_printer" / "uploads").exists()
  3559. assert (tmp_path / "virtual_printer" / "certs").exists()
  3560. def test_ensure_base_directories_handles_permission_error(self, tmp_path, caplog):
  3561. """Verify _ensure_base_directories logs error on permission failure."""
  3562. import logging
  3563. from backend.app.services.virtual_printer.manager import VirtualPrinterManager
  3564. manager = VirtualPrinterManager()
  3565. vp_dir = tmp_path / "virtual_printer"
  3566. manager._base_dir = vp_dir
  3567. original_mkdir = type(vp_dir).mkdir
  3568. def mock_mkdir(self, *args, **kwargs):
  3569. if "virtual_printer" in str(self):
  3570. raise PermissionError("Permission denied")
  3571. return original_mkdir(self, *args, **kwargs)
  3572. with caplog.at_level(logging.ERROR), patch.object(type(vp_dir), "mkdir", mock_mkdir):
  3573. manager._ensure_base_directories()
  3574. assert "Permission denied" in caplog.text
  3575. def test_instance_creates_per_vp_directories(self, tmp_path):
  3576. """Verify VirtualPrinterInstance creates per-VP upload and cert dirs."""
  3577. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  3578. VirtualPrinterInstance(
  3579. vp_id=42,
  3580. name="Test",
  3581. mode="library",
  3582. model="C11",
  3583. access_code="12345678",
  3584. serial_suffix="391800042",
  3585. base_dir=tmp_path,
  3586. )
  3587. assert (tmp_path / "uploads" / "42").exists()
  3588. assert (tmp_path / "uploads" / "42" / "cache").exists()
  3589. assert (tmp_path / "certs" / "42").exists()
  3590. class TestVirtualPrinterCameraPassthrough:
  3591. """Tests for the non-proxy VP camera pass-through port selection (#1868).
  3592. The camera port must follow the TARGET printer's model, not the VP's
  3593. spoofed model: RTSP models (X1/X2/H2/P2S) use 322, chamber-image models
  3594. (A1/P1) use 6000. Before the fix, the manager hardcoded 322, so P1S /
  3595. A1 targets got a 322 listener with no upstream and BambuStudio /
  3596. OrcaSlicer Liveview failed with error `[2:-10061]` (the reporter's
  3597. symptom in #1868).
  3598. """
  3599. @staticmethod
  3600. def _patch_start_server(monkeypatch):
  3601. """Patch every non-camera constructor start_server touches so the
  3602. test only exercises the camera-port branch. Returns the list that
  3603. ``CameraBridge`` calls get captured into."""
  3604. from backend.app.services.virtual_printer import manager as vp_manager
  3605. tcp_calls: list[dict] = []
  3606. class FakeTCPProxy:
  3607. def __init__(self, **kwargs):
  3608. tcp_calls.append(kwargs)
  3609. self.kwargs = kwargs
  3610. async def start(self):
  3611. return None
  3612. async def stop(self):
  3613. return None
  3614. # The camera bridge is the only camera listener start_server constructs (#1430).
  3615. monkeypatch.setattr(vp_manager, "CameraBridge", FakeTCPProxy)
  3616. # No-op every other service constructor + resolve_cert_and_advertise.
  3617. # start_server() awaits `.ready.wait()` on FTP/MQTT/Bind/SSDP after
  3618. # spawning them, so each fake instance must carry an already-set
  3619. # asyncio.Event as `.ready` — a plain MagicMock returns another
  3620. # MagicMock for `.wait()`, which `asyncio.gather` then rejects with
  3621. # `TypeError: An asyncio.Future, a coroutine or an awaitable is
  3622. # required`.
  3623. def _service_factory():
  3624. def make(*args, **kwargs):
  3625. inst = MagicMock()
  3626. inst.start = AsyncMock(return_value=None)
  3627. inst.stop = AsyncMock(return_value=None)
  3628. ready = asyncio.Event()
  3629. ready.set()
  3630. inst.ready = ready
  3631. return inst
  3632. return make
  3633. for name in (
  3634. "VirtualPrinterFTPServer",
  3635. "SimpleMQTTServer",
  3636. "MQTTBridge",
  3637. "BindServer",
  3638. "VirtualPrinterSSDPServer",
  3639. "SSDPProxy",
  3640. ):
  3641. monkeypatch.setattr(vp_manager, name, _service_factory())
  3642. return tcp_calls
  3643. async def _run_start_server(
  3644. self,
  3645. tmp_path,
  3646. monkeypatch,
  3647. *,
  3648. target_model: str,
  3649. target_ip: str = "192.168.1.100",
  3650. ) -> list[dict]:
  3651. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  3652. tcp_calls = self._patch_start_server(monkeypatch)
  3653. instance = VirtualPrinterInstance(
  3654. vp_id=99,
  3655. name="CamTest",
  3656. mode="library",
  3657. model="BL-P001", # VP's spoofed identity — irrelevant here
  3658. access_code="12345678",
  3659. serial_suffix="391800099",
  3660. target_printer_id=7,
  3661. base_dir=tmp_path,
  3662. )
  3663. # printer_manager stub returns a client with the model + ip we want.
  3664. client = MagicMock()
  3665. client.ip_address = target_ip
  3666. client.model = target_model
  3667. printer_manager = MagicMock()
  3668. printer_manager.get_client.return_value = client
  3669. instance._printer_manager = printer_manager
  3670. # Cert / advertise resolution — start_server calls this early. Patch
  3671. # to a fixed tuple so no filesystem I/O is required.
  3672. monkeypatch.setattr(
  3673. instance,
  3674. "_resolve_cert_and_advertise",
  3675. lambda: (Path("/tmp/cert.pem"), Path("/tmp/key.pem"), "192.168.1.1"), # nosec B108
  3676. )
  3677. try:
  3678. await instance.start_server()
  3679. finally:
  3680. for task in instance._tasks:
  3681. task.cancel()
  3682. await asyncio.gather(*instance._tasks, return_exceptions=True)
  3683. return tcp_calls
  3684. @pytest.mark.asyncio
  3685. async def test_rtsp_model_p2s_opens_port_322(self, tmp_path, monkeypatch):
  3686. calls = await self._run_start_server(tmp_path, monkeypatch, target_model="P2S")
  3687. assert any(c["listen_port"] == 322 and c["target_port"] == 322 for c in calls), (
  3688. f"Expected 322 pass-through for RTSP model P2S, got {calls}"
  3689. )
  3690. @pytest.mark.asyncio
  3691. async def test_chamber_image_model_p1s_opens_port_6000(self, tmp_path, monkeypatch):
  3692. """#1868 regression guard: P1S target must expose 6000, not 322."""
  3693. calls = await self._run_start_server(tmp_path, monkeypatch, target_model="P1S")
  3694. assert any(c["listen_port"] == 6000 and c["target_port"] == 6000 for c in calls), (
  3695. f"Expected 6000 pass-through for chamber-image model P1S (#1868), got {calls}"
  3696. )
  3697. assert not any(c["listen_port"] == 322 for c in calls), f"P1S should NOT get a 322 listener, got {calls}"
  3698. @pytest.mark.asyncio
  3699. async def test_chamber_image_model_a1_opens_port_6000(self, tmp_path, monkeypatch):
  3700. calls = await self._run_start_server(tmp_path, monkeypatch, target_model="A1")
  3701. assert any(c["listen_port"] == 6000 and c["target_port"] == 6000 for c in calls)
  3702. @pytest.mark.asyncio
  3703. async def test_rtsp_model_x1c_opens_port_322(self, tmp_path, monkeypatch):
  3704. calls = await self._run_start_server(tmp_path, monkeypatch, target_model="X1C")
  3705. assert any(c["listen_port"] == 322 and c["target_port"] == 322 for c in calls)
  3706. @pytest.mark.asyncio
  3707. @pytest.mark.parametrize(("target_model", "protocol"), [("H2C", "rtsp"), ("P1S", "chamber")])
  3708. async def test_the_camera_signs_the_slicer_in_for_the_printer(self, tmp_path, monkeypatch, target_model, protocol):
  3709. """#1430: the camera bridge, not a plain pass-through, so a personal code gets the stream too."""
  3710. calls = await self._run_start_server(tmp_path, monkeypatch, target_model=target_model)
  3711. (call,) = calls
  3712. assert call["protocol"] == protocol
  3713. assert call["cert_path"] == Path("/tmp/cert.pem") # nosec B108
  3714. assert callable(call["codes"]) and callable(call["printer_code"])
  3715. class TestCameraCodes:
  3716. """What the camera through a non-proxy VP accepts (#1430)."""
  3717. @staticmethod
  3718. def _instance(tmp_path, printer_code="12345678", session_factory=None):
  3719. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  3720. instance = VirtualPrinterInstance(
  3721. vp_id=98,
  3722. name="Cam",
  3723. mode="library",
  3724. model="O1C",
  3725. access_code="87654321",
  3726. serial_suffix="391800098",
  3727. target_printer_id=7,
  3728. base_dir=tmp_path,
  3729. session_factory=session_factory,
  3730. )
  3731. client = MagicMock()
  3732. client.access_code = printer_code
  3733. printer_manager = MagicMock()
  3734. printer_manager.get_client.return_value = client
  3735. instance._printer_manager = printer_manager
  3736. return instance
  3737. @pytest.mark.asyncio
  3738. async def test_the_vp_and_printer_codes_without_a_database(self, tmp_path):
  3739. from backend.app.services.virtual_printer.camera_bridge import AcceptedCode
  3740. instance = self._instance(tmp_path)
  3741. assert instance._target_access_code() == "12345678"
  3742. assert await instance.camera_codes() == [AcceptedCode("87654321"), AcceptedCode("12345678")]
  3743. @pytest.mark.asyncio
  3744. async def test_personal_codes_of_users_who_may_watch(self, tmp_path):
  3745. from backend.app.services.virtual_printer.camera_bridge import AcceptedCode
  3746. session = MagicMock()
  3747. session.__aenter__ = AsyncMock(return_value=MagicMock())
  3748. session.__aexit__ = AsyncMock(return_value=False)
  3749. instance = self._instance(tmp_path, printer_code="87654321", session_factory=MagicMock(return_value=session))
  3750. with patch(
  3751. "backend.app.services.virtual_printer.user_codes.camera_codes",
  3752. AsyncMock(return_value=[("11112222", 5)]),
  3753. ) as lookup:
  3754. codes = await instance.camera_codes()
  3755. lookup.assert_awaited_once()
  3756. assert lookup.await_args.args[1] == 7
  3757. # The printer's code equals the VP's here, so it is listed once.
  3758. assert codes == [AcceptedCode("87654321"), AcceptedCode("11112222", 5)]
  3759. class TestVirtualPrinterInstanceProxyMode:
  3760. """Tests for VirtualPrinterInstance proxy mode."""
  3761. @pytest.fixture
  3762. def proxy_instance(self, tmp_path):
  3763. """Create a proxy-mode VirtualPrinterInstance."""
  3764. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  3765. return VirtualPrinterInstance(
  3766. vp_id=10,
  3767. name="ProxyTest",
  3768. mode="proxy",
  3769. model="C11",
  3770. access_code="",
  3771. serial_suffix="391800010",
  3772. target_printer_ip="192.168.1.100",
  3773. target_printer_serial="01P00A000000001",
  3774. base_dir=tmp_path,
  3775. )
  3776. def test_proxy_instance_properties(self, proxy_instance):
  3777. """Verify proxy instance stores config correctly."""
  3778. assert proxy_instance.is_proxy is True
  3779. assert proxy_instance.mode == "proxy"
  3780. assert proxy_instance.target_printer_ip == "192.168.1.100"
  3781. assert proxy_instance.target_printer_serial == "01P00A000000001"
  3782. def test_proxy_instance_does_not_require_access_code(self, proxy_instance):
  3783. """Verify proxy mode can have empty access code."""
  3784. assert proxy_instance.access_code == ""
  3785. def test_get_status_proxy_includes_proxy_fields(self, proxy_instance):
  3786. """Verify get_status includes proxy fields when proxy is active."""
  3787. mock_proxy = MagicMock()
  3788. mock_proxy.get_status.return_value = {
  3789. "running": True,
  3790. "ftp_port": 990,
  3791. "mqtt_port": 8883,
  3792. "ftp_connections": 1,
  3793. "mqtt_connections": 2,
  3794. "target_host": "192.168.1.100",
  3795. }
  3796. proxy_instance._proxy = mock_proxy
  3797. status = proxy_instance.get_status()
  3798. assert "proxy" in status
  3799. assert status["proxy"]["ftp_port"] == 990
  3800. assert status["proxy"]["mqtt_connections"] == 2
  3801. def test_proxy_instance_stores_remote_interface(self, tmp_path):
  3802. """Verify proxy instance stores remote_interface_ip."""
  3803. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  3804. inst = VirtualPrinterInstance(
  3805. vp_id=11,
  3806. name="Proxy2",
  3807. mode="proxy",
  3808. model="C11",
  3809. access_code="",
  3810. serial_suffix="391800011",
  3811. target_printer_ip="192.168.1.100",
  3812. remote_interface_ip="10.0.0.50",
  3813. base_dir=tmp_path,
  3814. )
  3815. assert inst.remote_interface_ip == "10.0.0.50"
  3816. class TestVirtualPrinterInstanceIPOverride:
  3817. """Tests for remote_interface_ip and bind_ip on VirtualPrinterInstance."""
  3818. @pytest.fixture
  3819. def instance_with_remote_ip(self, tmp_path):
  3820. """Create an instance with remote_interface_ip set."""
  3821. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  3822. return VirtualPrinterInstance(
  3823. vp_id=20,
  3824. name="IPTest",
  3825. mode="library",
  3826. model="BL-P001",
  3827. access_code="12345678",
  3828. serial_suffix="391800020",
  3829. bind_ip="192.168.1.50",
  3830. remote_interface_ip="10.0.0.50",
  3831. base_dir=tmp_path,
  3832. )
  3833. def test_instance_stores_bind_ip(self, instance_with_remote_ip):
  3834. """Verify bind_ip is stored."""
  3835. assert instance_with_remote_ip.bind_ip == "192.168.1.50"
  3836. def test_instance_stores_remote_interface_ip(self, instance_with_remote_ip):
  3837. """Verify remote_interface_ip is stored."""
  3838. assert instance_with_remote_ip.remote_interface_ip == "10.0.0.50"
  3839. def test_generate_certificates_includes_remote_and_bind_ip(self, instance_with_remote_ip):
  3840. """Verify generate_certificates passes remote_interface_ip and bind_ip as SANs."""
  3841. with (
  3842. patch.object(instance_with_remote_ip._cert_service, "delete_printer_certificate"),
  3843. patch.object(
  3844. instance_with_remote_ip._cert_service,
  3845. "generate_certificates",
  3846. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  3847. ) as mock_gen,
  3848. ):
  3849. instance_with_remote_ip.generate_certificates()
  3850. mock_gen.assert_called_once_with(additional_ips=["10.0.0.50", "192.168.1.50"])
  3851. def test_generate_certificates_no_remote_ip(self, tmp_path):
  3852. """Verify generate_certificates passes only bind_ip when no remote_interface_ip."""
  3853. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  3854. inst = VirtualPrinterInstance(
  3855. vp_id=21,
  3856. name="NoRemote",
  3857. mode="library",
  3858. model="BL-P001",
  3859. access_code="12345678",
  3860. serial_suffix="391800021",
  3861. bind_ip="192.168.1.50",
  3862. base_dir=tmp_path,
  3863. )
  3864. with (
  3865. patch.object(inst._cert_service, "delete_printer_certificate"),
  3866. patch.object(
  3867. inst._cert_service,
  3868. "generate_certificates",
  3869. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  3870. ) as mock_gen,
  3871. ):
  3872. inst.generate_certificates()
  3873. mock_gen.assert_called_once_with(additional_ips=["192.168.1.50"])
  3874. def test_generate_certificates_no_ips(self, tmp_path):
  3875. """Verify generate_certificates passes None when no IPs configured."""
  3876. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  3877. inst = VirtualPrinterInstance(
  3878. vp_id=22,
  3879. name="NoIPs",
  3880. mode="library",
  3881. model="BL-P001",
  3882. access_code="12345678",
  3883. serial_suffix="391800022",
  3884. base_dir=tmp_path,
  3885. )
  3886. with (
  3887. patch.object(inst._cert_service, "delete_printer_certificate"),
  3888. patch.object(
  3889. inst._cert_service,
  3890. "generate_certificates",
  3891. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  3892. ) as mock_gen,
  3893. ):
  3894. inst.generate_certificates()
  3895. mock_gen.assert_called_once_with(additional_ips=None)
  3896. class TestBindServer:
  3897. """Tests for the BindServer (port 3002 bind/detect protocol)."""
  3898. @pytest.fixture
  3899. def bind_server(self):
  3900. """Create a BindServer instance."""
  3901. from backend.app.services.virtual_printer.bind_server import BindServer
  3902. return BindServer(
  3903. serial="01S00C000000001",
  3904. model="BL-P001",
  3905. name="Bambuddy",
  3906. )
  3907. def test_build_frame(self, bind_server):
  3908. """Verify frame format: 0xA5A5 + len(u16le) + JSON + 0xA7A7."""
  3909. payload = {"login": {"command": "detect"}}
  3910. frame = bind_server._build_frame(payload)
  3911. assert frame[:2] == b"\xa5\xa5"
  3912. assert frame[-2:] == b"\xa7\xa7"
  3913. # Length field is total message size
  3914. import struct
  3915. total_len = struct.unpack_from("<H", frame, 2)[0]
  3916. assert total_len == len(frame)
  3917. # JSON payload is between header and trailer
  3918. import json
  3919. json_bytes = frame[4:-2]
  3920. parsed = json.loads(json_bytes)
  3921. assert parsed == payload
  3922. def test_parse_frame_valid(self, bind_server):
  3923. """Verify valid frame parsing."""
  3924. frame = bind_server._build_frame({"login": {"command": "detect", "sequence_id": "20000"}})
  3925. result = bind_server._parse_frame(frame)
  3926. assert result is not None
  3927. assert result["login"]["command"] == "detect"
  3928. assert result["login"]["sequence_id"] == "20000"
  3929. def test_parse_frame_invalid_header(self, bind_server):
  3930. """Verify invalid header returns None."""
  3931. frame = b"\xb5\xb5\x10\x00" + b'{"login":{}}' + b"\xa7\xa7"
  3932. assert bind_server._parse_frame(frame) is None
  3933. def test_parse_frame_invalid_trailer(self, bind_server):
  3934. """Verify invalid trailer returns None."""
  3935. frame = b"\xa5\xa5\x10\x00" + b'{"login":{}}' + b"\xb7\xb7"
  3936. assert bind_server._parse_frame(frame) is None
  3937. def test_parse_frame_too_short(self, bind_server):
  3938. """Verify short data returns None."""
  3939. assert bind_server._parse_frame(b"\xa5\xa5\x00") is None
  3940. assert bind_server._parse_frame(b"") is None
  3941. def test_parse_frame_invalid_json(self, bind_server):
  3942. """Verify invalid JSON returns None."""
  3943. import struct
  3944. bad_json = b"not json"
  3945. total_len = 4 + len(bad_json) + 2
  3946. frame = b"\xa5\xa5" + struct.pack("<H", total_len) + bad_json + b"\xa7\xa7"
  3947. assert bind_server._parse_frame(frame) is None
  3948. def test_build_frame_roundtrip(self, bind_server):
  3949. """Verify build then parse roundtrip."""
  3950. original = {"login": {"bind": "free", "command": "detect", "id": "01S00C000000001"}}
  3951. frame = bind_server._build_frame(original)
  3952. parsed = bind_server._parse_frame(frame)
  3953. assert parsed == original
  3954. def test_bind_server_stores_config(self, bind_server):
  3955. """Verify config is stored correctly."""
  3956. assert bind_server.serial == "01S00C000000001"
  3957. assert bind_server.model == "BL-P001"
  3958. assert bind_server.name == "Bambuddy"
  3959. assert bind_server.version == "01.00.00.00"
  3960. def test_bind_server_custom_version(self):
  3961. """Verify custom firmware version is stored."""
  3962. from backend.app.services.virtual_printer.bind_server import BindServer
  3963. server = BindServer(
  3964. serial="01S00C000000001",
  3965. model="BL-P001",
  3966. name="Bambuddy",
  3967. version="01.09.00.10",
  3968. )
  3969. assert server.version == "01.09.00.10"
  3970. def test_bind_ports_includes_both(self):
  3971. """Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility."""
  3972. from backend.app.services.virtual_printer.bind_server import BIND_PORTS
  3973. assert 3000 in BIND_PORTS
  3974. assert 3002 in BIND_PORTS
  3975. def test_bind_server_initializes_empty_servers_list(self, bind_server):
  3976. """Verify bind server starts with empty servers list."""
  3977. assert bind_server._servers == []
  3978. assert bind_server._running is False
  3979. @pytest.mark.asyncio
  3980. async def test_start_server_creates_bind_server(self, tmp_path):
  3981. """Verify start_server creates BindServer with correct params."""
  3982. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  3983. inst = VirtualPrinterInstance(
  3984. vp_id=99,
  3985. name="Bambuddy",
  3986. mode="library",
  3987. model="BL-P001",
  3988. access_code="12345678",
  3989. serial_suffix="391800099",
  3990. bind_ip="192.168.1.50",
  3991. base_dir=tmp_path,
  3992. )
  3993. # Each mocked child service exposes a real asyncio.Event for the
  3994. # readiness barrier added in start_server (set on instantiation so
  3995. # the barrier returns immediately in tests).
  3996. ready_event = asyncio.Event()
  3997. ready_event.set()
  3998. def with_ready(*_args, **_kwargs):
  3999. child = MagicMock()
  4000. child.ready = ready_event
  4001. return child
  4002. with (
  4003. patch(
  4004. "backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer",
  4005. side_effect=with_ready,
  4006. ),
  4007. patch(
  4008. "backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer",
  4009. side_effect=with_ready,
  4010. ),
  4011. patch(
  4012. "backend.app.services.virtual_printer.manager.SimpleMQTTServer",
  4013. side_effect=with_ready,
  4014. ),
  4015. patch(
  4016. "backend.app.services.virtual_printer.manager.BindServer",
  4017. side_effect=with_ready,
  4018. ) as mock_bind_cls,
  4019. patch.object(inst._cert_service, "delete_printer_certificate"),
  4020. patch.object(
  4021. inst._cert_service,
  4022. "generate_certificates",
  4023. return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
  4024. ),
  4025. ):
  4026. await inst.start_server()
  4027. mock_bind_cls.assert_called_once_with(
  4028. serial=inst.serial,
  4029. model="BL-P001",
  4030. name="Bambuddy",
  4031. bind_address="192.168.1.50",
  4032. cert_path=Path("/tmp/cert.pem"), # nosec B108
  4033. key_path=Path("/tmp/key.pem"), # nosec B108
  4034. )
  4035. class TestResolveModelCodes:
  4036. """Tests for model code resolution (display name → SSDP code)."""
  4037. def test_display_name_to_model_code_maps_all_models(self):
  4038. """Verify reverse mapping covers all VIRTUAL_PRINTER_MODELS entries."""
  4039. from backend.app.services.virtual_printer.manager import DISPLAY_NAME_TO_MODEL_CODE, VIRTUAL_PRINTER_MODELS
  4040. for _code, display_name in VIRTUAL_PRINTER_MODELS.items():
  4041. assert display_name in DISPLAY_NAME_TO_MODEL_CODE
  4042. # For non-duplicate display names, should map back to a valid code
  4043. assert DISPLAY_NAME_TO_MODEL_CODE[display_name] in VIRTUAL_PRINTER_MODELS
  4044. def test_resolve_printer_model_with_ssdp_code(self):
  4045. """SSDP codes pass through unchanged."""
  4046. from backend.app.api.routes.virtual_printers import _resolve_printer_model
  4047. assert _resolve_printer_model("BL-P001") == "BL-P001"
  4048. assert _resolve_printer_model("O1D") == "O1D"
  4049. assert _resolve_printer_model("N2S") == "N2S"
  4050. def test_resolve_printer_model_with_display_name(self):
  4051. """Display names resolve to SSDP codes."""
  4052. from backend.app.api.routes.virtual_printers import _resolve_printer_model
  4053. assert _resolve_printer_model("X1C") == "BL-P001"
  4054. assert _resolve_printer_model("H2D") == "O1D"
  4055. assert _resolve_printer_model("A1") == "N2S"
  4056. assert _resolve_printer_model("P1S") == "C12"
  4057. def test_resolve_printer_model_with_none_or_unknown(self):
  4058. """None and unknown values return None."""
  4059. from backend.app.api.routes.virtual_printers import _resolve_printer_model
  4060. assert _resolve_printer_model(None) is None
  4061. assert _resolve_printer_model("UnknownModel") is None
  4062. class TestMqttIpRewrite:
  4063. """Tests for TLSProxy._rewrite_mqtt_ip() MQTT packet IP rewriting."""
  4064. @staticmethod
  4065. def _build_mqtt_publish(topic: str, payload: bytes) -> bytes:
  4066. """Build a minimal MQTT PUBLISH packet."""
  4067. # PUBLISH fixed header: type 3, no flags
  4068. topic_bytes = topic.encode("utf-8")
  4069. # Variable header: topic length (2 bytes) + topic
  4070. var_header = len(topic_bytes).to_bytes(2, "big") + topic_bytes
  4071. body = var_header + payload
  4072. # Encode remaining length
  4073. remaining = len(body)
  4074. header = bytearray([0x30]) # PUBLISH, QoS 0
  4075. while True:
  4076. encoded_byte = remaining % 128
  4077. remaining //= 128
  4078. if remaining > 0:
  4079. encoded_byte |= 0x80
  4080. header.append(encoded_byte)
  4081. if remaining == 0:
  4082. break
  4083. return bytes(header) + body
  4084. @staticmethod
  4085. def _build_mqtt_pingreq() -> bytes:
  4086. """Build an MQTT PINGREQ packet (2 bytes, no payload)."""
  4087. return b"\xc0\x00"
  4088. def test_rewrite_ip_in_publish(self):
  4089. """IP string in PUBLISH payload is rewritten."""
  4090. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  4091. payload = b'{"rtsp_url":"rtsps://192.168.1.100:322/live"}'
  4092. packet = self._build_mqtt_publish("device/status", payload)
  4093. result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray())
  4094. assert b"10.0.0.1" in result
  4095. assert b"192.168.1.100" not in result
  4096. def test_no_rewrite_when_ip_absent(self):
  4097. """Packets without the target IP are passed through unchanged."""
  4098. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  4099. payload = b'{"status":"idle"}'
  4100. packet = self._build_mqtt_publish("device/status", payload)
  4101. result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray())
  4102. assert result == packet
  4103. def test_non_publish_packets_unchanged(self):
  4104. """Non-PUBLISH packets (e.g. PINGREQ) are never rewritten."""
  4105. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  4106. pingreq = self._build_mqtt_pingreq()
  4107. result, buf = TLSProxy._rewrite_mqtt_ip(pingreq, b"192.168.1.100", b"10.0.0.1", bytearray())
  4108. assert result == pingreq
  4109. def test_rewrite_preserves_packet_framing(self):
  4110. """Rewritten packet has valid MQTT remaining length."""
  4111. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  4112. # Use IPs of different lengths to test length re-encoding
  4113. old_ip = b"192.168.255.133" # 15 bytes
  4114. new_ip = b"10.0.0.1" # 8 bytes
  4115. payload = b'{"ip":"192.168.255.133"}'
  4116. packet = self._build_mqtt_publish("device/status", payload)
  4117. result, buf = TLSProxy._rewrite_mqtt_ip(packet, old_ip, new_ip, bytearray())
  4118. # Parse the result to verify framing
  4119. assert result[0] == 0x30 # PUBLISH header byte
  4120. # Decode remaining length
  4121. pos = 1
  4122. remaining = 0
  4123. multiplier = 1
  4124. while True:
  4125. b = result[pos]
  4126. pos += 1
  4127. remaining += (b & 0x7F) * multiplier
  4128. multiplier *= 128
  4129. if (b & 0x80) == 0:
  4130. break
  4131. # Remaining length should match actual data
  4132. assert pos + remaining == len(result)
  4133. assert new_ip in result
  4134. def test_incomplete_packet_buffered(self):
  4135. """Incomplete packet at end of chunk is buffered for next call."""
  4136. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  4137. payload = b'{"ip":"192.168.1.100"}'
  4138. packet = self._build_mqtt_publish("device/status", payload)
  4139. # Split packet in the middle
  4140. half = len(packet) // 2
  4141. chunk1 = packet[:half]
  4142. chunk2 = packet[half:]
  4143. result1, buf = TLSProxy._rewrite_mqtt_ip(chunk1, b"192.168.1.100", b"10.0.0.1", bytearray())
  4144. # First chunk should be buffered (incomplete packet)
  4145. assert len(buf) > 0
  4146. result2, buf = TLSProxy._rewrite_mqtt_ip(chunk2, b"192.168.1.100", b"10.0.0.1", buf)
  4147. # Second chunk completes the packet, IP should be rewritten
  4148. combined = result1 + result2
  4149. assert b"10.0.0.1" in combined
  4150. assert b"192.168.1.100" not in combined
  4151. def test_multiple_packets_in_one_chunk(self):
  4152. """Multiple MQTT packets in a single chunk are all processed."""
  4153. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  4154. payload1 = b'{"ip":"192.168.1.100"}'
  4155. payload2 = b'{"other":"data"}'
  4156. packet1 = self._build_mqtt_publish("topic1", payload1)
  4157. packet2 = self._build_mqtt_publish("topic2", payload2)
  4158. combined = packet1 + packet2
  4159. result, buf = TLSProxy._rewrite_mqtt_ip(combined, b"192.168.1.100", b"10.0.0.1", bytearray())
  4160. assert b"10.0.0.1" in result
  4161. assert b"192.168.1.100" not in result
  4162. # Second packet should still be present
  4163. assert b"other" in result
  4164. def test_extra_replacements(self):
  4165. """Extra replacement pairs (e.g. integer IP) are also applied."""
  4166. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  4167. payload = b'{"net":{"info":[{"ip":2248124608}]}}'
  4168. packet = self._build_mqtt_publish("device/status", payload)
  4169. result, buf = TLSProxy._rewrite_mqtt_ip(
  4170. packet,
  4171. b"NOMATCH",
  4172. b"NOREPLACE",
  4173. bytearray(),
  4174. extra_replacements=[(b"2248124608", b"285190336")],
  4175. )
  4176. assert b"285190336" in result
  4177. assert b"2248124608" not in result
  4178. class TestIpToLeIntBytes:
  4179. """Tests for TLSProxy._ip_to_le_int_bytes() integer IP conversion."""
  4180. def test_converts_ip_to_le_int(self):
  4181. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  4182. assert TLSProxy._ip_to_le_int_bytes("192.168.255.133") == b"2248124608"
  4183. assert TLSProxy._ip_to_le_int_bytes("192.168.255.16") == b"285190336"
  4184. assert TLSProxy._ip_to_le_int_bytes("10.0.0.1") == b"16777226"
  4185. def test_roundtrip(self):
  4186. """Verify the integer converts back to the correct IP."""
  4187. import struct
  4188. from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
  4189. for ip in ["192.168.1.1", "10.0.0.1", "172.16.0.100", "192.168.255.133"]:
  4190. le_int = int(TLSProxy._ip_to_le_int_bytes(ip))
  4191. parts = ip.split(".")
  4192. expected = struct.unpack("<I", bytes(int(p) for p in parts))[0]
  4193. assert le_int == expected
  4194. class TestSSDPProxyName:
  4195. """Tests for SSDPProxy VP name rewriting."""
  4196. @pytest.fixture
  4197. def ssdp_proxy_with_name(self):
  4198. from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
  4199. return SSDPProxy(
  4200. local_interface_ip="192.168.1.100",
  4201. remote_interface_ip="10.0.0.100",
  4202. target_printer_ip="192.168.1.50",
  4203. name="H2D-1 Proxy",
  4204. )
  4205. @pytest.fixture
  4206. def ssdp_proxy_without_name(self):
  4207. from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
  4208. return SSDPProxy(
  4209. local_interface_ip="192.168.1.100",
  4210. remote_interface_ip="10.0.0.100",
  4211. target_printer_ip="192.168.1.50",
  4212. )
  4213. def test_rewrite_uses_configured_name(self, ssdp_proxy_with_name):
  4214. """When name is set, DevName is replaced entirely."""
  4215. 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"
  4216. rewritten = ssdp_proxy_with_name._rewrite_ssdp(packet)
  4217. assert b"DevName.bambu.com: H2D-1 Proxy" in rewritten
  4218. assert b"RealPrinter" not in rewritten
  4219. def test_rewrite_appends_proxy_without_name(self, ssdp_proxy_without_name):
  4220. """When no name is set, ' - Proxy' is appended to the real name."""
  4221. 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"
  4222. rewritten = ssdp_proxy_without_name._rewrite_ssdp(packet)
  4223. assert b"DevName.bambu.com: RealPrinter - Proxy" in rewritten
  4224. class TestVPProjectFileStashKey:
  4225. """Regression: `on_print_command` MUST stash slicer options under the
  4226. FTP filename (`data["file"]`, with extension), NOT under `filename`
  4227. (the slicer's `subtask_name`, bare).
  4228. #1780 root cause (real bundle, 2026-06-21): BambuStudio sends
  4229. `subtask_name = "Model_Name"` (bare) and `file = "Model_Name.gcode.3mf"`
  4230. (with extension). `_add_to_print_queue` looks up the stash under
  4231. `file_path.name` from the FTP receive side, which always has the
  4232. extension. If the stash uses `subtask_name`, lookup misses → every
  4233. captured slicer field (bed_leveling, flow_cali, vibration_cali,
  4234. layer_inspect, timelapse, nozzle_mapping) silently falls back to
  4235. settings defaults on every Bambu Studio "Send" upload.
  4236. `filename` (subtask_name) must still flow to `_schedule_finish_release`
  4237. untouched — push_status echoes it back as gcode_file / subtask_name and
  4238. the slicer matches against its own local subtask_name there. So
  4239. `on_print_command` keeps `filename` for state-feedback but derives the
  4240. stash key from `data["file"]`.
  4241. """
  4242. @pytest.fixture
  4243. def instance(self, tmp_path):
  4244. from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
  4245. return VirtualPrinterInstance(
  4246. vp_id=99,
  4247. name="StashKeyTest",
  4248. mode="queue",
  4249. model="O1C2",
  4250. access_code="12345678",
  4251. serial_suffix="999999999",
  4252. base_dir=tmp_path,
  4253. )
  4254. @pytest.mark.asyncio
  4255. async def test_stash_key_uses_file_field_not_subtask_name(self, instance):
  4256. """BambuStudio's real wire shape: `subtask_name` ≠ `file`.
  4257. on_print_command must stash under `data["file"]` so the FTP-side
  4258. `_add_to_print_queue` lookup matches.
  4259. """
  4260. # mqtt_server.py:_handle_publish hands the bare subtask_name as
  4261. # `filename` and the full print_data body as `data`. The FTP filename
  4262. # lives in `data["file"]`.
  4263. await instance.on_print_command(
  4264. "Filament_Track_Switch_Holder", # subtask_name (bare)
  4265. {
  4266. "command": "project_file",
  4267. "subtask_name": "Filament_Track_Switch_Holder",
  4268. "file": "Filament_Track_Switch_Holder.gcode.3mf",
  4269. "nozzle_mapping": [16, -1, -1, 1],
  4270. },
  4271. )
  4272. # Stash MUST be under the FTP filename, not the bare subtask_name.
  4273. # `_add_to_print_queue` does `_slicer_print_options.pop(file_path.name, None)`
  4274. # where file_path.name == "Filament_Track_Switch_Holder.gcode.3mf".
  4275. assert "Filament_Track_Switch_Holder.gcode.3mf" in instance._slicer_print_options
  4276. assert "Filament_Track_Switch_Holder" not in instance._slicer_print_options
  4277. # Body must carry nozzle_mapping verbatim.
  4278. stashed = instance._slicer_print_options["Filament_Track_Switch_Holder.gcode.3mf"]
  4279. assert stashed["nozzle_mapping"] == [16, -1, -1, 1]
  4280. @pytest.mark.asyncio
  4281. async def test_stash_key_falls_back_to_filename_when_file_absent(self, instance):
  4282. """Defensive fallback: a slicer that omits the `file` field entirely
  4283. (legacy / non-3MF) must fall back to `filename` (subtask_name), not
  4284. leave the stash unkeyed."""
  4285. await instance.on_print_command(
  4286. "BareName",
  4287. {
  4288. "command": "project_file",
  4289. "subtask_name": "BareName",
  4290. # no "file" field
  4291. },
  4292. )
  4293. assert "BareName" in instance._slicer_print_options
  4294. @pytest.mark.asyncio
  4295. async def test_stash_key_signals_event_under_file_key(self, instance):
  4296. """`_add_to_print_queue` registers a wait-event under `file_path.name`
  4297. when the slicer's project_file arrives late. on_print_command must
  4298. signal THAT event (keyed by the FTP filename), not one keyed by
  4299. subtask_name — else the waiter times out even though the stash is
  4300. present and addressable."""
  4301. import asyncio
  4302. ftp_filename = "Filament_Track_Switch_Holder.gcode.3mf"
  4303. event = asyncio.Event()
  4304. instance._slicer_print_options_events[ftp_filename] = event
  4305. await instance.on_print_command(
  4306. "Filament_Track_Switch_Holder", # bare subtask_name
  4307. {
  4308. "command": "project_file",
  4309. "subtask_name": "Filament_Track_Switch_Holder",
  4310. "file": ftp_filename,
  4311. },
  4312. )
  4313. # Event keyed by FTP filename must fire even though on_print_command
  4314. # was called with the bare subtask_name.
  4315. assert event.is_set()