| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135 |
- """Unit tests for Virtual Printer services.
- Tests the virtual printer manager, FTP server, and SSDP server components.
- """
- import asyncio
- import json
- import zipfile
- from pathlib import Path
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- def _write_3mf_with_filaments(file_path: Path, filaments: list[dict], plate_index: int = 1) -> None:
- """Build a minimal 3MF zip with `Metadata/slice_info.config` carrying the
- given per-slot filament entries. Each `filaments` dict needs `id`, `type`,
- `color`, `used_g`. Used by the #1188 VP queue-mode tests below."""
- filament_xml = "".join(
- f'<filament id="{f["id"]}" type="{f["type"]}" color="{f["color"]}" '
- f'used_g="{f["used_g"]}" tray_info_idx="{f.get("tray_info_idx", "")}"/>'
- for f in filaments
- )
- config = (
- '<?xml version="1.0" encoding="utf-8"?>'
- "<config>"
- f'<plate><metadata key="index" value="{plate_index}"/>'
- f"{filament_xml}"
- "</plate>"
- "</config>"
- )
- with zipfile.ZipFile(file_path, "w") as zf:
- zf.writestr("Metadata/slice_info.config", config)
- # Plate gcode is referenced for plate-id detection in the VP path —
- # presence is enough; contents don't matter.
- zf.writestr(f"Metadata/plate_{plate_index}.gcode", "; gcode\n")
- class TestVirtualPrinterInstance:
- """Tests for VirtualPrinterInstance class."""
- @pytest.fixture
- def instance(self, tmp_path):
- """Create a VirtualPrinterInstance with test defaults."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- return VirtualPrinterInstance(
- vp_id=1,
- name="TestPrinter",
- mode="archive",
- model="C11",
- access_code="12345678",
- serial_suffix="391800001",
- base_dir=tmp_path,
- )
- # ========================================================================
- # Tests for instance properties
- # ========================================================================
- def test_instance_stores_parameters(self, instance):
- """Verify constructor stores parameters correctly."""
- assert instance.id == 1
- assert instance.name == "TestPrinter"
- assert instance.mode == "archive"
- assert instance.model == "C11"
- assert instance.access_code == "12345678"
- assert instance.serial_suffix == "391800001"
- def test_instance_serial_property(self, instance):
- """Verify serial is generated from model prefix + suffix."""
- # C11 = P1P, prefix = 01S00A
- assert instance.serial == "01S00A391800001"
- def test_instance_serial_x1c(self, tmp_path):
- """Verify X1C serial uses correct prefix."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=2,
- name="X1C",
- mode="archive",
- model="BL-P001",
- access_code="12345678",
- serial_suffix="391800002",
- base_dir=tmp_path,
- )
- assert inst.serial == "00M00A391800002"
- def test_instance_is_proxy_false(self, instance):
- """Verify is_proxy is False for non-proxy mode."""
- assert instance.is_proxy is False
- def test_instance_is_proxy_true(self, tmp_path):
- """Verify is_proxy is True for proxy mode."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=3,
- name="Proxy",
- mode="proxy",
- model="C11",
- access_code="",
- serial_suffix="391800003",
- target_printer_ip="192.168.1.100",
- base_dir=tmp_path,
- )
- assert inst.is_proxy is True
- def test_instance_is_running_with_active_tasks(self, instance):
- """Verify is_running is True when tasks are active."""
- mock_task = MagicMock()
- mock_task.done.return_value = False
- instance._tasks = [mock_task]
- assert instance.is_running is True
- def test_instance_is_running_with_no_tasks(self, instance):
- """Verify is_running is False when no tasks."""
- assert instance.is_running is False
- def test_instance_creates_directories(self, instance, tmp_path):
- """Verify instance creates upload and cert directories."""
- assert (tmp_path / "uploads" / "1").exists()
- assert (tmp_path / "uploads" / "1" / "cache").exists()
- assert (tmp_path / "certs" / "1").exists()
- # ========================================================================
- # Tests for status
- # ========================================================================
- def test_get_status_returns_correct_format(self, instance):
- """Verify get_status returns expected fields."""
- instance._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")} # nosec B108
- mock_task = MagicMock(done=MagicMock(return_value=False))
- instance._tasks = [mock_task]
- status = instance.get_status()
- assert status["running"] is True
- assert status["pending_files"] == 1
- def test_get_status_not_running(self, instance):
- """Verify get_status when no tasks."""
- status = instance.get_status()
- assert status["running"] is False
- assert status["pending_files"] == 0
- # ========================================================================
- # Tests for file handling
- # ========================================================================
- @pytest.mark.asyncio
- async def test_on_file_received_adds_to_pending(self, instance):
- """Verify received file is added to pending list in review mode."""
- instance.mode = "review"
- file_path = Path("/tmp/test.3mf") # nosec B108
- with patch.object(instance, "_queue_file", new_callable=AsyncMock) as mock_queue:
- await instance.on_file_received(file_path, "192.168.1.100")
- assert "test.3mf" in instance._pending_files
- mock_queue.assert_called_once()
- @pytest.mark.asyncio
- async def test_on_file_received_archives_immediately(self, instance):
- """Verify file is archived in immediate mode."""
- file_path = Path("/tmp/test.3mf") # nosec B108
- with patch.object(instance, "_archive_file", new_callable=AsyncMock) as mock_archive:
- await instance.on_file_received(file_path, "192.168.1.100")
- mock_archive.assert_called_once_with(file_path, "192.168.1.100")
- @pytest.mark.asyncio
- async def test_on_file_received_signals_FINISH_to_slicer(self, instance):
- """Regression #1280: when a slicer's Print flow uploads to a non-proxy VP,
- the VP must transition gcode_state PREPARE → FINISH so the slicer's
- in-flight-job lock releases. Going PREPARE → IDLE wedges Orca at
- "Downloading...(0%)" and blocks the next dispatch with "busy with
- another print job".
- Send-flow slicers don't watch the post-upload state, so this is a
- no-op behavior change for them.
- """
- instance.mode = "archive"
- instance._mqtt = MagicMock()
- instance._mqtt.set_gcode_state = MagicMock()
- file_path = Path("/tmp/test.3mf") # nosec B108
- with patch.object(instance, "_archive_file", new_callable=AsyncMock):
- await instance.on_file_received(file_path, "192.168.1.100")
- instance._mqtt.set_gcode_state.assert_called_once_with("FINISH", filename="test.3mf", prepare_percent="100")
- @pytest.mark.asyncio
- async def test_on_file_received_non_3mf_does_not_touch_state(self, instance):
- """Non-3MF uploads (e.g., a job's auxiliary files) must not transition
- the visible state — the slicer is only tracking the .3mf upload."""
- instance.mode = "archive"
- instance._mqtt = MagicMock()
- instance._mqtt.set_gcode_state = MagicMock()
- file_path = Path("/tmp/test.gcode") # nosec B108
- with patch.object(instance, "_archive_file", new_callable=AsyncMock):
- await instance.on_file_received(file_path, "192.168.1.100")
- instance._mqtt.set_gcode_state.assert_not_called()
- @pytest.mark.asyncio
- async def test_on_print_command_schedules_finish_release_non_proxy(self, instance):
- """#1658: Bambu Studio 2.7.x flipped the slicer's Send flow to
- FTP → FTP → MQTT project_file. Under that order the synthetic
- project_file ack overwrites the FINISH set by #1280 in
- ``on_file_received`` back to PREPARE, leaving the slicer's
- "Downloading" modal stuck forever. Re-firing FINISH a moment after
- the ack releases the modal.
- """
- instance.mode = "archive"
- instance._mqtt = MagicMock()
- instance._mqtt.set_gcode_state = MagicMock()
- with patch.object(instance, "_delayed_finish_release", new_callable=AsyncMock) as mock_delayed:
- await instance.on_print_command("test.3mf", {"command": "project_file"})
- mock_delayed.assert_called_once()
- # First positional arg should be the filename; second is the delay seconds.
- args = mock_delayed.call_args.args
- assert args[0] == "test.3mf"
- assert isinstance(args[1], int | float)
- @pytest.mark.asyncio
- async def test_on_print_command_proxy_mode_does_not_reschedule_finish(self, instance):
- """Proxy mode hands push_status straight from the real printer through
- the bridge. Re-firing a synthetic FINISH would clobber a real
- PREPARE / RUNNING transition coming back from the printer, so the
- scheduler is exempt for proxy mode."""
- instance.mode = "proxy"
- instance._mqtt = MagicMock()
- with patch.object(instance, "_delayed_finish_release", new_callable=AsyncMock) as mock_delayed:
- await instance.on_print_command("test.3mf", {"command": "project_file"})
- mock_delayed.assert_not_called()
- @pytest.mark.asyncio
- async def test_on_print_command_no_mqtt_does_not_schedule(self, instance):
- """If the MQTT server isn't running yet (transient race during boot),
- the scheduler must skip silently — no AttributeError, no orphan task."""
- instance.mode = "queue"
- instance._mqtt = None
- # Should not raise.
- await instance.on_print_command("test.3mf", {"command": "project_file"})
- assert instance._finish_release_task is None
- @pytest.mark.asyncio
- async def test_schedule_finish_release_cancels_previous_timer(self, instance):
- """A slicer that fires project_file twice (e.g. retry after a transient
- FTP hiccup) must only result in one FINISH transition — the earlier
- in-flight timer is cancelled when the next one is scheduled."""
- instance.mode = "queue"
- instance._mqtt = MagicMock()
- instance._schedule_finish_release("first.3mf", delay=10.0)
- first_task = instance._finish_release_task
- assert first_task is not None
- instance._schedule_finish_release("second.3mf", delay=10.0)
- second_task = instance._finish_release_task
- assert second_task is not first_task
- # Give the loop one tick so the cancelled task settles.
- await asyncio.sleep(0)
- assert first_task.cancelled() or first_task.done()
- # Clean up the still-pending second task so the test doesn't leak it.
- second_task.cancel()
- try:
- await second_task
- except asyncio.CancelledError:
- pass
- @pytest.mark.asyncio
- async def test_delayed_finish_release_sets_finish_state(self, instance):
- """End-to-end: after the delay elapses, set_gcode_state is called with
- FINISH and prepare_percent=100, matching the wire-format the slicer's
- Print flow consumes to release "Downloading"."""
- instance._mqtt = MagicMock()
- instance._mqtt.set_gcode_state = MagicMock()
- await instance._delayed_finish_release("queued.3mf", delay=0.0)
- instance._mqtt.set_gcode_state.assert_called_once_with("FINISH", filename="queued.3mf", prepare_percent="100")
- @pytest.mark.asyncio
- async def test_on_print_command_no_filename_does_not_schedule(self, instance):
- """A project_file command without a subtask_name (defensive — real
- slicers always send one) must not schedule a no-op FINISH that would
- carry an empty filename on the next 1 Hz push."""
- instance.mode = "queue"
- instance._mqtt = MagicMock()
- with patch.object(instance, "_delayed_finish_release", new_callable=AsyncMock) as mock_delayed:
- await instance.on_print_command("", {"command": "project_file"})
- mock_delayed.assert_not_called()
- @pytest.mark.asyncio
- async def test_archive_file_skips_non_3mf(self, instance):
- """Verify non-3MF files are skipped and cleaned up."""
- instance._session_factory = MagicMock()
- instance._pending_files["verify_job"] = Path("/tmp/verify_job") # nosec B108
- with patch("pathlib.Path.unlink"):
- await instance._archive_file(Path("/tmp/verify_job"), "192.168.1.100") # nosec B108
- assert "verify_job" not in instance._pending_files
- @pytest.mark.asyncio
- async def test_archive_file_broadcasts_archive_created(self, tmp_path):
- """#1282: VP immediate-mode archives must broadcast archive_created so
- the Archives page refreshes without a tab switch. Real-printer prints
- get this via main.py's MQTT print_start handler; the VP path used to
- skip the broadcast entirely."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- mock_db = AsyncMock()
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=30,
- name="ImmediateBroadcast",
- mode="archive",
- model="C12",
- access_code="12345678",
- serial_suffix="391800030",
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- mock_archive = MagicMock()
- mock_archive.id = 99
- mock_archive.printer_id = None
- mock_archive.filename = "test.3mf"
- mock_archive.print_name = "test"
- mock_archive.status = "archived"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- patch(
- "backend.app.core.websocket.ws_manager.send_archive_created",
- new_callable=AsyncMock,
- ) as mock_broadcast,
- ):
- await inst._archive_file(file_path, "192.168.1.100")
- mock_broadcast.assert_awaited_once()
- payload = mock_broadcast.await_args.args[0]
- assert payload["id"] == 99
- assert payload["filename"] == "test.3mf"
- assert payload["status"] == "archived"
- # ========================================================================
- # Tests for auto_dispatch
- # ========================================================================
- def test_auto_dispatch_defaults_to_true(self, tmp_path):
- """Verify auto_dispatch defaults to True when not specified."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=10,
- name="DefaultDispatch",
- mode="queue",
- model="C11",
- access_code="12345678",
- serial_suffix="391800010",
- base_dir=tmp_path,
- )
- assert inst.auto_dispatch is True
- @pytest.mark.asyncio
- async def test_add_to_print_queue_with_auto_dispatch_on(self, tmp_path):
- """Verify queue items have manual_start=False when auto_dispatch=True."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- mock_db = AsyncMock()
- added_items = []
- def capture_add(item):
- added_items.append(item)
- mock_db.add = MagicMock(side_effect=capture_add)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=11,
- name="AutoDispatchOn",
- mode="queue",
- model="C11",
- access_code="12345678",
- serial_suffix="391800011",
- auto_dispatch=True,
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- # Create a temp 3mf file
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "test"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- queue_item = added_items[0]
- assert queue_item.manual_start is False
- @pytest.mark.asyncio
- async def test_add_to_print_queue_broadcasts_archive_created(self, tmp_path):
- """#1282: VP queue-mode uploads must broadcast archive_created so the
- Archives page picks up the new entry live. Pre-fix the page only
- refreshed when the user manually switched tabs."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- mock_db = AsyncMock()
- mock_db.add = MagicMock()
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=31,
- name="QueueBroadcast",
- mode="queue",
- model="C12",
- access_code="12345678",
- serial_suffix="391800031",
- auto_dispatch=True,
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- mock_archive = MagicMock()
- mock_archive.id = 77
- mock_archive.printer_id = None
- mock_archive.filename = "test.3mf"
- mock_archive.print_name = "test"
- mock_archive.status = "archived"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- patch(
- "backend.app.core.websocket.ws_manager.send_archive_created",
- new_callable=AsyncMock,
- ) as mock_broadcast,
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- mock_broadcast.assert_awaited_once()
- payload = mock_broadcast.await_args.args[0]
- assert payload["id"] == 77
- assert payload["print_name"] == "test"
- assert payload["status"] == "archived"
- @pytest.mark.asyncio
- async def test_add_to_print_queue_with_auto_dispatch_off(self, tmp_path):
- """Verify queue items have manual_start=True when auto_dispatch=False."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- mock_db = AsyncMock()
- added_items = []
- def capture_add(item):
- added_items.append(item)
- mock_db.add = MagicMock(side_effect=capture_add)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=12,
- name="AutoDispatchOff",
- mode="queue",
- model="C11",
- access_code="12345678",
- serial_suffix="391800012",
- auto_dispatch=False,
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- # Create a temp 3mf file
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "test"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- queue_item = added_items[0]
- assert queue_item.manual_start is True
- @pytest.mark.asyncio
- async def test_add_to_print_queue_gcode_injection_on(self, tmp_path):
- """#1516: queue items opt into injection when the VP has gcode_injection=True."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- mock_db = AsyncMock()
- added_items = []
- mock_db.add = MagicMock(side_effect=added_items.append)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=13,
- name="InjectOn",
- mode="queue",
- model="C11",
- access_code="12345678",
- serial_suffix="391800013",
- gcode_injection=True,
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "test"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- assert added_items[0].gcode_injection is True
- @pytest.mark.asyncio
- async def test_add_to_print_queue_gcode_injection_off_by_default(self, tmp_path):
- """#1516: queue items do NOT inject when the VP leaves gcode_injection at its default."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- mock_db = AsyncMock()
- added_items = []
- mock_db.add = MagicMock(side_effect=added_items.append)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=14,
- name="InjectOff",
- mode="queue",
- model="C11",
- access_code="12345678",
- serial_suffix="391800014",
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "test"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- assert added_items[0].gcode_injection is False
- @pytest.mark.asyncio
- async def test_add_to_print_queue_uses_workflow_defaults_from_settings(self, tmp_path):
- """#1235: VP queue-mode constructed PrintQueueItem without specifying
- bed_levelling / flow_cali / vibration_cali / layer_inspect / timelapse,
- so SQLAlchemy applied the column-level defaults and ignored the user's
- workflow preferences entirely. Every print sent from the slicer to the
- VP came through with the OPPOSITE of what the workflow page said,
- forcing the user to edit each queue item by hand.
- """
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- added_items = []
- mock_db = AsyncMock()
- mock_db.add = MagicMock(side_effect=added_items.append)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=22,
- name="DefaultsTest",
- mode="queue",
- model="C12",
- access_code="12345678",
- serial_suffix="391800022",
- auto_dispatch=True,
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- # The reporter set every workflow default to the OPPOSITE of the model's
- # column default. Pre-fix the column defaults won; with the fix the
- # settings values must flow through to the queue item exactly as stored.
- settings_map = {
- "virtual_printer_archive_name_source": None,
- # Legacy boolean-string rows still coerce (false->off, true->on).
- "default_bed_levelling": "false", # tri-state default: auto
- "default_flow_cali": "true", # tri-state default: auto
- "default_vibration_cali": "false", # model default: True
- "default_layer_inspect": "true", # model default: False
- "default_timelapse": "true", # model default: False
- }
- async def fake_get_setting(_db, key):
- return settings_map.get(key)
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "test"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new=fake_get_setting,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- queue_item = added_items[0]
- assert queue_item.bed_levelling == "off", "default_bed_levelling=false must flow through"
- assert queue_item.flow_cali == "on", "default_flow_cali=true must flow through"
- assert queue_item.vibration_cali is False, "default_vibration_cali=false must flow through"
- assert queue_item.layer_inspect is True, "default_layer_inspect=true must flow through"
- assert queue_item.timelapse is True, "default_timelapse=true must flow through"
- @pytest.mark.asyncio
- async def test_add_to_print_queue_falls_back_to_schema_defaults_when_unset(self, tmp_path):
- """#1235 fallback: when no workflow setting is in the DB, the queue
- item should use the AppSettings (Pydantic) defaults — same values
- the user sees in the workflow page on a fresh install.
- """
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- added_items = []
- mock_db = AsyncMock()
- mock_db.add = MagicMock(side_effect=added_items.append)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=23,
- name="FreshInstallDefaults",
- mode="queue",
- model="C12",
- access_code="12345678",
- serial_suffix="391800023",
- auto_dispatch=True,
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "test"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None, # No settings → fall back to schema defaults
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- queue_item = added_items[0]
- # These must match the AppSettings (Pydantic) defaults in schemas/settings.py
- assert queue_item.bed_levelling == "auto"
- assert queue_item.flow_cali == "auto"
- assert queue_item.vibration_cali is True
- assert queue_item.layer_inspect is False
- assert queue_item.timelapse is False
- @pytest.mark.asyncio
- async def test_add_to_print_queue_inherits_slicer_print_options(self, tmp_path):
- """#1403: VP-queue items used to fall back to `default_timelapse` even
- though the slicer's MQTT `project_file` command carries the user's
- actual choice. Capture-via-`on_print_command` flow lets the user's
- slicer toggle reach the queue item.
- Settings here have timelapse OFF; the slicer's MQTT capture has it ON.
- After the fix the queue item must reflect the slicer's choice.
- """
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- added_items = []
- mock_db = AsyncMock()
- mock_db.add = MagicMock(side_effect=added_items.append)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=24,
- name="SlicerInherits",
- mode="queue",
- model="C12",
- access_code="12345678",
- serial_suffix="391800024",
- auto_dispatch=True,
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- # Pre-populate the capture as if MQTT `project_file` arrived already.
- # Settings (below) deliberately have timelapse OFF — only the slicer
- # capture should drive the resulting queue item.
- await inst.on_print_command(
- file_path.name,
- {
- "command": "project_file",
- "timelapse": True,
- "bed_leveling": False, # Note: MQTT field is single-L `bed_leveling`
- "flow_cali": True,
- "vibration_cali": False,
- "layer_inspect": True,
- },
- )
- settings_map = {
- "virtual_printer_archive_name_source": None,
- "default_bed_levelling": "true",
- "default_flow_cali": "false",
- "default_vibration_cali": "true",
- "default_layer_inspect": "false",
- "default_timelapse": "false",
- }
- async def fake_get_setting(_db, key):
- return settings_map.get(key)
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "test"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new=fake_get_setting,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- queue_item = added_items[0]
- assert queue_item.timelapse is True, "Slicer's timelapse=True must override settings.default_timelapse=False"
- assert queue_item.bed_levelling == "off", "Slicer's bed_leveling=False must override default_bed_levelling"
- assert queue_item.flow_cali == "on"
- assert queue_item.vibration_cali is False
- assert queue_item.layer_inspect is True
- # Capture is consumed — no lingering state for the next print of the same name.
- assert file_path.name not in inst._slicer_print_options
- @pytest.mark.asyncio
- async def test_add_to_print_queue_coerces_slicer_integer_zero_one(self, tmp_path):
- """#1403: H-family firmwares carry calibration flags as integers
- (0/1) rather than booleans. The capture must coerce both shapes so
- H-family-sliced jobs through the VP queue work the same as P1/X1.
- """
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- added_items = []
- mock_db = AsyncMock()
- mock_db.add = MagicMock(side_effect=added_items.append)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=25,
- name="SlicerIntegers",
- mode="queue",
- model="C12",
- access_code="12345678",
- serial_suffix="391800025",
- auto_dispatch=True,
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- await inst.on_print_command(
- file_path.name,
- {"command": "project_file", "timelapse": 1, "bed_leveling": 0, "flow_cali": 1},
- )
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "test"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- queue_item = added_items[0]
- assert queue_item.timelapse is True, "integer 1 must coerce to True"
- assert queue_item.bed_levelling == "off", "integer 0 must coerce to off"
- assert queue_item.flow_cali == "on"
- @pytest.mark.asyncio
- async def test_add_to_print_queue_captures_slicer_auto_from_int_companion(self, tmp_path):
- """The slicer's tri-state rides on the int companion (auto_bed_leveling /
- extrude_cali_flag). When the slicer picks "Auto" it sends bed_leveling
- false + auto_bed_leveling 2; the VP must record "auto", not "off".
- """
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- added_items = []
- mock_db = AsyncMock()
- mock_db.add = MagicMock(side_effect=added_items.append)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=26,
- name="SlicerAuto",
- mode="queue",
- model="C12",
- access_code="12345678",
- serial_suffix="391800026",
- auto_dispatch=True,
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- await inst.on_print_command(
- file_path.name,
- {
- "command": "project_file",
- "bed_leveling": False,
- "auto_bed_leveling": 2,
- "flow_cali": False,
- "extrude_cali_flag": 2,
- },
- )
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "test"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- queue_item = added_items[0]
- assert queue_item.bed_levelling == "auto", "auto_bed_leveling=2 must record 'auto'"
- assert queue_item.flow_cali == "auto", "extrude_cali_flag=2 must record 'auto'"
- @pytest.mark.asyncio
- async def test_add_to_print_queue_populates_required_filament_types(self, tmp_path):
- """#1188: VP queue-mode used to create PrintQueueItems with no
- filament fields, so the scheduler fell through to model-only matching
- and dispatched onto whatever printer was free regardless of loaded
- colour. ``required_filament_types`` is populated unconditionally
- (cheap, helps the scheduler validate type even without
- ``force_color_match``) — pin that contract here."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- added_items = []
- mock_db = AsyncMock()
- mock_db.add = MagicMock(side_effect=added_items.append)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=21,
- name="Reqs",
- mode="queue",
- model="C12",
- access_code="12345678",
- serial_suffix="391800021",
- auto_dispatch=True,
- queue_force_color_match=False, # off → only required_filament_types
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "multi.3mf"
- _write_3mf_with_filaments(
- file_path,
- [
- {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "12.3"},
- {"id": "2", "type": "PETG", "color": "#000000", "used_g": "4.5"},
- # used_g=0 → not actually consumed by this plate, must be ignored
- {"id": "3", "type": "ABS", "color": "#FF0000", "used_g": "0"},
- ],
- plate_index=1,
- )
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "multi"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- queue_item = added_items[0]
- # Type-only fallback always populated. Sorted, deduped, no zero-use ABS.
- assert queue_item.required_filament_types is not None
- assert json.loads(queue_item.required_filament_types) == ["PETG", "PLA"]
- # Setting off → no force_color_match overrides leaked.
- assert queue_item.filament_overrides is None
- @pytest.mark.asyncio
- async def test_add_to_print_queue_force_color_match_writes_overrides(self, tmp_path):
- """#1188 core fix: when the per-VP ``queue_force_color_match`` toggle
- is on, every consumed slot lands as a ``filament_overrides`` entry
- with ``force_color_match: true``. This is the field the scheduler
- keys on (``print_scheduler.py:512``) — without it, slot-by-slot
- type+color matching never runs."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- added_items = []
- mock_db = AsyncMock()
- mock_db.add = MagicMock(side_effect=added_items.append)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=22,
- name="ForceColor",
- mode="queue",
- model="C12",
- access_code="12345678",
- serial_suffix="391800022",
- auto_dispatch=True,
- queue_force_color_match=True, # on
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "forced.3mf"
- _write_3mf_with_filaments(
- file_path,
- [
- {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "10.0"},
- {"id": "2", "type": "PLA", "color": "#FF00FF", "used_g": "5.0"},
- ],
- plate_index=1,
- )
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "forced"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- queue_item = added_items[0]
- assert queue_item.filament_overrides is not None
- overrides = json.loads(queue_item.filament_overrides)
- assert overrides == [
- {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "", "force_color_match": True},
- {"slot_id": 2, "type": "PLA", "color": "#FF00FF", "tray_info_idx": "", "force_color_match": True},
- ]
- # required_filament_types still populated alongside overrides.
- assert json.loads(queue_item.required_filament_types) == ["PLA"]
- @pytest.mark.asyncio
- async def test_add_to_print_queue_force_color_match_carries_tray_info_idx(self, tmp_path):
- """#2650: the force override must carry the 3MF's ``tray_info_idx`` so the
- scheduler can tell Bambu PLA variants apart (Basic GFA00 / Matte GFA01 /
- Silk GFA06) — they all report ``tray_type == "PLA"`` with the same colour,
- so type+colour alone dispatches onto the wrong variant."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- added_items = []
- mock_db = AsyncMock()
- mock_db.add = MagicMock(side_effect=added_items.append)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=24,
- name="Variant",
- mode="queue",
- model="C12",
- access_code="12345678",
- serial_suffix="391800024",
- auto_dispatch=True,
- queue_force_color_match=True,
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "variant.3mf"
- _write_3mf_with_filaments(
- file_path,
- [
- # White PLA Matte — same colour as Basic/Silk, distinguished only by idx.
- {"id": "1", "type": "PLA", "color": "#FFFFFF", "used_g": "10.0", "tray_info_idx": "GFA01"},
- ],
- plate_index=1,
- )
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "variant"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- overrides = json.loads(added_items[0].filament_overrides)
- assert overrides == [
- {"slot_id": 1, "type": "PLA", "color": "#FFFFFF", "tray_info_idx": "GFA01", "force_color_match": True},
- ]
- @pytest.mark.asyncio
- async def test_add_to_print_queue_force_color_match_skips_when_3mf_unparseable(self, tmp_path):
- """A malformed or fake-bytes 3MF must not crash the upload path —
- we just write the queue item with no filament fields and let the
- scheduler fall back to model-only matching (the pre-#1188 default).
- Regression guard for the existing fake-bytes happy-path tests."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- added_items = []
- mock_db = AsyncMock()
- mock_db.add = MagicMock(side_effect=added_items.append)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=23,
- name="Unparseable",
- mode="queue",
- model="C12",
- access_code="12345678",
- serial_suffix="391800023",
- auto_dispatch=True,
- queue_force_color_match=True,
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "bad.3mf"
- file_path.write_bytes(b"not a real 3mf zip")
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "bad"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- queue_item = added_items[0]
- # No filament data extractable → both fields stay None (graceful
- # fallback to model-only scheduling).
- assert queue_item.required_filament_types is None
- assert queue_item.filament_overrides is None
- # ========================================================================
- # Tests for archive_name_source setting (#1152)
- # ========================================================================
- @pytest.mark.asyncio
- @pytest.mark.parametrize(
- ("setting_value", "expected_prefer_filename"),
- [
- ("filename", True),
- ("metadata", False),
- (None, False), # Default when setting unset
- ("", False), # Defensive: empty string is not "filename"
- ],
- )
- async def test_archive_file_passes_prefer_filename_per_setting(
- self, tmp_path, setting_value, expected_prefer_filename
- ):
- """_archive_file reads `virtual_printer_archive_name_source` and forwards
- prefer_filename_for_name=True only when it equals 'filename' (#1152)."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- mock_db = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=20,
- name="NameSource",
- mode="archive",
- model="C11",
- access_code="12345678",
- serial_suffix="391800020",
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "user-renamed-job.3mf"
- file_path.write_bytes(b"fake3mf")
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "user-renamed-job"
- archive_print_mock = AsyncMock(return_value=mock_archive)
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=setting_value,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- archive_print_mock,
- ),
- ):
- await inst._archive_file(file_path, "192.168.1.100")
- assert archive_print_mock.await_count == 1
- kwargs = archive_print_mock.await_args.kwargs
- assert kwargs.get("prefer_filename_for_name") is expected_prefer_filename
- # ========================================================================
- # Tests for failure-path cleanup (#audit-R2-1)
- # ========================================================================
- #
- # All three file handlers (_archive_file, _queue_file, _add_to_print_queue)
- # previously only popped _pending_files and unlinked the temp file on the
- # success branch. Failure paths leaked the marker (blocking same-name
- # retries via the FTP layer) and the temp file on disk. The cleanup must
- # ALWAYS run, even when archival / queue insert raises.
- @pytest.mark.asyncio
- async def test_archive_file_failure_path_pops_pending_and_unlinks(self, tmp_path):
- """When the archive layer raises, `_pending_files[filename]` must still
- be popped and the temp file must be unlinked. Otherwise the FTP layer's
- same-name retry guard would silently reject the slicer's next attempt
- and the upload_dir would accumulate ghost files."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- mock_db = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=40,
- name="ArchiveFailCleanup",
- mode="archive",
- model="C12",
- access_code="12345678",
- serial_suffix="391800040",
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "cleanup-archive.3mf"
- file_path.write_bytes(b"fake3mf")
- inst._pending_files[file_path.name] = file_path
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- side_effect=RuntimeError("archive blew up"),
- ),
- ):
- await inst._archive_file(file_path, "192.168.1.100")
- assert file_path.name not in inst._pending_files
- assert not file_path.exists()
- @pytest.mark.asyncio
- async def test_queue_file_failure_path_pops_pending_and_unlinks(self, tmp_path):
- """Same invariant for _queue_file: a DB error during PendingUpload
- insert must not leak the in-flight marker or the temp file."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- mock_db = AsyncMock()
- # Commit raises — emulating a DB connectivity error.
- mock_db.add = MagicMock()
- mock_db.commit = AsyncMock(side_effect=RuntimeError("db unreachable"))
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=41,
- name="QueueFailCleanup",
- mode="review",
- model="C12",
- access_code="12345678",
- serial_suffix="391800041",
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "cleanup-queue.3mf"
- file_path.write_bytes(b"fake3mf")
- inst._pending_files[file_path.name] = file_path
- await inst._queue_file(file_path, "192.168.1.100")
- assert file_path.name not in inst._pending_files
- assert not file_path.exists()
- @pytest.mark.asyncio
- async def test_add_to_print_queue_failure_path_pops_pending_and_unlinks(self, tmp_path):
- """Same invariant for _add_to_print_queue: a DB error or archive
- failure must not leak the in-flight marker or the temp file."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- mock_db = AsyncMock()
- mock_db.add = MagicMock()
- mock_db.commit = AsyncMock()
- mock_db.execute = AsyncMock(side_effect=RuntimeError("queue insert blew up"))
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=42,
- name="DispatchFailCleanup",
- mode="queue",
- model="C12",
- access_code="12345678",
- serial_suffix="391800042",
- auto_dispatch=True,
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "cleanup-dispatch.3mf"
- file_path.write_bytes(b"fake3mf")
- inst._pending_files[file_path.name] = file_path
- with patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert file_path.name not in inst._pending_files
- assert not file_path.exists()
- # ========================================================================
- # Test for position=MAX+1 (audit-R2)
- # ========================================================================
- @pytest.mark.asyncio
- async def test_add_to_print_queue_position_picks_max_plus_one(self, tmp_path):
- """VP-queue items previously got hardcoded `position=1`, colliding
- with existing items at position 1 and producing non-deterministic
- execution order. Now the position is chosen by `MAX(position)+1`
- against the target queue, matching the canonical `POST /print-queue/`
- path."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- # Capture the inserted PrintQueueItem so we can assert on .position.
- added_items: list = []
- class _RecordingDb:
- def __init__(self):
- self.add = lambda item: added_items.append(item)
- self.commit = AsyncMock()
- async def execute(self, query): # noqa: ARG002
- """Return a stub result whose `.scalar()` reports the existing
- MAX(position) for the target. Returning 7 means the new item
- should land at 8."""
- result = MagicMock()
- result.scalar = MagicMock(return_value=7)
- return result
- mock_db = _RecordingDb()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=43,
- name="PositionMaxPlusOne",
- mode="queue",
- model="C12",
- access_code="12345678",
- serial_suffix="391800043",
- target_printer_id=99,
- auto_dispatch=True,
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "next-position.3mf"
- file_path.write_bytes(b"fake3mf")
- mock_archive = MagicMock()
- mock_archive.id = 555
- mock_archive.printer_id = None
- mock_archive.filename = "next-position.3mf"
- mock_archive.print_name = "next-position"
- mock_archive.status = "archived"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- patch(
- "backend.app.core.websocket.ws_manager.send_archive_created",
- new_callable=AsyncMock,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- # One queue item was added.
- assert len(added_items) == 1
- queue_item = added_items[0]
- # Position = max(7) + 1 = 8 — NOT the legacy hardcoded 1.
- assert queue_item.position == 8
- @pytest.mark.asyncio
- async def test_add_to_print_queue_multi_plate_send_all_enqueues_one_per_plate(self, tmp_path):
- """#1733: BambuStudio / OrcaSlicer "Send All" of a multi-plate project
- uploads ONE 3MF containing every plate. Pre-fix only the first plate
- index was extracted and one queue item was created; plates 2..N were
- silently dropped. Post-fix every `<plate>` block in `slice_info.config`
- produces its own PrintQueueItem with the correct ``plate_id``, sharing
- the same backing archive, with consecutive positions for plate-order
- execution.
- """
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- added_items: list = []
- class _RecordingDb:
- def __init__(self):
- # Capture inserted items as they're added; assign a fake .id
- # on flush so the manager's logger doesn't see None.
- self._next_id = 1000
- def _add(item):
- added_items.append(item)
- self.add = _add
- self.commit = AsyncMock()
- async def execute(self, query): # noqa: ARG002
- """Return MAX(position) = 0 so plate items land at 1, 2, 3."""
- result = MagicMock()
- result.scalar = MagicMock(return_value=0)
- return result
- async def flush(self):
- # Mimic the FK populate so queue_item.id is available after add().
- for item in added_items:
- if getattr(item, "id", None) is None:
- item.id = self._next_id
- self._next_id += 1
- mock_db = _RecordingDb()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=44,
- name="MultiPlateSendAll",
- mode="queue",
- model="O1D", # H2D — matches the live VP H2D-1 Proxy in #1733
- access_code="12345678",
- serial_suffix="391800044",
- target_printer_id=1,
- auto_dispatch=False, # manual_start, mirrors the live VP
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- # Build a 3MF with three plates baked into slice_info.config —
- # mirrors what BambuStudio / OrcaSlicer's "Send All" puts on the wire.
- file_path = tmp_path / "Cube.gcode.3mf"
- _write_3mf_with_filaments(
- file_path, [{"id": 1, "type": "PLA", "color": "#000000", "used_g": "15.61"}], plate_index=1
- )
- # Append plate 2 and 3 blocks to slice_info.config to mimic Send All.
- with zipfile.ZipFile(file_path, "r") as zf:
- existing = zf.read("Metadata/slice_info.config").decode()
- # Inject two additional <plate> blocks (indices 2 and 3) inside <config>.
- multi_plate_config = existing.replace(
- "</config>",
- (
- '<plate><metadata key="index" value="2"/>'
- '<filament id="2" type="PETG" color="#FB0207" used_g="14.45"/>'
- "</plate>"
- '<plate><metadata key="index" value="3"/>'
- '<filament id="3" type="PLA" color="#FFFFFF" used_g="12.10"/>'
- "</plate>"
- "</config>"
- ),
- )
- # Repack the zip with the expanded slice_info.config.
- import io as _io
- buf = _io.BytesIO()
- with zipfile.ZipFile(file_path, "r") as src, zipfile.ZipFile(buf, "w") as dst:
- for name in src.namelist():
- if name == "Metadata/slice_info.config":
- dst.writestr(name, multi_plate_config)
- else:
- dst.writestr(name, src.read(name))
- # Plate-2 and plate-3 gcode payloads so `extract_filament_requirements`
- # has something to read for each — contents irrelevant, presence matters.
- dst.writestr("Metadata/plate_2.gcode", "; plate 2 gcode\n")
- dst.writestr("Metadata/plate_3.gcode", "; plate 3 gcode\n")
- file_path.write_bytes(buf.getvalue())
- mock_archive = MagicMock()
- mock_archive.id = 999
- mock_archive.printer_id = None
- mock_archive.filename = "Cube.gcode.3mf"
- mock_archive.print_name = "Cube"
- mock_archive.status = "archived"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- patch(
- "backend.app.core.websocket.ws_manager.send_archive_created",
- new_callable=AsyncMock,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- # Three queue items, one per plate, with the correct plate_id and
- # consecutive positions starting at MAX(position)+1 = 1.
- assert len(added_items) == 3, f"Expected 3 queue items for 3-plate Send All, got {len(added_items)}"
- plate_ids = [q.plate_id for q in added_items]
- assert plate_ids == [1, 2, 3], f"plate_ids should preserve slice_info order, got {plate_ids}"
- positions = [q.position for q in added_items]
- assert positions == [1, 2, 3], f"positions should be consecutive, got {positions}"
- archive_ids = {q.archive_id for q in added_items}
- assert archive_ids == {999}, f"All queue items must share the single backing archive, got {archive_ids}"
- # auto_dispatch=False on the VP → every item is manual_start.
- assert all(q.manual_start for q in added_items)
- @pytest.mark.asyncio
- async def test_add_to_print_queue_captures_nozzle_mapping(self, tmp_path):
- """#1780: BambuStudio's project_file for H2C rack-swap (O1C2) sends
- per-filament physical nozzle position IDs in `nozzle_mapping`. VP
- intake must store it as a JSON string on the queue item so the
- dispatcher can replay it. Without this the H2C firmware falls back
- to "last matching nozzle" auto-pick and ignores the user's slicer
- choice.
- """
- import json as _json
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- added_items = []
- mock_db = AsyncMock()
- mock_db.add = MagicMock(side_effect=added_items.append)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=42,
- name="H2CRack",
- mode="queue",
- model="O1C2",
- access_code="12345678",
- serial_suffix="391800042",
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- # Pre-populate as if BS's project_file arrived. Wire shape matches
- # BambuStudio's PrintJob params: nozzle_mapping = 32-entry array of
- # per-filament physical nozzle position IDs (verified via H2C wire
- # capture). The slicer-side `nozzles_info` field that the original
- # #1780 attempt also looked for was never actually sent — it has
- # been dropped from the capture path entirely.
- await inst.on_print_command(
- file_path.name,
- {
- "command": "project_file",
- "nozzle_mapping": [16, -1, -1, 1, -1, -1, -1, -1],
- },
- )
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "test"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- item = added_items[0]
- assert item.nozzle_mapping is not None
- assert _json.loads(item.nozzle_mapping) == [16, -1, -1, 1, -1, -1, -1, -1]
- @pytest.mark.asyncio
- async def test_add_to_print_queue_no_nozzle_mapping_when_slicer_omits(self, tmp_path):
- """#1780: every model other than O1C2 sends no nozzle_mapping — the
- queue item must carry NULL, not an empty list. NULL is what the
- dispatch layer keys off of to skip the injection entirely on non-
- rack-swap printers.
- """
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- added_items = []
- mock_db = AsyncMock()
- mock_db.add = MagicMock(side_effect=added_items.append)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=43,
- name="NotH2C",
- mode="queue",
- model="C11",
- access_code="12345678",
- serial_suffix="391800043",
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- # X1C-style slicer command — no nozzle fields.
- await inst.on_print_command(
- file_path.name,
- {"command": "project_file", "timelapse": False, "bed_leveling": True},
- )
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "test"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- item = added_items[0]
- assert item.nozzle_mapping is None
- @pytest.mark.asyncio
- async def test_add_to_print_queue_nozzle_pick_replicated_across_plates(self, tmp_path, monkeypatch):
- """#1780 × #1697/#1188: a multi-plate Send All from BS must stamp the
- same nozzle_mapping on every plate's queue item, not only the first.
- Mirrors the per-plate stamping for gcode_injection,
- filament_overrides, etc.
- """
- import json as _json
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- added_items = []
- mock_db = AsyncMock()
- mock_db.add = MagicMock(side_effect=added_items.append)
- mock_db.flush = AsyncMock()
- mock_db.commit = AsyncMock()
- mock_db.execute = AsyncMock()
- mock_db.execute.return_value.scalar.return_value = None
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=44,
- name="H2CMultiPlate",
- mode="queue",
- model="O1C2",
- access_code="12345678",
- serial_suffix="391800044",
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- # Force 3 plates so the queue loop runs three times.
- monkeypatch.setattr(inst, "_extract_plate_ids", lambda _p: [1, 2, 3])
- await inst.on_print_command(
- file_path.name,
- {
- "command": "project_file",
- "nozzle_mapping": [16, 0],
- },
- )
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "test"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 3
- for item in added_items:
- assert _json.loads(item.nozzle_mapping) == [16, 0]
- @pytest.mark.asyncio
- async def test_on_print_command_late_mqtt_retroactively_stamps_queue_item(self, tmp_path):
- """#1780 round 3: Bambu Studio's MQTT project_file can arrive AFTER
- `_add_to_print_queue` already gave up waiting (observed at 2.085 s
- on H2C wireless setups). The queue item was committed with settings
- defaults; the slicer's nozzle_mapping + workflow flags must be
- patched onto it when MQTT lands, otherwise the H2C firmware falls
- back to auto-pick.
- """
- import json as _json
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- added_items: list = []
- mock_db = AsyncMock()
- mock_db.add = MagicMock(
- side_effect=lambda item: (added_items.append(item), setattr(item, "id", 100 + len(added_items)))[0]
- )
- async def _flush():
- # added_items[-1].id was set by `add`; nothing else to do.
- return None
- mock_db.flush = AsyncMock(side_effect=_flush)
- mock_db.commit = AsyncMock()
- # First execute() call (the position-max SELECT inside _add_to_print_queue)
- # returns None; second (the eligible-pending SELECT in
- # _restamp_recent_queue_item) returns the committed queue id; third
- # (the UPDATE) is fire-and-forget.
- position_max_result = MagicMock()
- position_max_result.scalar = MagicMock(return_value=None)
- select_pending_result = MagicMock()
- select_pending_result.all = MagicMock(return_value=[(101,)])
- update_result = MagicMock()
- mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=99,
- name="LateMQTT",
- mode="queue",
- model="O1C2",
- access_code="12345678",
- serial_suffix="391800099",
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- # MQTT server presence enables the wait_for path; we don't actually
- # use any methods on it.
- inst._mqtt = MagicMock()
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "test"
- # 1. _add_to_print_queue runs WITHOUT a prior on_print_command —
- # the wait_for times out (settings-default fallback) and the
- # queue item is committed.
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- # Shorten the wait so the test isn't slow.
- patch(
- "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
- 0.05,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- assert len(added_items) == 1
- assert added_items[0].nozzle_mapping is None # MQTT was never received
- assert file_path.name in inst._recent_queue_items
- # 2. MQTT project_file arrives AFTER the wait expired — must
- # retroactively patch the queue item.
- await inst.on_print_command(
- file_path.name,
- {
- "command": "project_file",
- "file": file_path.name,
- "nozzle_mapping": [16, -1, -1, 1],
- "timelapse": True,
- "bed_leveling": False,
- },
- )
- # The UPDATE call is the third execute. Inspect its values.
- update_call = mock_db.execute.await_args_list[2]
- update_stmt = update_call.args[0]
- compiled = update_stmt.compile(compile_kwargs={"literal_binds": False})
- params = dict(compiled.params)
- assert _json.loads(params["nozzle_mapping"]) == [16, -1, -1, 1]
- assert params["timelapse"] is True
- assert params["bed_levelling"] == "off" # MQTT bed_leveling → column bed_levelling (tri-state)
- # Recent-queue tracking dict is cleared after the patch.
- assert file_path.name not in inst._recent_queue_items
- @pytest.mark.asyncio
- async def test_add_to_print_queue_catches_mqtt_stashed_post_wait_timeout(self, tmp_path):
- """The actual race-window scenario: wait_for times out, then MQTT
- arrives and stashes options AFTER the wait but BEFORE the
- post-commit re-check. The post-commit pop must catch it.
- """
- import json as _json
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- added_items: list = []
- mock_db = AsyncMock()
- mock_db.add = MagicMock(
- side_effect=lambda item: (added_items.append(item), setattr(item, "id", 300 + len(added_items)))[0]
- )
- mock_db.flush = AsyncMock()
- mock_db.commit = AsyncMock()
- position_max_result = MagicMock()
- position_max_result.scalar = MagicMock(return_value=None)
- select_pending_result = MagicMock()
- select_pending_result.all = MagicMock(return_value=[(301,)])
- update_result = MagicMock()
- mock_db.execute = AsyncMock(side_effect=[position_max_result, select_pending_result, update_result])
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=96,
- name="RaceCommitYield",
- mode="queue",
- model="O1C2",
- access_code="12345678",
- serial_suffix="391800096",
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- inst._mqtt = MagicMock()
- file_path = tmp_path / "test.3mf"
- file_path.write_bytes(b"fake3mf")
- # Stash MQTT data on the FIRST commit (simulating MQTT arrival
- # during _add_to_print_queue's commit yield); _restamp also calls
- # db.commit later, so we one-shot the side effect.
- commit_calls = {"n": 0}
- async def _delayed_stash(*_args, **_kwargs):
- commit_calls["n"] += 1
- if commit_calls["n"] == 1:
- inst._slicer_print_options[file_path.name] = {
- "command": "project_file",
- "file": file_path.name,
- "nozzle_mapping": [0, 16, -1, -1],
- "timelapse": False,
- }
- return None
- mock_db.commit = AsyncMock(side_effect=_delayed_stash)
- mock_archive = MagicMock()
- mock_archive.id = 1
- mock_archive.print_name = "test"
- with (
- patch(
- "backend.app.api.routes.settings.get_setting",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "backend.app.services.archive.ArchiveService.archive_print",
- new_callable=AsyncMock,
- return_value=mock_archive,
- ),
- patch(
- "backend.app.services.virtual_printer.manager._SLICER_OPTIONS_WAIT_TIMEOUT",
- 0.05,
- ),
- ):
- await inst._add_to_print_queue(file_path, "192.168.1.100")
- # Queue item INSERTed with defaults (wait timed out, no slicer_opts).
- assert len(added_items) == 1
- # But the post-commit pop caught the late stash and applied the
- # slicer nozzle_mapping via _restamp's UPDATE.
- update_call = mock_db.execute.await_args_list[2]
- update_stmt = update_call.args[0]
- compiled = update_stmt.compile(compile_kwargs={"literal_binds": False})
- params = dict(compiled.params)
- assert _json.loads(params["nozzle_mapping"]) == [0, 16, -1, -1]
- assert params["timelapse"] is False
- # _recent_queue_items entry was consumed by the post-commit
- # _restamp call.
- assert file_path.name not in inst._recent_queue_items
- # And the stash is empty.
- assert file_path.name not in inst._slicer_print_options
- @pytest.mark.asyncio
- async def test_on_print_command_late_mqtt_skips_already_dispatched_item(self, tmp_path):
- """Once the scheduler has picked the queue item up (status != pending),
- the retroactive patch is a no-op — racing the dispatcher would be
- unsafe.
- """
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- mock_db = AsyncMock()
- # The eligible-pending SELECT returns nothing — item is no longer pending.
- empty_result = MagicMock()
- empty_result.all = MagicMock(return_value=[])
- mock_db.execute = AsyncMock(return_value=empty_result)
- mock_db.commit = AsyncMock()
- mock_session_factory = MagicMock()
- mock_session_ctx = AsyncMock()
- mock_session_ctx.__aenter__ = AsyncMock(return_value=mock_db)
- mock_session_ctx.__aexit__ = AsyncMock(return_value=False)
- mock_session_factory.return_value = mock_session_ctx
- inst = VirtualPrinterInstance(
- vp_id=98,
- name="LateMQTTDispatched",
- mode="queue",
- model="O1C2",
- access_code="12345678",
- serial_suffix="391800098",
- base_dir=tmp_path,
- session_factory=mock_session_factory,
- )
- inst._mqtt = MagicMock()
- # Pre-seed the recent-queue dict — pretend _add_to_print_queue just
- # committed item id 42.
- inst._recent_queue_items["test.3mf"] = ([42], 1_000_000.0)
- # Drive _restamp via on_print_command on the late-MQTT path.
- with patch("backend.app.services.virtual_printer.manager.time.monotonic", return_value=1_000_001.0):
- await inst.on_print_command(
- "test.3mf",
- {
- "command": "project_file",
- "file": "test.3mf",
- "nozzle_mapping": [16, -1],
- },
- )
- # No UPDATE was issued — only the eligibility SELECT ran.
- assert mock_db.execute.await_count == 1
- mock_db.commit.assert_not_awaited()
- assert "test.3mf" not in inst._recent_queue_items
- class TestVirtualPrinterManager:
- """Tests for VirtualPrinterManager orchestrator."""
- @pytest.fixture
- def manager(self):
- """Create a VirtualPrinterManager instance."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterManager
- return VirtualPrinterManager()
- def test_manager_starts_empty(self, manager):
- """Verify manager starts with no instances."""
- assert len(manager._instances) == 0
- assert manager.is_enabled is False
- def test_manager_get_status_empty(self, manager):
- """Verify get_status returns disabled state when no instances."""
- status = manager.get_status()
- assert status["enabled"] is False
- assert status["running"] is False
- assert status["mode"] == "archive"
- def test_manager_is_enabled_with_instance(self, manager, tmp_path):
- """Verify is_enabled is True when instances exist."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=1,
- name="Test",
- mode="archive",
- model="C11",
- access_code="12345678",
- serial_suffix="391800001",
- base_dir=tmp_path,
- )
- manager._instances[1] = inst
- assert manager.is_enabled is True
- @pytest.mark.asyncio
- async def test_manager_remove_instance_server(self, manager, tmp_path):
- """Verify remove_instance stops and removes a server-mode instance."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=1,
- name="Test",
- mode="archive",
- model="C11",
- access_code="12345678",
- serial_suffix="391800001",
- base_dir=tmp_path,
- )
- inst.stop_server = AsyncMock()
- manager._instances[1] = inst
- await manager.remove_instance(1)
- assert 1 not in manager._instances
- inst.stop_server.assert_called_once()
- @pytest.mark.asyncio
- async def test_manager_remove_instance_proxy(self, manager, tmp_path):
- """Verify remove_instance stops proxy-mode instance."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=2,
- name="Proxy",
- mode="proxy",
- model="C11",
- access_code="",
- serial_suffix="391800002",
- target_printer_ip="192.168.1.100",
- base_dir=tmp_path,
- )
- inst.stop_proxy = AsyncMock()
- manager._instances[2] = inst
- await manager.remove_instance(2)
- assert 2 not in manager._instances
- inst.stop_proxy.assert_called_once()
- def test_manager_get_status_with_instance(self, manager, tmp_path):
- """Verify legacy get_status returns first instance data."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=1,
- name="Bambuddy",
- mode="archive",
- model="C11",
- access_code="12345678",
- serial_suffix="391800001",
- base_dir=tmp_path,
- )
- mock_task = MagicMock(done=MagicMock(return_value=False))
- inst._tasks = [mock_task]
- inst._pending_files = {"file1.3mf": Path("/tmp/file1.3mf")} # nosec B108
- manager._instances[1] = inst
- status = manager.get_status()
- assert status["enabled"] is True
- assert status["running"] is True
- assert status["mode"] == "archive"
- assert status["name"] == "Bambuddy"
- assert status["serial"] == "01S00A391800001"
- assert status["model"] == "C11"
- assert status["model_name"] == "P1P"
- assert status["pending_files"] == 1
- def test_manager_get_all_status(self, manager, tmp_path):
- """Verify get_all_status returns status for all instances."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- for i in range(1, 3):
- inst = VirtualPrinterInstance(
- vp_id=i,
- name=f"VP{i}",
- mode="archive",
- model="C11",
- access_code="12345678",
- serial_suffix=f"39180000{i}",
- base_dir=tmp_path,
- )
- manager._instances[i] = inst
- statuses = manager.get_all_status()
- assert len(statuses) == 2
- assert statuses[0]["name"] == "VP1"
- assert statuses[1]["name"] == "VP2"
- @pytest.mark.asyncio
- async def test_manager_stop_all(self, manager, tmp_path):
- """Verify stop_all removes all instances."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- for i in range(1, 3):
- inst = VirtualPrinterInstance(
- vp_id=i,
- name=f"VP{i}",
- mode="archive",
- model="C11",
- access_code="12345678",
- serial_suffix=f"39180000{i}",
- base_dir=tmp_path,
- )
- inst.stop_server = AsyncMock()
- manager._instances[i] = inst
- await manager.stop_all()
- assert len(manager._instances) == 0
- # ========================================================================
- # Tests for sync_from_db config change detection
- # ========================================================================
- def _make_db_vp(self, **overrides):
- """Create a mock VirtualPrinter DB object."""
- defaults = {
- "id": 1,
- "name": "TestVP",
- "enabled": True,
- "mode": "archive",
- "model": "C11",
- "access_code": "12345678",
- "serial_suffix": "391800001",
- "bind_ip": "",
- "remote_interface_ip": "",
- "target_printer_id": None,
- "auto_dispatch": True,
- "tailscale_disabled": True, # Opt-in default (#1070 UX fix)
- "queue_force_color_match": False, # default — must be explicit so MagicMock truthiness doesn't trip the change detector
- "gcode_injection": False, # same reason as above
- "position": 0,
- }
- defaults.update(overrides)
- vp = MagicMock()
- for k, v in defaults.items():
- setattr(vp, k, v)
- return vp
- def _setup_sync_mocks(self, manager, enabled_vps, tmp_path):
- """Wire up session_factory mock for sync_from_db."""
- mock_result = MagicMock()
- mock_result.scalars.return_value.all.return_value = enabled_vps
- mock_db = AsyncMock()
- mock_db.execute = AsyncMock(return_value=mock_result)
- mock_db.__aenter__ = AsyncMock(return_value=mock_db)
- mock_db.__aexit__ = AsyncMock(return_value=False)
- manager._session_factory = MagicMock(return_value=mock_db)
- manager._base_dir = tmp_path
- @pytest.mark.asyncio
- async def test_sync_from_db_restarts_on_mode_change(self, manager, tmp_path):
- """Verify sync_from_db restarts VP when mode changes."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=1,
- name="TestVP",
- mode="archive",
- model="C11",
- access_code="12345678",
- serial_suffix="391800001",
- base_dir=tmp_path,
- )
- inst.stop_server = AsyncMock()
- manager._instances[1] = inst
- # DB says mode changed to "review"
- db_vp = self._make_db_vp(mode="review")
- self._setup_sync_mocks(manager, [db_vp], tmp_path)
- with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
- # Patch VirtualPrinterInstance to prevent actual start
- with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
- mock_new = MagicMock()
- mock_new.start_server = AsyncMock()
- MockInst.return_value = mock_new
- await manager.sync_from_db()
- mock_remove.assert_called_once_with(1)
- @pytest.mark.asyncio
- async def test_sync_from_db_restarts_on_access_code_change(self, manager, tmp_path):
- """Verify sync_from_db restarts VP when access_code changes."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=1,
- name="TestVP",
- mode="archive",
- model="C11",
- access_code="12345678",
- serial_suffix="391800001",
- base_dir=tmp_path,
- )
- inst.stop_server = AsyncMock()
- manager._instances[1] = inst
- db_vp = self._make_db_vp(access_code="newcode99")
- self._setup_sync_mocks(manager, [db_vp], tmp_path)
- with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
- with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
- mock_new = MagicMock()
- mock_new.start_server = AsyncMock()
- MockInst.return_value = mock_new
- await manager.sync_from_db()
- mock_remove.assert_called_once_with(1)
- @pytest.mark.asyncio
- async def test_sync_from_db_skips_unchanged_instance(self, manager, tmp_path):
- """Verify sync_from_db does NOT restart when config is identical."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=1,
- name="TestVP",
- mode="archive",
- model="C11",
- access_code="12345678",
- serial_suffix="391800001",
- base_dir=tmp_path,
- )
- manager._instances[1] = inst
- # DB matches running config exactly
- db_vp = self._make_db_vp()
- self._setup_sync_mocks(manager, [db_vp], tmp_path)
- with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
- await manager.sync_from_db()
- mock_remove.assert_not_called()
- @pytest.mark.asyncio
- async def test_sync_from_db_restarts_on_bind_ip_change(self, manager, tmp_path):
- """Verify sync_from_db restarts VP when bind_ip changes."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=1,
- name="TestVP",
- mode="archive",
- model="C11",
- access_code="12345678",
- serial_suffix="391800001",
- bind_ip="192.168.1.10",
- base_dir=tmp_path,
- )
- inst.stop_server = AsyncMock()
- manager._instances[1] = inst
- db_vp = self._make_db_vp(bind_ip="192.168.1.20")
- self._setup_sync_mocks(manager, [db_vp], tmp_path)
- with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
- with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
- mock_new = MagicMock()
- mock_new.start_server = AsyncMock()
- MockInst.return_value = mock_new
- await manager.sync_from_db()
- mock_remove.assert_called_once_with(1)
- @pytest.mark.asyncio
- async def test_sync_from_db_restarts_on_model_change(self, manager, tmp_path):
- """Verify sync_from_db restarts VP when model changes."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=1,
- name="TestVP",
- mode="archive",
- model="C11",
- access_code="12345678",
- serial_suffix="391800001",
- base_dir=tmp_path,
- )
- inst.stop_server = AsyncMock()
- manager._instances[1] = inst
- db_vp = self._make_db_vp(model="C12")
- self._setup_sync_mocks(manager, [db_vp], tmp_path)
- with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
- with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
- mock_new = MagicMock()
- mock_new.start_server = AsyncMock()
- MockInst.return_value = mock_new
- await manager.sync_from_db()
- mock_remove.assert_called_once_with(1)
- @pytest.mark.asyncio
- async def test_sync_from_db_does_not_restart_on_tailscale_toggle(self, manager, tmp_path):
- """Flipping tailscale_disabled is purely informational — must NOT trigger a restart.
- Cert provisioning was removed; the toggle only governs whether the VP card surfaces
- the host's Tailscale IP/FQDN to the user. No service needs to reload, so changing
- it through sync_from_db should leave any running instance untouched.
- """
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=1,
- name="TestVP",
- mode="archive",
- model="C11",
- access_code="12345678",
- serial_suffix="391800001",
- tailscale_disabled=False,
- base_dir=tmp_path,
- )
- inst.stop_server = AsyncMock()
- manager._instances[1] = inst
- db_vp = self._make_db_vp(tailscale_disabled=True)
- self._setup_sync_mocks(manager, [db_vp], tmp_path)
- with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
- await manager.sync_from_db()
- mock_remove.assert_not_called()
- @pytest.mark.asyncio
- async def test_sync_from_db_restarts_on_gcode_injection_toggle(self, manager, tmp_path):
- """Toggling gcode_injection in the DB must restart the running instance.
- Without this, the in-memory ``self.gcode_injection`` keeps its old value
- and ``_add_to_print_queue`` stamps the stale flag on every new queue
- item — so disabling injection in the UI silently has no effect until
- the process restarts.
- """
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=1,
- name="TestVP",
- mode="archive",
- model="C11",
- access_code="12345678",
- serial_suffix="391800001",
- gcode_injection=True,
- base_dir=tmp_path,
- )
- inst.stop_server = AsyncMock()
- manager._instances[1] = inst
- db_vp = self._make_db_vp(gcode_injection=False)
- self._setup_sync_mocks(manager, [db_vp], tmp_path)
- with patch.object(manager, "remove_instance", new_callable=AsyncMock) as mock_remove:
- with patch("backend.app.services.virtual_printer.manager.VirtualPrinterInstance") as MockInst:
- mock_new = MagicMock()
- mock_new.start_server = AsyncMock()
- MockInst.return_value = mock_new
- await manager.sync_from_db()
- mock_remove.assert_called_once_with(1)
- class TestFTPSession:
- """Tests for FTP session handling."""
- @pytest.fixture
- def mock_reader(self):
- """Create a mock StreamReader."""
- reader = AsyncMock()
- return reader
- @pytest.fixture
- def mock_writer(self):
- """Create a mock StreamWriter."""
- writer = MagicMock()
- writer.get_extra_info = MagicMock(return_value=("192.168.1.100", 12345))
- writer.write = MagicMock()
- writer.drain = AsyncMock()
- writer.close = MagicMock()
- writer.wait_closed = AsyncMock()
- writer.is_closing = MagicMock(return_value=False)
- return writer
- @pytest.fixture
- def ssl_context(self):
- """Create a mock SSL context."""
- return MagicMock()
- @pytest.fixture
- def session(self, mock_reader, mock_writer, ssl_context, tmp_path):
- """Create an FTPSession instance."""
- from backend.app.services.virtual_printer.ftp_server import FTPSession
- return FTPSession(
- reader=mock_reader,
- writer=mock_writer,
- upload_dir=tmp_path,
- access_code="12345678",
- ssl_context=ssl_context,
- on_file_received=None,
- )
- # ========================================================================
- # Tests for authentication
- # ========================================================================
- @pytest.mark.asyncio
- async def test_user_command_accepts_bblp(self, session):
- """Verify USER command accepts bblp user."""
- await session.cmd_USER("bblp")
- assert session.username == "bblp"
- @pytest.mark.asyncio
- async def test_pass_command_authenticates(self, session):
- """Verify PASS command authenticates with correct code."""
- session.username = "bblp"
- await session.cmd_PASS("12345678")
- assert session.authenticated is True
- @pytest.mark.asyncio
- async def test_pass_command_rejects_wrong_code(self, session):
- """Verify PASS command rejects wrong access code."""
- session.username = "bblp"
- await session.cmd_PASS("wrongcode")
- assert session.authenticated is False
- # ========================================================================
- # Tests for FTP commands
- # ========================================================================
- @pytest.mark.asyncio
- async def test_syst_command(self, session):
- """Verify SYST returns UNIX type."""
- await session.cmd_SYST("")
- session.writer.write.assert_called()
- call_args = session.writer.write.call_args[0][0].decode()
- assert "215" in call_args
- assert "UNIX" in call_args
- @pytest.mark.asyncio
- async def test_pwd_command_requires_auth(self, session):
- """Verify PWD requires authentication."""
- session.authenticated = False
- await session.cmd_PWD("")
- call_args = session.writer.write.call_args[0][0].decode()
- assert "530" in call_args
- @pytest.mark.asyncio
- async def test_pwd_command_when_authenticated(self, session):
- """Verify PWD returns root directory when authenticated."""
- session.authenticated = True
- await session.cmd_PWD("")
- call_args = session.writer.write.call_args[0][0].decode()
- assert "257" in call_args
- @pytest.mark.asyncio
- async def test_type_command_sets_binary(self, session):
- """Verify TYPE I sets binary mode."""
- session.authenticated = True
- await session.cmd_TYPE("I")
- assert session.transfer_type == "I"
- @pytest.mark.asyncio
- async def test_pbsz_command(self, session):
- """Verify PBSZ returns success."""
- await session.cmd_PBSZ("0")
- call_args = session.writer.write.call_args[0][0].decode()
- assert "200" in call_args
- @pytest.mark.asyncio
- async def test_prot_command_accepts_p(self, session):
- """Verify PROT P is accepted."""
- await session.cmd_PROT("P")
- call_args = session.writer.write.call_args[0][0].decode()
- assert "200" in call_args
- @pytest.mark.asyncio
- async def test_quit_command(self, session):
- """Verify QUIT sends goodbye and raises CancelledError."""
- with pytest.raises(asyncio.CancelledError):
- await session.cmd_QUIT("")
- class TestSSDPServer:
- """Tests for Virtual Printer SSDP server."""
- @pytest.fixture
- def ssdp_server(self):
- """Create a VirtualPrinterSSDPServer instance."""
- from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
- return VirtualPrinterSSDPServer(
- serial="TEST123",
- name="TestPrinter",
- model="BL-P001",
- )
- # ========================================================================
- # Tests for SSDP response
- # ========================================================================
- def test_build_notify_message(self, ssdp_server):
- """Verify NOTIFY packet contains required headers."""
- # Set a known IP for testing
- ssdp_server._local_ip = "192.168.1.100"
- message = ssdp_server._build_notify_message()
- assert b"NOTIFY" in message
- assert b"DevName.bambu.com: TestPrinter" in message
- assert b"USN: TEST123" in message
- def test_build_response_message(self, ssdp_server):
- """Verify response packet contains required headers."""
- # Set a known IP for testing
- ssdp_server._local_ip = "192.168.1.100"
- message = ssdp_server._build_response_message()
- assert b"HTTP/1.1 200 OK" in message
- assert b"DevName.bambu.com: TestPrinter" in message
- assert b"USN: TEST123" in message
- def test_ssdp_server_uses_correct_model(self, ssdp_server):
- """Verify SSDP server uses the provided model."""
- ssdp_server._local_ip = "192.168.1.100"
- message = ssdp_server._build_notify_message()
- assert b"DevModel.bambu.com: BL-P001" in message
- # ========================================================================
- # Tests for advertise_ip parameter
- # ========================================================================
- def test_advertise_ip_sets_local_ip(self):
- """Verify advertise_ip overrides auto-detection."""
- from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
- server = VirtualPrinterSSDPServer(
- serial="TEST123",
- name="TestPrinter",
- model="BL-P001",
- advertise_ip="10.0.0.50",
- )
- assert server._local_ip == "10.0.0.50"
- def test_advertise_ip_empty_string_uses_auto_detect(self):
- """Verify empty advertise_ip falls back to auto-detection."""
- from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
- server = VirtualPrinterSSDPServer(
- serial="TEST123",
- name="TestPrinter",
- model="BL-P001",
- advertise_ip="",
- )
- assert server._local_ip is None
- def test_advertise_ip_in_notify_message(self):
- """Verify NOTIFY message uses the advertise_ip."""
- from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
- server = VirtualPrinterSSDPServer(
- serial="TEST123",
- name="TestPrinter",
- model="BL-P001",
- advertise_ip="10.0.0.50",
- )
- message = server._build_notify_message()
- assert b"Location: 10.0.0.50" in message
- def test_advertise_ip_in_response_message(self):
- """Verify M-SEARCH response uses the advertise_ip."""
- from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
- server = VirtualPrinterSSDPServer(
- serial="TEST123",
- name="TestPrinter",
- model="BL-P001",
- advertise_ip="10.0.0.50",
- )
- message = server._build_response_message()
- assert b"Location: 10.0.0.50" in message
- def test_default_no_advertise_ip(self):
- """Verify default constructor has None local_ip (auto-detect)."""
- from backend.app.services.virtual_printer.ssdp_server import VirtualPrinterSSDPServer
- server = VirtualPrinterSSDPServer()
- assert server._local_ip is None
- class TestCertificateService:
- """Tests for TLS certificate generation."""
- @pytest.fixture
- def cert_service(self, tmp_path):
- """Create a CertificateService instance."""
- from backend.app.services.virtual_printer.certificate import CertificateService
- return CertificateService(cert_dir=tmp_path, serial="TEST123")
- def test_generate_certificates(self, cert_service, tmp_path):
- """Verify certificates are generated correctly."""
- cert_path, key_path = cert_service.generate_certificates()
- assert cert_path.exists()
- assert key_path.exists()
- # Verify certificate content
- cert_content = cert_path.read_text()
- assert "BEGIN CERTIFICATE" in cert_content
- key_content = key_path.read_text()
- assert "BEGIN" in key_content and "KEY" in key_content
- def test_certificates_reused_if_exist(self, cert_service):
- """Verify existing certificates are reused."""
- # First generation
- cert_path1, key_path1 = cert_service.generate_certificates()
- mtime1 = cert_path1.stat().st_mtime
- # Second call should reuse (via ensure_certificates)
- cert_path2, key_path2 = cert_service.ensure_certificates()
- mtime2 = cert_path2.stat().st_mtime
- assert mtime1 == mtime2 # File wasn't regenerated
- def test_delete_certificates(self, cert_service):
- """Verify certificates can be deleted."""
- cert_service.generate_certificates()
- assert cert_service.cert_path.exists()
- assert cert_service.key_path.exists()
- cert_service.delete_certificates()
- assert not cert_service.cert_path.exists()
- assert not cert_service.key_path.exists()
- def test_ensure_creates_if_not_exist(self, cert_service):
- """Verify ensure_certificates generates if not existing."""
- assert not cert_service.cert_path.exists()
- cert_path, key_path = cert_service.ensure_certificates()
- assert cert_path.exists()
- assert key_path.exists()
- class TestBindServer:
- """Tests for BindServer (port 3002 bind/detect protocol)."""
- @pytest.fixture
- def bind_server(self):
- """Create a BindServer instance."""
- from backend.app.services.virtual_printer.bind_server import BindServer
- return BindServer(
- serial="09400A391800001",
- model="O1D",
- name="Bambuddy",
- )
- def test_build_frame(self, bind_server):
- """Verify frame building produces correct format."""
- payload = {"login": {"command": "detect"}}
- frame = bind_server._build_frame(payload)
- # Header: 0xA5A5
- assert frame[:2] == b"\xa5\xa5"
- # Trailer: 0xA7A7
- assert frame[-2:] == b"\xa7\xa7"
- # Length field is total message size (LE uint16)
- import struct
- total_len = struct.unpack_from("<H", frame, 2)[0]
- assert total_len == len(frame)
- # JSON payload is between header and trailer
- import json
- json_bytes = frame[4:-2]
- parsed = json.loads(json_bytes)
- assert parsed == payload
- def test_parse_frame_valid(self, bind_server):
- """Verify valid frame parsing extracts JSON correctly."""
- import json
- import struct
- payload = {"login": {"command": "detect", "sequence_id": "20000"}}
- json_bytes = json.dumps(payload, separators=(",", ":")).encode()
- total_len = 4 + len(json_bytes) + 2
- frame = b"\xa5\xa5" + struct.pack("<H", total_len) + json_bytes + b"\xa7\xa7"
- result = bind_server._parse_frame(frame)
- assert result is not None
- assert result["login"]["command"] == "detect"
- assert result["login"]["sequence_id"] == "20000"
- def test_parse_frame_invalid_header(self, bind_server):
- """Verify invalid header returns None."""
- result = bind_server._parse_frame(b"\xbb\xbb\x06\x00{}\xa7\xa7")
- assert result is None
- def test_parse_frame_invalid_trailer(self, bind_server):
- """Verify invalid trailer returns None."""
- result = bind_server._parse_frame(b"\xa5\xa5\x06\x00{}\xbb\xbb")
- assert result is None
- def test_parse_frame_too_short(self, bind_server):
- """Verify short data returns None."""
- result = bind_server._parse_frame(b"\xa5\xa5\x00")
- assert result is None
- def test_parse_frame_invalid_json(self, bind_server):
- """Verify invalid JSON returns None."""
- import struct
- bad_json = b"not json"
- total_len = 4 + len(bad_json) + 2
- frame = b"\xa5\xa5" + struct.pack("<H", total_len) + bad_json + b"\xa7\xa7"
- result = bind_server._parse_frame(frame)
- assert result is None
- def test_build_frame_roundtrip(self, bind_server):
- """Verify build_frame output can be parsed back."""
- payload = {
- "login": {
- "bind": "free",
- "command": "detect",
- "connect": "lan",
- "dev_cap": 1,
- "id": "09400A391800001",
- "model": "O1D",
- "name": "Bambuddy",
- "sequence_id": 3021,
- "version": "01.00.00.00",
- }
- }
- frame = bind_server._build_frame(payload)
- parsed = bind_server._parse_frame(frame)
- assert parsed is not None
- assert parsed["login"]["id"] == "09400A391800001"
- assert parsed["login"]["model"] == "O1D"
- assert parsed["login"]["name"] == "Bambuddy"
- assert parsed["login"]["bind"] == "free"
- def test_bind_server_stores_config(self, bind_server):
- """Verify bind server stores serial, model, name."""
- assert bind_server.serial == "09400A391800001"
- assert bind_server.model == "O1D"
- assert bind_server.name == "Bambuddy"
- assert bind_server.version == "01.00.00.00"
- def test_bind_server_custom_version(self):
- """Verify custom firmware version is stored."""
- from backend.app.services.virtual_printer.bind_server import BindServer
- server = BindServer(
- serial="TEST123",
- model="C13",
- name="Test",
- version="02.03.04.05",
- )
- assert server.version == "02.03.04.05"
- def test_bind_ports_constant(self):
- """Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility."""
- from backend.app.services.virtual_printer.bind_server import BIND_PORTS
- assert 3000 in BIND_PORTS
- assert 3002 in BIND_PORTS
- def test_bind_server_initializes_empty_servers_list(self, bind_server):
- """Verify bind server starts with empty servers list."""
- assert bind_server._servers == []
- assert bind_server._running is False
- class TestSlicerProxyManager:
- """Tests for SlicerProxyManager (proxy mode)."""
- @pytest.fixture
- def proxy_manager(self, tmp_path):
- """Create a SlicerProxyManager instance."""
- from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
- # Create dummy cert files
- cert_path = tmp_path / "cert.pem"
- key_path = tmp_path / "key.pem"
- cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
- # Split string to avoid pre-commit hook false positive on test data
- key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
- return SlicerProxyManager(
- target_host="192.168.1.100",
- cert_path=cert_path,
- key_path=key_path,
- )
- def test_proxy_manager_initializes_ports(self, proxy_manager):
- """Verify proxy manager has correct port constants."""
- # FTP proxy uses privileged port 990 to match what Bambu Studio expects
- assert proxy_manager.LOCAL_FTP_PORT == 990
- assert proxy_manager.LOCAL_MQTT_PORT == 8883
- assert proxy_manager.PRINTER_FTP_PORT == 990
- assert proxy_manager.PRINTER_MQTT_PORT == 8883
- assert proxy_manager.PRINTER_FILE_TRANSFER_PORT == 6000
- assert proxy_manager.PRINTER_RTSP_PORT == 322
- # Auxiliary ports: undocumented proprietary ports for A1/P1S etc.
- assert proxy_manager.PRINTER_AUX_PORTS == [2024, 2025, 2026]
- # Bind ports: both 3000 and 3002 for slicer compatibility
- assert proxy_manager.PRINTER_BIND_PORTS == [3000, 3002]
- # FTP data port range for transparent EPSV proxying
- assert proxy_manager.FTP_DATA_PORT_MIN == 50000
- assert proxy_manager.FTP_DATA_PORT_MAX == 50100
- def test_proxy_manager_stores_target_host(self, proxy_manager):
- """Verify proxy manager stores target host."""
- assert proxy_manager.target_host == "192.168.1.100"
- def test_get_status_before_start(self, proxy_manager):
- """Verify get_status returns zeros before start."""
- status = proxy_manager.get_status()
- assert status["running"] is False
- assert status["ftp_connections"] == 0
- assert status["mqtt_connections"] == 0
- @pytest.mark.asyncio
- async def test_proxy_start_creates_transparent_proxies(self, tmp_path):
- """Verify start() uses TCPProxy for FTP/FileTransfer/RTSP and TLSProxy only for MQTT.
- The transparent proxy architecture preserves end-to-end TLS between
- slicer and printer for all protocols except MQTT, which must be
- TLS-terminated to rewrite the printer's IP in MQTT payloads.
- """
- from unittest.mock import patch
- from backend.app.services.virtual_printer.tcp_proxy import (
- SlicerProxyManager,
- TCPProxy,
- TLSProxy,
- )
- cert_path = tmp_path / "cert.pem"
- key_path = tmp_path / "key.pem"
- cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
- key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
- mgr = SlicerProxyManager(
- target_host="192.168.1.100",
- cert_path=cert_path,
- key_path=key_path,
- bind_address="10.0.0.1",
- )
- # Mock asyncio.create_task and asyncio.gather to prevent actual
- # server start. Close every coroutine handed to gather — otherwise
- # the ~110 run_with_logging() coros built inside start() are
- # garbage-collected unfinalized and surface later as
- # PytestUnraisableExceptionWarning at random in other tests.
- async def _close_pending(*coros, **_):
- for c in coros:
- if asyncio.iscoroutine(c):
- c.close()
- with (
- patch("asyncio.create_task") as mock_create_task,
- patch("asyncio.gather", side_effect=_close_pending),
- patch.object(SlicerProxyManager, "_log_activity"),
- ):
- mock_create_task.return_value = MagicMock()
- await mgr.start()
- # FTP, FileTransfer, RTSP should be TCPProxy (transparent)
- assert isinstance(mgr._ftp_proxy, TCPProxy), "FTP should be TCPProxy (transparent)"
- assert isinstance(mgr._file_transfer_proxy, TCPProxy), "FileTransfer should be TCPProxy"
- assert isinstance(mgr._rtsp_proxy, TCPProxy), "RTSP should be TCPProxy"
- # MQTT should be TLSProxy (TLS-terminated for IP rewriting)
- assert isinstance(mgr._mqtt_proxy, TLSProxy), "MQTT should be TLSProxy (TLS-terminated)"
- # Auxiliary ports (2024-2026) should be TCPProxy (transparent)
- assert len(mgr._aux_proxies) == 3, "Should have 3 aux port proxies"
- for ap in mgr._aux_proxies:
- assert isinstance(ap, TCPProxy), "Aux proxies should be TCPProxy"
- assert mgr._aux_proxies[0].listen_port == 2024
- assert mgr._aux_proxies[0].target_port == 2024
- assert mgr._aux_proxies[2].listen_port == 2026
- # FTP data ports should be pre-created as TCPProxy instances
- assert len(mgr._ftp_data_proxies) == 101 # 50000-50100 inclusive
- for dp in mgr._ftp_data_proxies:
- assert isinstance(dp, TCPProxy), "FTP data proxies should be TCPProxy"
- # Verify FTP data proxies target the same port on the printer
- first_dp = mgr._ftp_data_proxies[0]
- assert first_dp.listen_port == 50000
- assert first_dp.target_port == 50000
- assert first_dp.target_host == "192.168.1.100"
- last_dp = mgr._ftp_data_proxies[-1]
- assert last_dp.listen_port == 50100
- assert last_dp.target_port == 50100
- def test_proxy_manager_mqtt_has_ip_rewriting(self, tmp_path):
- """Verify MQTT proxy is configured with IP rewriting when bind_address is set."""
- from backend.app.services.virtual_printer.tcp_proxy import SlicerProxyManager
- cert_path = tmp_path / "cert.pem"
- key_path = tmp_path / "key.pem"
- cert_path.write_text("-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----")
- key_path.write_text("-----BEGIN " + "PRIVATE KEY-----\ntest\n-----END " + "PRIVATE KEY-----")
- mgr = SlicerProxyManager(
- target_host="192.168.1.100",
- cert_path=cert_path,
- key_path=key_path,
- bind_address="10.0.0.1",
- )
- # Before start, proxies are None — verify constructor stores rewrite config
- assert mgr.bind_address == "10.0.0.1"
- assert mgr.target_host == "192.168.1.100"
- class TestSSDPProxy:
- """Tests for SSDPProxy (cross-network SSDP relay)."""
- @pytest.fixture
- def ssdp_proxy(self):
- """Create an SSDPProxy instance."""
- from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
- return SSDPProxy(
- local_interface_ip="192.168.1.100",
- remote_interface_ip="10.0.0.100",
- target_printer_ip="192.168.1.50",
- )
- def test_ssdp_proxy_stores_interface_ips(self, ssdp_proxy):
- """Verify SSDPProxy stores interface IPs correctly."""
- assert ssdp_proxy.local_interface_ip == "192.168.1.100"
- assert ssdp_proxy.remote_interface_ip == "10.0.0.100"
- assert ssdp_proxy.target_printer_ip == "192.168.1.50"
- def test_rewrite_ssdp_location(self, ssdp_proxy):
- """Verify SSDP Location header is rewritten to remote interface IP."""
- original_packet = b"NOTIFY * HTTP/1.1\r\nLocation: 192.168.1.50\r\nDevName.bambu.com: TestPrinter\r\n\r\n"
- rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
- # Location should be changed to remote interface IP
- assert b"Location: 10.0.0.100" in rewritten
- assert b"Location: 192.168.1.50" not in rewritten
- # Other headers should be preserved
- assert b"DevName.bambu.com: TestPrinter" in rewritten
- def test_rewrite_ssdp_location_case_insensitive(self, ssdp_proxy):
- """Verify SSDP Location rewrite is case insensitive."""
- original_packet = b"NOTIFY * HTTP/1.1\r\nlocation: 192.168.1.50\r\n\r\n"
- rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
- assert b"10.0.0.100" in rewritten
- def test_rewrite_ssdp_location_no_match(self, ssdp_proxy):
- """Verify packet without Location header is returned unchanged."""
- original_packet = b"NOTIFY * HTTP/1.1\r\nDevName.bambu.com: Test\r\n\r\n"
- rewritten = ssdp_proxy._rewrite_ssdp(original_packet)
- # No Location header, but _rewrite_ssdp logs a warning and returns as-is
- assert b"DevName.bambu.com: Test" in rewritten
- def test_parse_ssdp_message(self, ssdp_proxy):
- """Verify SSDP message parsing extracts headers."""
- packet = (
- b"NOTIFY * HTTP/1.1\r\n"
- b"Location: 192.168.1.50\r\n"
- b"DevName.bambu.com: TestPrinter\r\n"
- b"DevModel.bambu.com: BL-P001\r\n"
- b"\r\n"
- )
- headers = ssdp_proxy._parse_ssdp_message(packet)
- assert headers["location"] == "192.168.1.50"
- assert headers["devname.bambu.com"] == "TestPrinter"
- assert headers["devmodel.bambu.com"] == "BL-P001"
- class TestVirtualPrinterManagerDirectories:
- """Tests for VirtualPrinterManager directory management."""
- def test_ensure_base_directories_creates_subdirs(self, tmp_path):
- """Verify _ensure_base_directories creates required base directories."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterManager
- manager = VirtualPrinterManager()
- manager._base_dir = tmp_path / "virtual_printer"
- manager._ensure_base_directories()
- assert (tmp_path / "virtual_printer").exists()
- assert (tmp_path / "virtual_printer" / "uploads").exists()
- assert (tmp_path / "virtual_printer" / "certs").exists()
- def test_ensure_base_directories_handles_permission_error(self, tmp_path, caplog):
- """Verify _ensure_base_directories logs error on permission failure."""
- import logging
- from backend.app.services.virtual_printer.manager import VirtualPrinterManager
- manager = VirtualPrinterManager()
- vp_dir = tmp_path / "virtual_printer"
- manager._base_dir = vp_dir
- original_mkdir = type(vp_dir).mkdir
- def mock_mkdir(self, *args, **kwargs):
- if "virtual_printer" in str(self):
- raise PermissionError("Permission denied")
- return original_mkdir(self, *args, **kwargs)
- with caplog.at_level(logging.ERROR), patch.object(type(vp_dir), "mkdir", mock_mkdir):
- manager._ensure_base_directories()
- assert "Permission denied" in caplog.text
- def test_instance_creates_per_vp_directories(self, tmp_path):
- """Verify VirtualPrinterInstance creates per-VP upload and cert dirs."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- VirtualPrinterInstance(
- vp_id=42,
- name="Test",
- mode="archive",
- model="C11",
- access_code="12345678",
- serial_suffix="391800042",
- base_dir=tmp_path,
- )
- assert (tmp_path / "uploads" / "42").exists()
- assert (tmp_path / "uploads" / "42" / "cache").exists()
- assert (tmp_path / "certs" / "42").exists()
- class TestVirtualPrinterCameraPassthrough:
- """Tests for the non-proxy VP camera pass-through port selection (#1868).
- The camera port must follow the TARGET printer's model, not the VP's
- spoofed model: RTSP models (X1/X2/H2/P2S) use 322, chamber-image models
- (A1/P1) use 6000. Before the fix, the manager hardcoded 322, so P1S /
- A1 targets got a 322 listener with no upstream and BambuStudio /
- OrcaSlicer Liveview failed with error `[2:-10061]` (the reporter's
- symptom in #1868).
- """
- @staticmethod
- def _patch_start_server(monkeypatch):
- """Patch every non-camera constructor start_server touches so the
- test only exercises the camera-port branch. Returns the list that
- ``TCPProxy`` calls get captured into."""
- from backend.app.services.virtual_printer import manager as vp_manager
- tcp_calls: list[dict] = []
- class FakeTCPProxy:
- def __init__(self, **kwargs):
- tcp_calls.append(kwargs)
- self.kwargs = kwargs
- async def start(self):
- return None
- async def stop(self):
- return None
- # Camera pass-through is the only TCPProxy start_server constructs.
- monkeypatch.setattr(vp_manager, "TCPProxy", FakeTCPProxy)
- # No-op every other service constructor + resolve_cert_and_advertise.
- # start_server() awaits `.ready.wait()` on FTP/MQTT/Bind/SSDP after
- # spawning them, so each fake instance must carry an already-set
- # asyncio.Event as `.ready` — a plain MagicMock returns another
- # MagicMock for `.wait()`, which `asyncio.gather` then rejects with
- # `TypeError: An asyncio.Future, a coroutine or an awaitable is
- # required`.
- def _service_factory():
- def make(*args, **kwargs):
- inst = MagicMock()
- inst.start = AsyncMock(return_value=None)
- inst.stop = AsyncMock(return_value=None)
- ready = asyncio.Event()
- ready.set()
- inst.ready = ready
- return inst
- return make
- for name in (
- "VirtualPrinterFTPServer",
- "SimpleMQTTServer",
- "MQTTBridge",
- "BindServer",
- "VirtualPrinterSSDPServer",
- "SSDPProxy",
- ):
- monkeypatch.setattr(vp_manager, name, _service_factory())
- return tcp_calls
- async def _run_start_server(
- self,
- tmp_path,
- monkeypatch,
- *,
- target_model: str,
- target_ip: str = "192.168.1.100",
- ) -> list[dict]:
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- tcp_calls = self._patch_start_server(monkeypatch)
- instance = VirtualPrinterInstance(
- vp_id=99,
- name="CamTest",
- mode="archive",
- model="BL-P001", # VP's spoofed identity — irrelevant here
- access_code="12345678",
- serial_suffix="391800099",
- target_printer_id=7,
- base_dir=tmp_path,
- )
- # printer_manager stub returns a client with the model + ip we want.
- client = MagicMock()
- client.ip_address = target_ip
- client.model = target_model
- printer_manager = MagicMock()
- printer_manager.get_client.return_value = client
- instance._printer_manager = printer_manager
- # Cert / advertise resolution — start_server calls this early. Patch
- # to a fixed tuple so no filesystem I/O is required.
- monkeypatch.setattr(
- instance,
- "_resolve_cert_and_advertise",
- lambda: (Path("/tmp/cert.pem"), Path("/tmp/key.pem"), "192.168.1.1"), # nosec B108
- )
- try:
- await instance.start_server()
- finally:
- for task in instance._tasks:
- task.cancel()
- await asyncio.gather(*instance._tasks, return_exceptions=True)
- return tcp_calls
- @pytest.mark.asyncio
- async def test_rtsp_model_p2s_opens_port_322(self, tmp_path, monkeypatch):
- calls = await self._run_start_server(tmp_path, monkeypatch, target_model="P2S")
- assert any(c["listen_port"] == 322 and c["target_port"] == 322 for c in calls), (
- f"Expected 322 pass-through for RTSP model P2S, got {calls}"
- )
- @pytest.mark.asyncio
- async def test_chamber_image_model_p1s_opens_port_6000(self, tmp_path, monkeypatch):
- """#1868 regression guard: P1S target must expose 6000, not 322."""
- calls = await self._run_start_server(tmp_path, monkeypatch, target_model="P1S")
- assert any(c["listen_port"] == 6000 and c["target_port"] == 6000 for c in calls), (
- f"Expected 6000 pass-through for chamber-image model P1S (#1868), got {calls}"
- )
- assert not any(c["listen_port"] == 322 for c in calls), f"P1S should NOT get a 322 listener, got {calls}"
- @pytest.mark.asyncio
- async def test_chamber_image_model_a1_opens_port_6000(self, tmp_path, monkeypatch):
- calls = await self._run_start_server(tmp_path, monkeypatch, target_model="A1")
- assert any(c["listen_port"] == 6000 and c["target_port"] == 6000 for c in calls)
- @pytest.mark.asyncio
- async def test_rtsp_model_x1c_opens_port_322(self, tmp_path, monkeypatch):
- calls = await self._run_start_server(tmp_path, monkeypatch, target_model="X1C")
- assert any(c["listen_port"] == 322 and c["target_port"] == 322 for c in calls)
- class TestVirtualPrinterInstanceProxyMode:
- """Tests for VirtualPrinterInstance proxy mode."""
- @pytest.fixture
- def proxy_instance(self, tmp_path):
- """Create a proxy-mode VirtualPrinterInstance."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- return VirtualPrinterInstance(
- vp_id=10,
- name="ProxyTest",
- mode="proxy",
- model="C11",
- access_code="",
- serial_suffix="391800010",
- target_printer_ip="192.168.1.100",
- target_printer_serial="01P00A000000001",
- base_dir=tmp_path,
- )
- def test_proxy_instance_properties(self, proxy_instance):
- """Verify proxy instance stores config correctly."""
- assert proxy_instance.is_proxy is True
- assert proxy_instance.mode == "proxy"
- assert proxy_instance.target_printer_ip == "192.168.1.100"
- assert proxy_instance.target_printer_serial == "01P00A000000001"
- def test_proxy_instance_does_not_require_access_code(self, proxy_instance):
- """Verify proxy mode can have empty access code."""
- assert proxy_instance.access_code == ""
- def test_get_status_proxy_includes_proxy_fields(self, proxy_instance):
- """Verify get_status includes proxy fields when proxy is active."""
- mock_proxy = MagicMock()
- mock_proxy.get_status.return_value = {
- "running": True,
- "ftp_port": 990,
- "mqtt_port": 8883,
- "ftp_connections": 1,
- "mqtt_connections": 2,
- "target_host": "192.168.1.100",
- }
- proxy_instance._proxy = mock_proxy
- status = proxy_instance.get_status()
- assert "proxy" in status
- assert status["proxy"]["ftp_port"] == 990
- assert status["proxy"]["mqtt_connections"] == 2
- def test_proxy_instance_stores_remote_interface(self, tmp_path):
- """Verify proxy instance stores remote_interface_ip."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=11,
- name="Proxy2",
- mode="proxy",
- model="C11",
- access_code="",
- serial_suffix="391800011",
- target_printer_ip="192.168.1.100",
- remote_interface_ip="10.0.0.50",
- base_dir=tmp_path,
- )
- assert inst.remote_interface_ip == "10.0.0.50"
- class TestVirtualPrinterInstanceIPOverride:
- """Tests for remote_interface_ip and bind_ip on VirtualPrinterInstance."""
- @pytest.fixture
- def instance_with_remote_ip(self, tmp_path):
- """Create an instance with remote_interface_ip set."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- return VirtualPrinterInstance(
- vp_id=20,
- name="IPTest",
- mode="archive",
- model="BL-P001",
- access_code="12345678",
- serial_suffix="391800020",
- bind_ip="192.168.1.50",
- remote_interface_ip="10.0.0.50",
- base_dir=tmp_path,
- )
- def test_instance_stores_bind_ip(self, instance_with_remote_ip):
- """Verify bind_ip is stored."""
- assert instance_with_remote_ip.bind_ip == "192.168.1.50"
- def test_instance_stores_remote_interface_ip(self, instance_with_remote_ip):
- """Verify remote_interface_ip is stored."""
- assert instance_with_remote_ip.remote_interface_ip == "10.0.0.50"
- def test_generate_certificates_includes_remote_and_bind_ip(self, instance_with_remote_ip):
- """Verify generate_certificates passes remote_interface_ip and bind_ip as SANs."""
- with (
- patch.object(instance_with_remote_ip._cert_service, "delete_printer_certificate"),
- patch.object(
- instance_with_remote_ip._cert_service,
- "generate_certificates",
- return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
- ) as mock_gen,
- ):
- instance_with_remote_ip.generate_certificates()
- mock_gen.assert_called_once_with(additional_ips=["10.0.0.50", "192.168.1.50"])
- def test_generate_certificates_no_remote_ip(self, tmp_path):
- """Verify generate_certificates passes only bind_ip when no remote_interface_ip."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=21,
- name="NoRemote",
- mode="archive",
- model="BL-P001",
- access_code="12345678",
- serial_suffix="391800021",
- bind_ip="192.168.1.50",
- base_dir=tmp_path,
- )
- with (
- patch.object(inst._cert_service, "delete_printer_certificate"),
- patch.object(
- inst._cert_service,
- "generate_certificates",
- return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
- ) as mock_gen,
- ):
- inst.generate_certificates()
- mock_gen.assert_called_once_with(additional_ips=["192.168.1.50"])
- def test_generate_certificates_no_ips(self, tmp_path):
- """Verify generate_certificates passes None when no IPs configured."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=22,
- name="NoIPs",
- mode="archive",
- model="BL-P001",
- access_code="12345678",
- serial_suffix="391800022",
- base_dir=tmp_path,
- )
- with (
- patch.object(inst._cert_service, "delete_printer_certificate"),
- patch.object(
- inst._cert_service,
- "generate_certificates",
- return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
- ) as mock_gen,
- ):
- inst.generate_certificates()
- mock_gen.assert_called_once_with(additional_ips=None)
- class TestBindServer:
- """Tests for the BindServer (port 3002 bind/detect protocol)."""
- @pytest.fixture
- def bind_server(self):
- """Create a BindServer instance."""
- from backend.app.services.virtual_printer.bind_server import BindServer
- return BindServer(
- serial="01S00C000000001",
- model="BL-P001",
- name="Bambuddy",
- )
- def test_build_frame(self, bind_server):
- """Verify frame format: 0xA5A5 + len(u16le) + JSON + 0xA7A7."""
- payload = {"login": {"command": "detect"}}
- frame = bind_server._build_frame(payload)
- assert frame[:2] == b"\xa5\xa5"
- assert frame[-2:] == b"\xa7\xa7"
- # Length field is total message size
- import struct
- total_len = struct.unpack_from("<H", frame, 2)[0]
- assert total_len == len(frame)
- # JSON payload is between header and trailer
- import json
- json_bytes = frame[4:-2]
- parsed = json.loads(json_bytes)
- assert parsed == payload
- def test_parse_frame_valid(self, bind_server):
- """Verify valid frame parsing."""
- frame = bind_server._build_frame({"login": {"command": "detect", "sequence_id": "20000"}})
- result = bind_server._parse_frame(frame)
- assert result is not None
- assert result["login"]["command"] == "detect"
- assert result["login"]["sequence_id"] == "20000"
- def test_parse_frame_invalid_header(self, bind_server):
- """Verify invalid header returns None."""
- frame = b"\xb5\xb5\x10\x00" + b'{"login":{}}' + b"\xa7\xa7"
- assert bind_server._parse_frame(frame) is None
- def test_parse_frame_invalid_trailer(self, bind_server):
- """Verify invalid trailer returns None."""
- frame = b"\xa5\xa5\x10\x00" + b'{"login":{}}' + b"\xb7\xb7"
- assert bind_server._parse_frame(frame) is None
- def test_parse_frame_too_short(self, bind_server):
- """Verify short data returns None."""
- assert bind_server._parse_frame(b"\xa5\xa5\x00") is None
- assert bind_server._parse_frame(b"") is None
- def test_parse_frame_invalid_json(self, bind_server):
- """Verify invalid JSON returns None."""
- import struct
- bad_json = b"not json"
- total_len = 4 + len(bad_json) + 2
- frame = b"\xa5\xa5" + struct.pack("<H", total_len) + bad_json + b"\xa7\xa7"
- assert bind_server._parse_frame(frame) is None
- def test_build_frame_roundtrip(self, bind_server):
- """Verify build then parse roundtrip."""
- original = {"login": {"bind": "free", "command": "detect", "id": "01S00C000000001"}}
- frame = bind_server._build_frame(original)
- parsed = bind_server._parse_frame(frame)
- assert parsed == original
- def test_bind_server_stores_config(self, bind_server):
- """Verify config is stored correctly."""
- assert bind_server.serial == "01S00C000000001"
- assert bind_server.model == "BL-P001"
- assert bind_server.name == "Bambuddy"
- assert bind_server.version == "01.00.00.00"
- def test_bind_server_custom_version(self):
- """Verify custom firmware version is stored."""
- from backend.app.services.virtual_printer.bind_server import BindServer
- server = BindServer(
- serial="01S00C000000001",
- model="BL-P001",
- name="Bambuddy",
- version="01.09.00.10",
- )
- assert server.version == "01.09.00.10"
- def test_bind_ports_includes_both(self):
- """Verify BIND_PORTS includes both 3000 and 3002 for slicer compatibility."""
- from backend.app.services.virtual_printer.bind_server import BIND_PORTS
- assert 3000 in BIND_PORTS
- assert 3002 in BIND_PORTS
- def test_bind_server_initializes_empty_servers_list(self, bind_server):
- """Verify bind server starts with empty servers list."""
- assert bind_server._servers == []
- assert bind_server._running is False
- @pytest.mark.asyncio
- async def test_start_server_creates_bind_server(self, tmp_path):
- """Verify start_server creates BindServer with correct params."""
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- inst = VirtualPrinterInstance(
- vp_id=99,
- name="Bambuddy",
- mode="archive",
- model="BL-P001",
- access_code="12345678",
- serial_suffix="391800099",
- bind_ip="192.168.1.50",
- base_dir=tmp_path,
- )
- # Each mocked child service exposes a real asyncio.Event for the
- # readiness barrier added in start_server (set on instantiation so
- # the barrier returns immediately in tests).
- ready_event = asyncio.Event()
- ready_event.set()
- def with_ready(*_args, **_kwargs):
- child = MagicMock()
- child.ready = ready_event
- return child
- with (
- patch(
- "backend.app.services.virtual_printer.manager.VirtualPrinterSSDPServer",
- side_effect=with_ready,
- ),
- patch(
- "backend.app.services.virtual_printer.manager.VirtualPrinterFTPServer",
- side_effect=with_ready,
- ),
- patch(
- "backend.app.services.virtual_printer.manager.SimpleMQTTServer",
- side_effect=with_ready,
- ),
- patch(
- "backend.app.services.virtual_printer.manager.BindServer",
- side_effect=with_ready,
- ) as mock_bind_cls,
- patch.object(inst._cert_service, "delete_printer_certificate"),
- patch.object(
- inst._cert_service,
- "generate_certificates",
- return_value=(Path("/tmp/cert.pem"), Path("/tmp/key.pem")), # nosec B108
- ),
- ):
- await inst.start_server()
- mock_bind_cls.assert_called_once_with(
- serial=inst.serial,
- model="BL-P001",
- name="Bambuddy",
- bind_address="192.168.1.50",
- cert_path=Path("/tmp/cert.pem"), # nosec B108
- key_path=Path("/tmp/key.pem"), # nosec B108
- )
- class TestResolveModelCodes:
- """Tests for model code resolution (display name → SSDP code)."""
- def test_display_name_to_model_code_maps_all_models(self):
- """Verify reverse mapping covers all VIRTUAL_PRINTER_MODELS entries."""
- from backend.app.services.virtual_printer.manager import DISPLAY_NAME_TO_MODEL_CODE, VIRTUAL_PRINTER_MODELS
- for _code, display_name in VIRTUAL_PRINTER_MODELS.items():
- assert display_name in DISPLAY_NAME_TO_MODEL_CODE
- # For non-duplicate display names, should map back to a valid code
- assert DISPLAY_NAME_TO_MODEL_CODE[display_name] in VIRTUAL_PRINTER_MODELS
- def test_resolve_printer_model_with_ssdp_code(self):
- """SSDP codes pass through unchanged."""
- from backend.app.api.routes.virtual_printers import _resolve_printer_model
- assert _resolve_printer_model("BL-P001") == "BL-P001"
- assert _resolve_printer_model("O1D") == "O1D"
- assert _resolve_printer_model("N2S") == "N2S"
- def test_resolve_printer_model_with_display_name(self):
- """Display names resolve to SSDP codes."""
- from backend.app.api.routes.virtual_printers import _resolve_printer_model
- assert _resolve_printer_model("X1C") == "BL-P001"
- assert _resolve_printer_model("H2D") == "O1D"
- assert _resolve_printer_model("A1") == "N2S"
- assert _resolve_printer_model("P1S") == "C12"
- def test_resolve_printer_model_with_none_or_unknown(self):
- """None and unknown values return None."""
- from backend.app.api.routes.virtual_printers import _resolve_printer_model
- assert _resolve_printer_model(None) is None
- assert _resolve_printer_model("UnknownModel") is None
- class TestMqttIpRewrite:
- """Tests for TLSProxy._rewrite_mqtt_ip() MQTT packet IP rewriting."""
- @staticmethod
- def _build_mqtt_publish(topic: str, payload: bytes) -> bytes:
- """Build a minimal MQTT PUBLISH packet."""
- # PUBLISH fixed header: type 3, no flags
- topic_bytes = topic.encode("utf-8")
- # Variable header: topic length (2 bytes) + topic
- var_header = len(topic_bytes).to_bytes(2, "big") + topic_bytes
- body = var_header + payload
- # Encode remaining length
- remaining = len(body)
- header = bytearray([0x30]) # PUBLISH, QoS 0
- while True:
- encoded_byte = remaining % 128
- remaining //= 128
- if remaining > 0:
- encoded_byte |= 0x80
- header.append(encoded_byte)
- if remaining == 0:
- break
- return bytes(header) + body
- @staticmethod
- def _build_mqtt_pingreq() -> bytes:
- """Build an MQTT PINGREQ packet (2 bytes, no payload)."""
- return b"\xc0\x00"
- def test_rewrite_ip_in_publish(self):
- """IP string in PUBLISH payload is rewritten."""
- from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
- payload = b'{"rtsp_url":"rtsps://192.168.1.100:322/live"}'
- packet = self._build_mqtt_publish("device/status", payload)
- result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray())
- assert b"10.0.0.1" in result
- assert b"192.168.1.100" not in result
- def test_no_rewrite_when_ip_absent(self):
- """Packets without the target IP are passed through unchanged."""
- from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
- payload = b'{"status":"idle"}'
- packet = self._build_mqtt_publish("device/status", payload)
- result, buf = TLSProxy._rewrite_mqtt_ip(packet, b"192.168.1.100", b"10.0.0.1", bytearray())
- assert result == packet
- def test_non_publish_packets_unchanged(self):
- """Non-PUBLISH packets (e.g. PINGREQ) are never rewritten."""
- from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
- pingreq = self._build_mqtt_pingreq()
- result, buf = TLSProxy._rewrite_mqtt_ip(pingreq, b"192.168.1.100", b"10.0.0.1", bytearray())
- assert result == pingreq
- def test_rewrite_preserves_packet_framing(self):
- """Rewritten packet has valid MQTT remaining length."""
- from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
- # Use IPs of different lengths to test length re-encoding
- old_ip = b"192.168.255.133" # 15 bytes
- new_ip = b"10.0.0.1" # 8 bytes
- payload = b'{"ip":"192.168.255.133"}'
- packet = self._build_mqtt_publish("device/status", payload)
- result, buf = TLSProxy._rewrite_mqtt_ip(packet, old_ip, new_ip, bytearray())
- # Parse the result to verify framing
- assert result[0] == 0x30 # PUBLISH header byte
- # Decode remaining length
- pos = 1
- remaining = 0
- multiplier = 1
- while True:
- b = result[pos]
- pos += 1
- remaining += (b & 0x7F) * multiplier
- multiplier *= 128
- if (b & 0x80) == 0:
- break
- # Remaining length should match actual data
- assert pos + remaining == len(result)
- assert new_ip in result
- def test_incomplete_packet_buffered(self):
- """Incomplete packet at end of chunk is buffered for next call."""
- from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
- payload = b'{"ip":"192.168.1.100"}'
- packet = self._build_mqtt_publish("device/status", payload)
- # Split packet in the middle
- half = len(packet) // 2
- chunk1 = packet[:half]
- chunk2 = packet[half:]
- result1, buf = TLSProxy._rewrite_mqtt_ip(chunk1, b"192.168.1.100", b"10.0.0.1", bytearray())
- # First chunk should be buffered (incomplete packet)
- assert len(buf) > 0
- result2, buf = TLSProxy._rewrite_mqtt_ip(chunk2, b"192.168.1.100", b"10.0.0.1", buf)
- # Second chunk completes the packet, IP should be rewritten
- combined = result1 + result2
- assert b"10.0.0.1" in combined
- assert b"192.168.1.100" not in combined
- def test_multiple_packets_in_one_chunk(self):
- """Multiple MQTT packets in a single chunk are all processed."""
- from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
- payload1 = b'{"ip":"192.168.1.100"}'
- payload2 = b'{"other":"data"}'
- packet1 = self._build_mqtt_publish("topic1", payload1)
- packet2 = self._build_mqtt_publish("topic2", payload2)
- combined = packet1 + packet2
- result, buf = TLSProxy._rewrite_mqtt_ip(combined, b"192.168.1.100", b"10.0.0.1", bytearray())
- assert b"10.0.0.1" in result
- assert b"192.168.1.100" not in result
- # Second packet should still be present
- assert b"other" in result
- def test_extra_replacements(self):
- """Extra replacement pairs (e.g. integer IP) are also applied."""
- from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
- payload = b'{"net":{"info":[{"ip":2248124608}]}}'
- packet = self._build_mqtt_publish("device/status", payload)
- result, buf = TLSProxy._rewrite_mqtt_ip(
- packet,
- b"NOMATCH",
- b"NOREPLACE",
- bytearray(),
- extra_replacements=[(b"2248124608", b"285190336")],
- )
- assert b"285190336" in result
- assert b"2248124608" not in result
- class TestIpToLeIntBytes:
- """Tests for TLSProxy._ip_to_le_int_bytes() integer IP conversion."""
- def test_converts_ip_to_le_int(self):
- from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
- assert TLSProxy._ip_to_le_int_bytes("192.168.255.133") == b"2248124608"
- assert TLSProxy._ip_to_le_int_bytes("192.168.255.16") == b"285190336"
- assert TLSProxy._ip_to_le_int_bytes("10.0.0.1") == b"16777226"
- def test_roundtrip(self):
- """Verify the integer converts back to the correct IP."""
- import struct
- from backend.app.services.virtual_printer.tcp_proxy import TLSProxy
- for ip in ["192.168.1.1", "10.0.0.1", "172.16.0.100", "192.168.255.133"]:
- le_int = int(TLSProxy._ip_to_le_int_bytes(ip))
- parts = ip.split(".")
- expected = struct.unpack("<I", bytes(int(p) for p in parts))[0]
- assert le_int == expected
- class TestSSDPProxyName:
- """Tests for SSDPProxy VP name rewriting."""
- @pytest.fixture
- def ssdp_proxy_with_name(self):
- from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
- return SSDPProxy(
- local_interface_ip="192.168.1.100",
- remote_interface_ip="10.0.0.100",
- target_printer_ip="192.168.1.50",
- name="H2D-1 Proxy",
- )
- @pytest.fixture
- def ssdp_proxy_without_name(self):
- from backend.app.services.virtual_printer.ssdp_server import SSDPProxy
- return SSDPProxy(
- local_interface_ip="192.168.1.100",
- remote_interface_ip="10.0.0.100",
- target_printer_ip="192.168.1.50",
- )
- def test_rewrite_uses_configured_name(self, ssdp_proxy_with_name):
- """When name is set, DevName is replaced entirely."""
- 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"
- rewritten = ssdp_proxy_with_name._rewrite_ssdp(packet)
- assert b"DevName.bambu.com: H2D-1 Proxy" in rewritten
- assert b"RealPrinter" not in rewritten
- def test_rewrite_appends_proxy_without_name(self, ssdp_proxy_without_name):
- """When no name is set, ' - Proxy' is appended to the real name."""
- 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"
- rewritten = ssdp_proxy_without_name._rewrite_ssdp(packet)
- assert b"DevName.bambu.com: RealPrinter - Proxy" in rewritten
- class TestVPProjectFileStashKey:
- """Regression: `on_print_command` MUST stash slicer options under the
- FTP filename (`data["file"]`, with extension), NOT under `filename`
- (the slicer's `subtask_name`, bare).
- #1780 root cause (real bundle, 2026-06-21): BambuStudio sends
- `subtask_name = "Model_Name"` (bare) and `file = "Model_Name.gcode.3mf"`
- (with extension). `_add_to_print_queue` looks up the stash under
- `file_path.name` from the FTP receive side, which always has the
- extension. If the stash uses `subtask_name`, lookup misses → every
- captured slicer field (bed_leveling, flow_cali, vibration_cali,
- layer_inspect, timelapse, nozzle_mapping) silently falls back to
- settings defaults on every Bambu Studio "Send" upload.
- `filename` (subtask_name) must still flow to `_schedule_finish_release`
- untouched — push_status echoes it back as gcode_file / subtask_name and
- the slicer matches against its own local subtask_name there. So
- `on_print_command` keeps `filename` for state-feedback but derives the
- stash key from `data["file"]`.
- """
- @pytest.fixture
- def instance(self, tmp_path):
- from backend.app.services.virtual_printer.manager import VirtualPrinterInstance
- return VirtualPrinterInstance(
- vp_id=99,
- name="StashKeyTest",
- mode="queue",
- model="O1C2",
- access_code="12345678",
- serial_suffix="999999999",
- base_dir=tmp_path,
- )
- @pytest.mark.asyncio
- async def test_stash_key_uses_file_field_not_subtask_name(self, instance):
- """BambuStudio's real wire shape: `subtask_name` ≠ `file`.
- on_print_command must stash under `data["file"]` so the FTP-side
- `_add_to_print_queue` lookup matches.
- """
- # mqtt_server.py:_handle_publish hands the bare subtask_name as
- # `filename` and the full print_data body as `data`. The FTP filename
- # lives in `data["file"]`.
- await instance.on_print_command(
- "Filament_Track_Switch_Holder", # subtask_name (bare)
- {
- "command": "project_file",
- "subtask_name": "Filament_Track_Switch_Holder",
- "file": "Filament_Track_Switch_Holder.gcode.3mf",
- "nozzle_mapping": [16, -1, -1, 1],
- },
- )
- # Stash MUST be under the FTP filename, not the bare subtask_name.
- # `_add_to_print_queue` does `_slicer_print_options.pop(file_path.name, None)`
- # where file_path.name == "Filament_Track_Switch_Holder.gcode.3mf".
- assert "Filament_Track_Switch_Holder.gcode.3mf" in instance._slicer_print_options
- assert "Filament_Track_Switch_Holder" not in instance._slicer_print_options
- # Body must carry nozzle_mapping verbatim.
- stashed = instance._slicer_print_options["Filament_Track_Switch_Holder.gcode.3mf"]
- assert stashed["nozzle_mapping"] == [16, -1, -1, 1]
- @pytest.mark.asyncio
- async def test_stash_key_falls_back_to_filename_when_file_absent(self, instance):
- """Defensive fallback: a slicer that omits the `file` field entirely
- (legacy / non-3MF) must fall back to `filename` (subtask_name), not
- leave the stash unkeyed."""
- await instance.on_print_command(
- "BareName",
- {
- "command": "project_file",
- "subtask_name": "BareName",
- # no "file" field
- },
- )
- assert "BareName" in instance._slicer_print_options
- @pytest.mark.asyncio
- async def test_stash_key_signals_event_under_file_key(self, instance):
- """`_add_to_print_queue` registers a wait-event under `file_path.name`
- when the slicer's project_file arrives late. on_print_command must
- signal THAT event (keyed by the FTP filename), not one keyed by
- subtask_name — else the waiter times out even though the stash is
- present and addressable."""
- import asyncio
- ftp_filename = "Filament_Track_Switch_Holder.gcode.3mf"
- event = asyncio.Event()
- instance._slicer_print_options_events[ftp_filename] = event
- await instance.on_print_command(
- "Filament_Track_Switch_Holder", # bare subtask_name
- {
- "command": "project_file",
- "subtask_name": "Filament_Track_Switch_Holder",
- "file": ftp_filename,
- },
- )
- # Event keyed by FTP filename must fire even though on_print_command
- # was called with the bare subtask_name.
- assert event.is_set()
|