test_print_queue_api.py 165 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185
  1. """Integration tests for Print Queue API endpoints."""
  2. import json
  3. import pytest
  4. from httpx import AsyncClient
  5. from sqlalchemy import select
  6. from backend.app.models.finance import CostCenter
  7. from backend.app.models.settings import Settings
  8. async def enable_billing(db_session):
  9. setting = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
  10. if setting is None:
  11. db_session.add(Settings(key="billing_enabled", value="true"))
  12. else:
  13. setting.value = "true"
  14. await db_session.commit()
  15. class TestPrintQueueAPI:
  16. """Integration tests for /api/v1/queue endpoints."""
  17. @pytest.fixture
  18. async def printer_factory(self, db_session):
  19. """Factory to create test printers."""
  20. _counter = [0]
  21. async def _create_printer(**kwargs):
  22. from backend.app.models.printer import Printer
  23. _counter[0] += 1
  24. counter = _counter[0]
  25. defaults = {
  26. "name": f"Test Printer {counter}",
  27. "ip_address": f"192.168.1.{100 + counter}",
  28. "serial_number": f"TESTSERIAL{counter:04d}",
  29. "access_code": "12345678",
  30. "model": "X1C",
  31. }
  32. defaults.update(kwargs)
  33. printer = Printer(**defaults)
  34. db_session.add(printer)
  35. await db_session.commit()
  36. await db_session.refresh(printer)
  37. return printer
  38. return _create_printer
  39. @pytest.fixture
  40. async def archive_factory(self, db_session):
  41. """Factory to create test archives."""
  42. _counter = [0]
  43. async def _create_archive(**kwargs):
  44. from backend.app.models.archive import PrintArchive
  45. _counter[0] += 1
  46. counter = _counter[0]
  47. defaults = {
  48. "filename": f"test_print_{counter}.3mf",
  49. "print_name": f"Test Print {counter}",
  50. "file_path": f"/tmp/test_print_{counter}.3mf", # nosec B108 -- a stored path, never opened
  51. "file_size": 1024,
  52. "content_hash": f"testhash{counter:08d}",
  53. "status": "completed",
  54. }
  55. defaults.update(kwargs)
  56. archive = PrintArchive(**defaults)
  57. db_session.add(archive)
  58. await db_session.commit()
  59. await db_session.refresh(archive)
  60. return archive
  61. return _create_archive
  62. @pytest.fixture
  63. async def queue_item_factory(self, db_session, printer_factory, archive_factory):
  64. """Factory to create test queue items."""
  65. _counter = [0]
  66. async def _create_queue_item(**kwargs):
  67. from backend.app.models.print_queue import PrintQueueItem
  68. _counter[0] += 1
  69. counter = _counter[0]
  70. # Create printer and archive if not provided
  71. if "printer_id" not in kwargs:
  72. printer = await printer_factory()
  73. kwargs["printer_id"] = printer.id
  74. if "archive_id" not in kwargs:
  75. archive = await archive_factory()
  76. kwargs["archive_id"] = archive.id
  77. defaults = {
  78. "status": "pending",
  79. "position": counter,
  80. }
  81. defaults.update(kwargs)
  82. item = PrintQueueItem(**defaults)
  83. db_session.add(item)
  84. await db_session.commit()
  85. await db_session.refresh(item)
  86. return item
  87. return _create_queue_item
  88. @pytest.mark.asyncio
  89. @pytest.mark.integration
  90. async def test_list_queue_empty(self, async_client: AsyncClient):
  91. """Verify empty list when no queue items exist."""
  92. response = await async_client.get("/api/v1/queue/")
  93. assert response.status_code == 200
  94. assert isinstance(response.json(), list)
  95. @pytest.mark.asyncio
  96. @pytest.mark.integration
  97. async def test_list_filters_on_several_statuses(self, async_client: AsyncClient, queue_item_factory):
  98. """The wiki documents ?status=pending,printing; it was matched as one
  99. literal status, so it returned nothing."""
  100. pending = await queue_item_factory(status="pending")
  101. printing = await queue_item_factory(status="printing")
  102. await queue_item_factory(status="completed")
  103. several = await async_client.get("/api/v1/queue/?status=pending,printing")
  104. one = await async_client.get("/api/v1/queue/?status=printing")
  105. assert several.status_code == 200
  106. assert sorted(item["id"] for item in several.json()) == sorted([pending.id, printing.id])
  107. assert [item["id"] for item in one.json()] == [printing.id]
  108. @pytest.mark.asyncio
  109. @pytest.mark.integration
  110. @pytest.mark.parametrize("pinned_first", [True, False], ids=["pinned-above-any", "any-above-pinned"])
  111. async def test_list_follows_queue_position_across_pinned_and_any_jobs(
  112. self, async_client: AsyncClient, printer_factory, queue_item_factory, pinned_first
  113. ):
  114. """A printer's first pending item is the one the scheduler starts next (#3200).
  115. The printer card's "Next in queue" shows that first item. The list used
  116. to sort by printer first, which put every "Any <model>" job (no
  117. printer_id) ahead of a job pinned to the printer -- the order the
  118. scheduler itself had until #3200 -- so the card kept naming a lower
  119. "Any" job after dispatch was fixed.
  120. """
  121. printer = await printer_factory(model="P2S")
  122. pinned_pos, any_pos = (1, 2) if pinned_first else (2, 1)
  123. pinned = await queue_item_factory(printer_id=printer.id, position=pinned_pos)
  124. any_model = await queue_item_factory(printer_id=None, target_model="P2S", position=any_pos)
  125. later_any = await queue_item_factory(printer_id=None, target_model="P2S", position=3)
  126. response = await async_client.get(
  127. "/api/v1/queue/", params={"printer_id": printer.id, "status": "pending", "target_model": "P2S"}
  128. )
  129. assert response.status_code == 200
  130. ids = [item["id"] for item in response.json()]
  131. expected_head = [pinned.id, any_model.id] if pinned_first else [any_model.id, pinned.id]
  132. assert ids == [*expected_head, later_any.id]
  133. @pytest.mark.asyncio
  134. @pytest.mark.integration
  135. async def test_add_to_queue(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
  136. """Verify item can be added to queue."""
  137. printer = await printer_factory()
  138. archive = await archive_factory()
  139. data = {
  140. "printer_id": printer.id,
  141. "archive_id": archive.id,
  142. }
  143. response = await async_client.post("/api/v1/queue/", json=data)
  144. assert response.status_code == 200
  145. result = response.json()
  146. assert result["printer_id"] == printer.id
  147. assert result["archive_id"] == archive.id
  148. assert result["status"] == "pending"
  149. assert result["manual_start"] is False
  150. @pytest.mark.asyncio
  151. @pytest.mark.integration
  152. async def test_add_to_queue_with_cost_center_id(
  153. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  154. ):
  155. """Verify item can be added to queue with cost_center_id."""
  156. await enable_billing(db_session)
  157. printer = await printer_factory()
  158. archive = await archive_factory(cost=1.25, filament_used_grams=50.0)
  159. cost_center = CostCenter(name="Queue CC", is_active=True, is_private=False)
  160. db_session.add(cost_center)
  161. await db_session.commit()
  162. await db_session.refresh(cost_center)
  163. response = await async_client.post(
  164. "/api/v1/queue/",
  165. json={
  166. "printer_id": printer.id,
  167. "archive_id": archive.id,
  168. "cost_center_id": cost_center.id,
  169. "estimated_cost": 0.01,
  170. },
  171. )
  172. assert response.status_code == 200
  173. result = response.json()
  174. assert result["cost_center_id"] == cost_center.id
  175. assert result["estimated_cost"] == 1.25
  176. from backend.app.models.print_queue import PrintQueueItem
  177. row = await db_session.scalar(select(PrintQueueItem).where(PrintQueueItem.id == result["id"]))
  178. assert row is not None
  179. assert row.cost_center_id == cost_center.id
  180. assert row.estimated_cost == 1.25
  181. @pytest.mark.asyncio
  182. @pytest.mark.integration
  183. async def test_add_to_queue_derives_cost_without_client_estimate(
  184. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  185. ):
  186. """The persisted budget estimate comes from the archive, not the request."""
  187. await enable_billing(db_session)
  188. printer = await printer_factory()
  189. archive = await archive_factory(cost=1.25, filament_used_grams=50.0)
  190. cost_center = CostCenter(name="Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
  191. db_session.add(cost_center)
  192. await db_session.commit()
  193. await db_session.refresh(cost_center)
  194. response = await async_client.post(
  195. "/api/v1/queue/",
  196. json={
  197. "printer_id": printer.id,
  198. "archive_id": archive.id,
  199. "cost_center_id": cost_center.id,
  200. },
  201. )
  202. assert response.status_code == 200
  203. assert response.json()["estimated_cost"] == 1.25
  204. @pytest.mark.asyncio
  205. @pytest.mark.integration
  206. async def test_add_model_based_queue_item_derives_cost_without_client_estimate(
  207. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  208. ):
  209. """Model dispatch has no printer-side estimate but remains billable."""
  210. await enable_billing(db_session)
  211. await printer_factory(model="X1C")
  212. archive = await archive_factory(cost=1.25, filament_used_grams=50.0, sliced_for_model="X1C")
  213. cost_center = CostCenter(name="Model Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
  214. db_session.add(cost_center)
  215. await db_session.commit()
  216. await db_session.refresh(cost_center)
  217. response = await async_client.post(
  218. "/api/v1/queue/",
  219. json={
  220. "target_model": "X1C",
  221. "archive_id": archive.id,
  222. "cost_center_id": cost_center.id,
  223. },
  224. )
  225. assert response.status_code == 200
  226. assert response.json()["printer_id"] is None
  227. assert response.json()["estimated_cost"] == 1.25
  228. @pytest.mark.asyncio
  229. @pytest.mark.integration
  230. async def test_add_to_queue_rejects_tampered_client_cost_when_server_cost_exceeds_budget(
  231. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  232. ):
  233. """A forged low client hint cannot bypass the server-derived budget check."""
  234. await enable_billing(db_session)
  235. printer = await printer_factory()
  236. archive = await archive_factory(cost=2.0, filament_used_grams=50.0)
  237. cost_center = CostCenter(name="Tiny Budget CC", is_active=True, is_private=False, monthly_budget=1.0)
  238. db_session.add(cost_center)
  239. await db_session.commit()
  240. await db_session.refresh(cost_center)
  241. response = await async_client.post(
  242. "/api/v1/queue/",
  243. json={
  244. "printer_id": printer.id,
  245. "archive_id": archive.id,
  246. "cost_center_id": cost_center.id,
  247. "estimated_cost": 0.01,
  248. },
  249. )
  250. assert response.status_code == 400
  251. assert "exceeds available cost center budget" in response.json()["detail"]
  252. @pytest.mark.asyncio
  253. @pytest.mark.integration
  254. async def test_add_to_queue_requires_cost_center_when_billing_enabled(
  255. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  256. ):
  257. """Billing enforcement rejects queue jobs that omit cost center."""
  258. await enable_billing(db_session)
  259. printer = await printer_factory()
  260. archive = await archive_factory(cost=1.0, filament_used_grams=50.0)
  261. response = await async_client.post(
  262. "/api/v1/queue/",
  263. json={
  264. "printer_id": printer.id,
  265. "archive_id": archive.id,
  266. },
  267. )
  268. assert response.status_code == 400
  269. assert "Cost center is required" in response.json()["detail"]
  270. @pytest.mark.asyncio
  271. @pytest.mark.integration
  272. async def test_add_to_queue_counts_pending_queue_reservations_against_budget(
  273. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
  274. ):
  275. """Open queue items reserve budget until they leave pending/printing states."""
  276. await enable_billing(db_session)
  277. printer = await printer_factory()
  278. archive = await archive_factory(cost=3.0, filament_used_grams=50.0)
  279. cost_center = CostCenter(name="Reserved Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
  280. db_session.add(cost_center)
  281. await db_session.commit()
  282. await db_session.refresh(cost_center)
  283. await queue_item_factory(
  284. printer_id=printer.id,
  285. archive_id=archive.id,
  286. cost_center_id=cost_center.id,
  287. estimated_cost=8.0,
  288. status="pending",
  289. )
  290. response = await async_client.post(
  291. "/api/v1/queue/",
  292. json={
  293. "printer_id": printer.id,
  294. "archive_id": archive.id,
  295. "cost_center_id": cost_center.id,
  296. "estimated_cost": 0.01,
  297. },
  298. )
  299. assert response.status_code == 400
  300. assert "exceeds available cost center budget" in response.json()["detail"]
  301. @pytest.mark.asyncio
  302. @pytest.mark.integration
  303. async def test_add_to_queue_with_manual_start(
  304. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  305. ):
  306. """Verify item can be added to queue with manual_start=True."""
  307. printer = await printer_factory()
  308. archive = await archive_factory()
  309. data = {
  310. "printer_id": printer.id,
  311. "archive_id": archive.id,
  312. "manual_start": True,
  313. }
  314. response = await async_client.post("/api/v1/queue/", json=data)
  315. assert response.status_code == 200
  316. result = response.json()
  317. assert result["printer_id"] == printer.id
  318. assert result["archive_id"] == archive.id
  319. assert result["status"] == "pending"
  320. assert result["manual_start"] is True
  321. @pytest.mark.asyncio
  322. @pytest.mark.integration
  323. async def test_add_to_queue_with_skip_filament_check(
  324. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  325. ):
  326. """PrintModal "Print Anyway" persists skip_filament_check on creation (#1698-followup)."""
  327. printer = await printer_factory()
  328. archive = await archive_factory()
  329. data = {
  330. "printer_id": printer.id,
  331. "archive_id": archive.id,
  332. "skip_filament_check": True,
  333. }
  334. response = await async_client.post("/api/v1/queue/", json=data)
  335. assert response.status_code == 200
  336. result = response.json()
  337. assert result["skip_filament_check"] is True
  338. @pytest.mark.asyncio
  339. @pytest.mark.integration
  340. async def test_add_to_queue_skip_filament_check_defaults_false(
  341. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  342. ):
  343. """Default add-to-queue has skip_filament_check=False — no silent bypass."""
  344. printer = await printer_factory()
  345. archive = await archive_factory()
  346. data = {"printer_id": printer.id, "archive_id": archive.id}
  347. response = await async_client.post("/api/v1/queue/", json=data)
  348. assert response.status_code == 200
  349. result = response.json()
  350. assert result["skip_filament_check"] is False
  351. @pytest.mark.asyncio
  352. @pytest.mark.integration
  353. async def test_update_queue_item_cost_center_id(
  354. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
  355. ):
  356. """Verify a pending queue item can be updated with cost_center_id."""
  357. await enable_billing(db_session)
  358. printer = await printer_factory()
  359. archive = await archive_factory(cost=1.25, filament_used_grams=50.0)
  360. item = await queue_item_factory(printer_id=printer.id, archive_id=archive.id)
  361. cost_center = CostCenter(name="Update CC", is_active=True, is_private=False)
  362. db_session.add(cost_center)
  363. await db_session.commit()
  364. await db_session.refresh(cost_center)
  365. response = await async_client.patch(
  366. f"/api/v1/queue/{item.id}",
  367. json={"cost_center_id": cost_center.id, "estimated_cost": 0.01},
  368. )
  369. assert response.status_code == 200
  370. assert response.json()["cost_center_id"] == cost_center.id
  371. assert response.json()["estimated_cost"] == 1.25
  372. @pytest.mark.asyncio
  373. @pytest.mark.integration
  374. async def test_colour_substitutions_are_returned_parsed(self, async_client: AsyncClient, queue_item_factory):
  375. """#3279: the queue row reads which slots printed in another colour."""
  376. subs = [{"slot_id": 1, "type": "PLA", "wanted": "#A4AAAC", "got": "#000000", "tray": 1}]
  377. item = await queue_item_factory(color_substitutions=json.dumps(subs))
  378. unreadable = await queue_item_factory(color_substitutions="{")
  379. not_a_list = await queue_item_factory(color_substitutions='{"slot_id": 1}')
  380. bad_entries = await queue_item_factory(color_substitutions='[1, {"slot_id": 1}]')
  381. assert (await async_client.get(f"/api/v1/queue/{item.id}")).json()["color_substitutions"] == subs
  382. assert (await async_client.get(f"/api/v1/queue/{unreadable.id}")).json()["color_substitutions"] is None
  383. assert (await async_client.get(f"/api/v1/queue/{not_a_list.id}")).json()["color_substitutions"] is None
  384. assert (await async_client.get(f"/api/v1/queue/{bad_entries.id}")).json()["color_substitutions"] is None
  385. # One bad row must not fail the whole list.
  386. listed = (await async_client.get("/api/v1/queue/")).json()
  387. assert {i["id"]: i["color_substitutions"] for i in listed}[item.id] == subs
  388. @pytest.mark.asyncio
  389. @pytest.mark.integration
  390. async def test_add_to_queue_with_project_id(
  391. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  392. ):
  393. """#932: queue items created from the project view carry project_id forward."""
  394. from backend.app.models.project import Project
  395. printer = await printer_factory()
  396. archive = await archive_factory()
  397. project = Project(name="Queue Project")
  398. db_session.add(project)
  399. await db_session.commit()
  400. await db_session.refresh(project)
  401. data = {
  402. "printer_id": printer.id,
  403. "archive_id": archive.id,
  404. "project_id": project.id,
  405. }
  406. response = await async_client.post("/api/v1/queue/", json=data)
  407. assert response.status_code == 200
  408. result = response.json()
  409. # The response schema may or may not echo project_id; the stored row is
  410. # what matters, so verify via DB.
  411. from sqlalchemy import select
  412. from backend.app.models.print_queue import PrintQueueItem
  413. row = (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == result["id"]))).scalar_one()
  414. assert row.project_id == project.id
  415. @pytest.mark.asyncio
  416. @pytest.mark.integration
  417. async def test_add_to_queue_invalid_project_id_returns_404(
  418. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  419. ):
  420. """#932: bogus project_id must be rejected before the FK constraint fires.
  421. Regression guard for the pre-check added to add_to_queue. Without the
  422. validation, a nonexistent project_id would reach db.commit() and raise
  423. an IntegrityError → 500. The pre-check must convert that to a 404 so
  424. the UI gets a clean error it can surface.
  425. """
  426. printer = await printer_factory()
  427. archive = await archive_factory()
  428. data = {
  429. "printer_id": printer.id,
  430. "archive_id": archive.id,
  431. "project_id": 999999, # nonexistent
  432. }
  433. response = await async_client.post("/api/v1/queue/", json=data)
  434. assert response.status_code == 404
  435. assert "project" in response.json()["detail"].lower()
  436. @pytest.mark.asyncio
  437. @pytest.mark.integration
  438. async def test_add_to_queue_with_ams_mapping(
  439. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  440. ):
  441. """Verify item can be added to queue with ams_mapping."""
  442. printer = await printer_factory()
  443. archive = await archive_factory()
  444. data = {
  445. "printer_id": printer.id,
  446. "archive_id": archive.id,
  447. "ams_mapping": [5, -1, 2, -1], # Slot 1 -> tray 5, slot 3 -> tray 2
  448. }
  449. response = await async_client.post("/api/v1/queue/", json=data)
  450. assert response.status_code == 200
  451. result = response.json()
  452. assert result["printer_id"] == printer.id
  453. assert result["archive_id"] == archive.id
  454. assert result["ams_mapping"] == [5, -1, 2, -1]
  455. @pytest.mark.asyncio
  456. @pytest.mark.integration
  457. async def test_add_to_queue_falls_back_to_archive_slicer_ams_mapping_when_unset(
  458. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  459. ):
  460. """When the caller sends no explicit ams_mapping, but the archive
  461. carries the slicer's own saved pick for this exact printer
  462. (extra_data.slicer_ams_mapping, written by a VP with "Save AMS
  463. mapping" on), the queue item should inherit it — the same
  464. exact-physical-spool reuse the "Mapping" button gives you, but
  465. automatic when nothing was hand-edited.
  466. """
  467. printer = await printer_factory()
  468. archive = await archive_factory(
  469. extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
  470. )
  471. data = {
  472. "printer_id": printer.id,
  473. "archive_id": archive.id,
  474. }
  475. response = await async_client.post("/api/v1/queue/", json=data)
  476. assert response.status_code == 200
  477. result = response.json()
  478. assert result["ams_mapping"] == [5, -1, 2, -1]
  479. @pytest.mark.asyncio
  480. @pytest.mark.integration
  481. async def test_add_to_queue_ignores_archive_slicer_ams_mapping_for_different_printer(
  482. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  483. ):
  484. """A saved mapping's tray IDs only mean something relative to the
  485. printer they were resolved against. Reprinting the same archive on a
  486. *different* printer must not inherit it — tray 5 on printer A can
  487. hold a completely different spool than tray 5 on printer B.
  488. """
  489. origin_printer = await printer_factory()
  490. other_printer = await printer_factory()
  491. archive = await archive_factory(
  492. extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
  493. )
  494. data = {
  495. "printer_id": other_printer.id,
  496. "archive_id": archive.id,
  497. }
  498. response = await async_client.post("/api/v1/queue/", json=data)
  499. assert response.status_code == 200
  500. result = response.json()
  501. assert result["ams_mapping"] is None
  502. @pytest.mark.asyncio
  503. @pytest.mark.integration
  504. async def test_add_to_queue_ignores_archive_slicer_ams_mapping_for_model_based_dispatch(
  505. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  506. ):
  507. """A model-based item (no fixed printer_id) can't know in advance
  508. which printer the scheduler will pick, so a saved mapping resolved
  509. against one specific printer must never be inherited here either.
  510. """
  511. origin_printer = await printer_factory()
  512. archive = await archive_factory(
  513. extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
  514. )
  515. data = {
  516. "target_model": "X1C",
  517. "archive_id": archive.id,
  518. }
  519. response = await async_client.post("/api/v1/queue/", json=data)
  520. assert response.status_code == 200
  521. result = response.json()
  522. assert result["ams_mapping"] is None
  523. @pytest.mark.asyncio
  524. @pytest.mark.integration
  525. async def test_add_to_queue_explicit_ams_mapping_wins_over_archive_fallback(
  526. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  527. ):
  528. """An explicit ams_mapping in the request (e.g. from the filament
  529. mapping panel) must take priority over the archive's saved slicer
  530. pick — the fallback only fires when the caller sent nothing at all.
  531. """
  532. printer = await printer_factory()
  533. archive = await archive_factory(
  534. extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
  535. )
  536. data = {
  537. "printer_id": printer.id,
  538. "archive_id": archive.id,
  539. "ams_mapping": [9, -1, 1, -1],
  540. }
  541. response = await async_client.post("/api/v1/queue/", json=data)
  542. assert response.status_code == 200
  543. result = response.json()
  544. assert result["ams_mapping"] == [9, -1, 1, -1]
  545. @pytest.mark.asyncio
  546. @pytest.mark.integration
  547. async def test_add_to_queue_archive_extra_data_without_slicer_mapping_key_not_used(
  548. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  549. ):
  550. """extra_data present but without a slicer_ams_mapping key (the
  551. common case — most archives have other metadata but no saved slicer
  552. mapping) must not accidentally trip the fallback."""
  553. printer = await printer_factory()
  554. archive = await archive_factory(extra_data={"filament_slots": []})
  555. data = {
  556. "printer_id": printer.id,
  557. "archive_id": archive.id,
  558. }
  559. response = await async_client.post("/api/v1/queue/", json=data)
  560. assert response.status_code == 200
  561. result = response.json()
  562. assert result["ams_mapping"] is None
  563. @pytest.mark.asyncio
  564. @pytest.mark.integration
  565. async def test_add_to_queue_force_color_match_overrides_beat_the_archive_fallback(
  566. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  567. ):
  568. """Force-color-match overrides are the caller asking the scheduler to
  569. match strictly against the printer's live trays, and they are only ever
  570. applied inside `_compute_ams_mapping_for_printer` — the function a
  571. stored mapping makes the scheduler skip. Inheriting the saved mapping
  572. here would silently retire the strictness that was just requested
  573. (#2700 review).
  574. """
  575. printer = await printer_factory()
  576. archive = await archive_factory(
  577. extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
  578. )
  579. data = {
  580. "printer_id": printer.id,
  581. "archive_id": archive.id,
  582. "filament_overrides": [
  583. {"slot_id": 1, "type": "PLA", "color": "#FF0000", "force_color_match": True},
  584. ],
  585. }
  586. response = await async_client.post("/api/v1/queue/", json=data)
  587. assert response.status_code == 200
  588. result = response.json()
  589. assert result["ams_mapping"] is None
  590. @pytest.mark.asyncio
  591. @pytest.mark.integration
  592. async def test_add_to_queue_plain_overrides_still_allow_the_archive_fallback(
  593. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  594. ):
  595. """Only force_color_match stands the fallback down. A plain preference
  596. override is a filament swap, not a request for live colour matching, so
  597. the saved mapping is still the best starting point.
  598. """
  599. printer = await printer_factory()
  600. archive = await archive_factory(
  601. extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": printer.id}}
  602. )
  603. data = {
  604. "printer_id": printer.id,
  605. "archive_id": archive.id,
  606. "filament_overrides": [{"slot_id": 1, "type": "PLA", "color": "#FF0000"}],
  607. }
  608. response = await async_client.post("/api/v1/queue/", json=data)
  609. assert response.status_code == 200
  610. result = response.json()
  611. assert result["ams_mapping"] == [5, -1, 2, -1]
  612. @pytest.mark.asyncio
  613. @pytest.mark.integration
  614. async def test_add_to_queue_keeps_overrides_on_a_specific_printer_job(
  615. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  616. ):
  617. """An override picked for "Any P2S" survives the dialog's switch to one
  618. P2S (#3133). The row must keep it: when the dialog could not resolve
  619. every tray, the scheduler recomputes the mapping at dispatch, and without
  620. the override it would match the 3MF's colour again. It used to be
  621. dropped whenever the item had no target model.
  622. """
  623. printer = await printer_factory()
  624. archive = await archive_factory()
  625. data = {
  626. "printer_id": printer.id,
  627. "archive_id": archive.id,
  628. "filament_overrides": [{"slot_id": 1, "type": "PLA", "color": "#F5F5DC"}],
  629. }
  630. response = await async_client.post("/api/v1/queue/", json=data)
  631. assert response.status_code == 200
  632. result = response.json()
  633. assert result["filament_overrides"] == [{"slot_id": 1, "type": "PLA", "color": "#F5F5DC"}]
  634. # The type list gates which printer of a model may take the job; a job
  635. # for one printer has no such choice left to make.
  636. from backend.app.models.print_queue import PrintQueueItem
  637. row = await db_session.get(PrintQueueItem, result["id"])
  638. assert row.required_filament_types is None
  639. @pytest.mark.asyncio
  640. @pytest.mark.integration
  641. async def test_queue_response_flags_saved_mapping_only_for_its_own_printer(
  642. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  643. ):
  644. """`archive_has_slicer_ams_mapping` drives a badge that claims the
  645. print reuses the slicer's exact trays. Global tray IDs mean nothing on
  646. another printer, so the flag must be false for a row targeting one —
  647. otherwise the badge is there while nothing is reused (#2700 review).
  648. """
  649. origin_printer = await printer_factory()
  650. other_printer = await printer_factory()
  651. archive = await archive_factory(
  652. extra_data={"slicer_ams_mapping": {"mapping": [5, -1, 2, -1], "printer_id": origin_printer.id}}
  653. )
  654. own = await async_client.post(
  655. "/api/v1/queue/", json={"printer_id": origin_printer.id, "archive_id": archive.id}
  656. )
  657. assert own.status_code == 200
  658. assert own.json()["archive_has_slicer_ams_mapping"] is True
  659. foreign = await async_client.post(
  660. "/api/v1/queue/", json={"printer_id": other_printer.id, "archive_id": archive.id}
  661. )
  662. assert foreign.status_code == 200
  663. assert foreign.json()["archive_has_slicer_ams_mapping"] is False
  664. # Model-based: the scheduler hasn't picked a printer yet, so the
  665. # mapping is not reused there either.
  666. model_based = await async_client.post("/api/v1/queue/", json={"target_model": "X1C", "archive_id": archive.id})
  667. assert model_based.status_code == 200
  668. assert model_based.json()["archive_has_slicer_ams_mapping"] is False
  669. @pytest.mark.asyncio
  670. @pytest.mark.integration
  671. async def test_a_vp_job_shows_the_pick_it_keeps_for_its_archive(
  672. self, async_client: AsyncClient, printer_factory, db_session
  673. ):
  674. """A virtual printer's job prints a File Manager file and has no
  675. archive until it prints, but keeps the slicer's pick for it (#2700):
  676. the badge shows, and the edit dialog's Mapping button gets the pick."""
  677. from backend.app.models.library import LibraryFile
  678. from backend.app.models.print_queue import PrintQueueItem
  679. printer = await printer_factory()
  680. other_printer = await printer_factory()
  681. kept = json.dumps({"mapping": [4, -1, 12], "printer_id": printer.id})
  682. library_file = LibraryFile(
  683. filename="vp.gcode.3mf",
  684. file_path="library/vp.3mf",
  685. file_type="3mf",
  686. file_size=1,
  687. source_type="virtual_printer",
  688. )
  689. db_session.add(library_file)
  690. await db_session.flush()
  691. fixed = PrintQueueItem(
  692. printer_id=printer.id, library_file_id=library_file.id, status="pending", slicer_ams_mapping=kept
  693. )
  694. model_based = PrintQueueItem(
  695. target_model="X1C", library_file_id=library_file.id, status="pending", slicer_ams_mapping=kept
  696. )
  697. # Moved to another printer: the trays were resolved on the first one.
  698. moved = PrintQueueItem(
  699. printer_id=other_printer.id, library_file_id=library_file.id, status="pending", slicer_ams_mapping=kept
  700. )
  701. db_session.add_all([fixed, model_based, moved])
  702. await db_session.commit()
  703. rows = {row["id"]: row for row in (await async_client.get("/api/v1/queue/")).json()}
  704. assert rows[fixed.id]["slicer_ams_mapping"] == [4, -1, 12]
  705. assert rows[fixed.id]["archive_has_slicer_ams_mapping"] is True
  706. assert rows[model_based.id]["slicer_ams_mapping"] is None
  707. assert rows[model_based.id]["archive_has_slicer_ams_mapping"] is False
  708. assert rows[moved.id]["slicer_ams_mapping"] is None
  709. assert rows[moved.id]["archive_has_slicer_ams_mapping"] is False
  710. @pytest.mark.asyncio
  711. @pytest.mark.integration
  712. async def test_add_to_queue_with_plate_id(
  713. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  714. ):
  715. """Verify item can be added to queue with plate_id for multi-plate 3MF."""
  716. printer = await printer_factory()
  717. archive = await archive_factory()
  718. data = {
  719. "printer_id": printer.id,
  720. "archive_id": archive.id,
  721. "plate_id": 3,
  722. }
  723. response = await async_client.post("/api/v1/queue/", json=data)
  724. assert response.status_code == 200
  725. result = response.json()
  726. assert result["plate_id"] == 3
  727. @pytest.mark.asyncio
  728. @pytest.mark.integration
  729. async def test_add_to_queue_with_print_options(
  730. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  731. ):
  732. """Verify item can be added to queue with print options."""
  733. printer = await printer_factory()
  734. archive = await archive_factory()
  735. data = {
  736. "printer_id": printer.id,
  737. "archive_id": archive.id,
  738. "bed_levelling": "off",
  739. "flow_cali": "on",
  740. "vibration_cali": False,
  741. "layer_inspect": True,
  742. "timelapse": True,
  743. "use_ams": False,
  744. }
  745. response = await async_client.post("/api/v1/queue/", json=data)
  746. assert response.status_code == 200
  747. result = response.json()
  748. assert result["bed_levelling"] == "off"
  749. assert result["flow_cali"] == "on"
  750. assert result["vibration_cali"] is False
  751. assert result["layer_inspect"] is True
  752. assert result["timelapse"] is True
  753. assert result["use_ams"] is False
  754. @pytest.mark.asyncio
  755. @pytest.mark.integration
  756. async def test_update_queue_item_plate_id(self, async_client: AsyncClient, queue_item_factory, db_session):
  757. """Verify queue item plate_id can be updated."""
  758. item = await queue_item_factory()
  759. response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"plate_id": 5})
  760. assert response.status_code == 200
  761. result = response.json()
  762. assert result["plate_id"] == 5
  763. @pytest.mark.asyncio
  764. @pytest.mark.integration
  765. async def test_update_queue_item_print_options(self, async_client: AsyncClient, queue_item_factory, db_session):
  766. """Verify queue item print options can be updated."""
  767. item = await queue_item_factory()
  768. response = await async_client.patch(
  769. f"/api/v1/queue/{item.id}",
  770. json={
  771. "bed_levelling": "off",
  772. "timelapse": True,
  773. },
  774. )
  775. assert response.status_code == 200
  776. result = response.json()
  777. assert result["bed_levelling"] == "off"
  778. assert result["timelapse"] is True
  779. @pytest.mark.asyncio
  780. @pytest.mark.integration
  781. async def test_reassign_rejected_while_dispatching(
  782. self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
  783. ):
  784. """#2615: a claimed (in-flight) row rejects edits with 409, so its printer
  785. can't be reassigned out from under the running FTP upload."""
  786. from datetime import datetime, timezone
  787. item = await queue_item_factory(dispatching_at=datetime.now(timezone.utc))
  788. other = await printer_factory()
  789. original_printer_id = item.printer_id
  790. response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"printer_id": other.id})
  791. assert response.status_code == 409
  792. await db_session.refresh(item)
  793. assert item.printer_id == original_printer_id, "printer_id must not change on a dispatching row"
  794. @pytest.mark.asyncio
  795. @pytest.mark.integration
  796. async def test_bulk_update_skips_dispatching_item(
  797. self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
  798. ):
  799. """#2615: bulk edits skip a claimed row rather than splitting it."""
  800. from datetime import datetime, timezone
  801. item = await queue_item_factory(dispatching_at=datetime.now(timezone.utc))
  802. other = await printer_factory()
  803. original_printer_id = item.printer_id
  804. response = await async_client.patch("/api/v1/queue/bulk", json={"item_ids": [item.id], "printer_id": other.id})
  805. assert response.status_code == 200
  806. body = response.json()
  807. assert body["skipped_count"] == 1
  808. assert body["updated_count"] == 0
  809. await db_session.refresh(item)
  810. assert item.printer_id == original_printer_id
  811. @pytest.mark.asyncio
  812. @pytest.mark.integration
  813. async def test_update_allowed_on_unclaimed_pending_item(
  814. self, async_client: AsyncClient, queue_item_factory, db_session
  815. ):
  816. """Regression guard: a normal pending row (no claim) still edits fine."""
  817. item = await queue_item_factory()
  818. response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"plate_id": 7})
  819. assert response.status_code == 200
  820. assert response.json()["plate_id"] == 7
  821. @pytest.mark.asyncio
  822. @pytest.mark.integration
  823. async def test_get_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  824. """Verify single queue item can be retrieved."""
  825. item = await queue_item_factory()
  826. response = await async_client.get(f"/api/v1/queue/{item.id}")
  827. assert response.status_code == 200
  828. assert response.json()["id"] == item.id
  829. @pytest.mark.asyncio
  830. @pytest.mark.integration
  831. async def test_get_queue_item_not_found(self, async_client: AsyncClient):
  832. """Verify 404 for non-existent queue item."""
  833. response = await async_client.get("/api/v1/queue/9999")
  834. assert response.status_code == 404
  835. @pytest.mark.asyncio
  836. @pytest.mark.integration
  837. async def test_update_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  838. """Verify queue item can be updated."""
  839. item = await queue_item_factory()
  840. response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"auto_off_after": True})
  841. assert response.status_code == 200
  842. result = response.json()
  843. assert result["auto_off_after"] is True
  844. @pytest.mark.asyncio
  845. @pytest.mark.integration
  846. async def test_update_queue_item_manual_start(self, async_client: AsyncClient, queue_item_factory, db_session):
  847. """Verify queue item manual_start can be updated."""
  848. item = await queue_item_factory(manual_start=False)
  849. response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"manual_start": True})
  850. assert response.status_code == 200
  851. result = response.json()
  852. assert result["manual_start"] is True
  853. @pytest.mark.asyncio
  854. @pytest.mark.integration
  855. async def test_update_queue_item_skip_filament_check(
  856. self, async_client: AsyncClient, queue_item_factory, db_session
  857. ):
  858. """#2799: the print dialog sends it on edit when the mapping deliberately
  859. puts a slot on another material."""
  860. item = await queue_item_factory()
  861. response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"skip_filament_check": True})
  862. assert response.status_code == 200
  863. assert response.json()["skip_filament_check"] is True
  864. # Omitted on a later edit, it stays as it was.
  865. response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"manual_start": True})
  866. assert response.json()["skip_filament_check"] is True
  867. @pytest.mark.asyncio
  868. @pytest.mark.integration
  869. async def test_delete_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  870. """Verify queue item can be deleted."""
  871. item = await queue_item_factory()
  872. response = await async_client.delete(f"/api/v1/queue/{item.id}")
  873. assert response.status_code == 200
  874. assert response.json()["message"] == "Queue item deleted"
  875. @pytest.mark.asyncio
  876. @pytest.mark.integration
  877. async def test_delete_queue_item_releases_reserved_budget(
  878. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
  879. ):
  880. """Deleting a pending cost-center queue item releases its reserved budget."""
  881. await enable_billing(db_session)
  882. printer = await printer_factory()
  883. archive = await archive_factory(cost=3.0, filament_used_grams=50.0)
  884. cost_center = CostCenter(
  885. name="Delete Releases Budget CC", is_active=True, is_private=False, monthly_budget=10.0
  886. )
  887. db_session.add(cost_center)
  888. await db_session.commit()
  889. await db_session.refresh(cost_center)
  890. item = await queue_item_factory(
  891. printer_id=printer.id,
  892. archive_id=archive.id,
  893. cost_center_id=cost_center.id,
  894. estimated_cost=8.0,
  895. status="pending",
  896. )
  897. blocked = await async_client.post(
  898. "/api/v1/queue/",
  899. json={
  900. "printer_id": printer.id,
  901. "archive_id": archive.id,
  902. "cost_center_id": cost_center.id,
  903. "estimated_cost": 0.01,
  904. },
  905. )
  906. assert blocked.status_code == 400
  907. deleted = await async_client.delete(f"/api/v1/queue/{item.id}")
  908. assert deleted.status_code == 200
  909. allowed = await async_client.post(
  910. "/api/v1/queue/",
  911. json={
  912. "printer_id": printer.id,
  913. "archive_id": archive.id,
  914. "cost_center_id": cost_center.id,
  915. "estimated_cost": 0.01,
  916. },
  917. )
  918. assert allowed.status_code == 200
  919. @pytest.mark.asyncio
  920. @pytest.mark.integration
  921. async def test_delete_queue_item_not_found(self, async_client: AsyncClient):
  922. """Verify 404 for deleting non-existent queue item."""
  923. response = await async_client.delete("/api/v1/queue/9999")
  924. assert response.status_code == 404
  925. class TestQueueStartEndpoint:
  926. """Tests for the /queue/{item_id}/start endpoint."""
  927. @pytest.fixture
  928. async def printer_factory(self, db_session):
  929. """Factory to create test printers."""
  930. _counter = [0]
  931. async def _create_printer(**kwargs):
  932. from backend.app.models.printer import Printer
  933. _counter[0] += 1
  934. counter = _counter[0]
  935. defaults = {
  936. "name": f"Test Printer {counter}",
  937. "ip_address": f"192.168.1.{100 + counter}",
  938. "serial_number": f"TESTSERIAL{counter:04d}",
  939. "access_code": "12345678",
  940. "model": "X1C",
  941. }
  942. defaults.update(kwargs)
  943. printer = Printer(**defaults)
  944. db_session.add(printer)
  945. await db_session.commit()
  946. await db_session.refresh(printer)
  947. return printer
  948. return _create_printer
  949. @pytest.fixture
  950. async def archive_factory(self, db_session):
  951. """Factory to create test archives."""
  952. _counter = [0]
  953. async def _create_archive(**kwargs):
  954. from backend.app.models.archive import PrintArchive
  955. _counter[0] += 1
  956. counter = _counter[0]
  957. defaults = {
  958. "filename": f"test_print_{counter}.3mf",
  959. "print_name": f"Test Print {counter}",
  960. "file_path": f"/tmp/test_print_{counter}.3mf", # nosec B108 -- a stored path, never opened
  961. "file_size": 1024,
  962. "content_hash": f"testhash{counter:08d}",
  963. "status": "completed",
  964. }
  965. defaults.update(kwargs)
  966. archive = PrintArchive(**defaults)
  967. db_session.add(archive)
  968. await db_session.commit()
  969. await db_session.refresh(archive)
  970. return archive
  971. return _create_archive
  972. @pytest.fixture
  973. async def queue_item_factory(self, db_session, printer_factory, archive_factory):
  974. """Factory to create test queue items."""
  975. _counter = [0]
  976. async def _create_queue_item(**kwargs):
  977. from backend.app.models.print_queue import PrintQueueItem
  978. _counter[0] += 1
  979. counter = _counter[0]
  980. if "printer_id" not in kwargs:
  981. printer = await printer_factory()
  982. kwargs["printer_id"] = printer.id
  983. if "archive_id" not in kwargs:
  984. archive = await archive_factory()
  985. kwargs["archive_id"] = archive.id
  986. defaults = {
  987. "status": "pending",
  988. "position": counter,
  989. }
  990. defaults.update(kwargs)
  991. item = PrintQueueItem(**defaults)
  992. db_session.add(item)
  993. await db_session.commit()
  994. await db_session.refresh(item)
  995. return item
  996. return _create_queue_item
  997. @pytest.mark.asyncio
  998. @pytest.mark.integration
  999. async def test_start_staged_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  1000. """Verify starting a staged (manual_start=True) queue item clears the flag."""
  1001. item = await queue_item_factory(manual_start=True)
  1002. assert item.manual_start is True
  1003. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  1004. assert response.status_code == 200
  1005. result = response.json()
  1006. assert result["manual_start"] is False
  1007. assert result["status"] == "pending"
  1008. @pytest.mark.asyncio
  1009. @pytest.mark.integration
  1010. async def test_start_with_a_filament_not_loaded_offers_print_anyway(
  1011. self, async_client: AsyncClient, queue_item_factory, db_session
  1012. ):
  1013. """#2799: Start on an item whose filament has no tray answers 409 with the
  1014. missing filaments instead of releasing it to be held again."""
  1015. from unittest.mock import AsyncMock, patch
  1016. item = await queue_item_factory(manual_start=True)
  1017. with patch(
  1018. "backend.app.services.print_scheduler.scheduler.missing_filament_for_start",
  1019. AsyncMock(return_value=["PETG #2850E0"]),
  1020. ):
  1021. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  1022. assert response.status_code == 409
  1023. assert response.json()["detail"] == {"code": "unmatched_filament", "missing": ["PETG #2850E0"]}
  1024. await db_session.refresh(item)
  1025. assert item.manual_start is True
  1026. @pytest.mark.asyncio
  1027. @pytest.mark.integration
  1028. async def test_a_failing_filament_check_does_not_break_start(
  1029. self, async_client: AsyncClient, queue_item_factory, db_session
  1030. ):
  1031. """The check is a convenience; the scheduler still holds the item if the
  1032. filament is missing, so an error in it must not turn Start into a 500."""
  1033. from unittest.mock import AsyncMock, patch
  1034. item = await queue_item_factory(manual_start=True)
  1035. with patch(
  1036. "backend.app.services.print_scheduler.scheduler.missing_filament_for_start",
  1037. AsyncMock(side_effect=RuntimeError("boom")),
  1038. ):
  1039. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  1040. assert response.status_code == 200
  1041. assert response.json()["manual_start"] is False
  1042. @pytest.mark.asyncio
  1043. @pytest.mark.integration
  1044. async def test_print_anyway_past_a_filament_not_loaded(
  1045. self, async_client: AsyncClient, queue_item_factory, db_session
  1046. ):
  1047. """The retry with skip_filament_check is not asked again, and is remembered."""
  1048. from unittest.mock import AsyncMock, patch
  1049. item = await queue_item_factory(manual_start=True)
  1050. check = AsyncMock(return_value=["PETG #2850E0"])
  1051. with patch("backend.app.services.print_scheduler.scheduler.missing_filament_for_start", check):
  1052. response = await async_client.post(f"/api/v1/queue/{item.id}/start?skip_filament_check=true")
  1053. assert response.status_code == 200
  1054. check.assert_not_awaited()
  1055. result = response.json()
  1056. assert result["manual_start"] is False
  1057. assert result["skip_filament_check"] is True
  1058. @pytest.mark.asyncio
  1059. @pytest.mark.integration
  1060. async def test_start_non_staged_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  1061. """Verify starting a non-staged queue item still works (idempotent)."""
  1062. item = await queue_item_factory(manual_start=False)
  1063. assert item.manual_start is False
  1064. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  1065. assert response.status_code == 200
  1066. result = response.json()
  1067. assert result["manual_start"] is False
  1068. @pytest.mark.asyncio
  1069. @pytest.mark.integration
  1070. async def test_start_queue_item_with_cost_center_requires_estimated_cost(
  1071. self, async_client: AsyncClient, queue_item_factory, db_session
  1072. ):
  1073. """Starting a cost-center queue item requires a stored estimate."""
  1074. await enable_billing(db_session)
  1075. cost_center = CostCenter(name="Start Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
  1076. db_session.add(cost_center)
  1077. await db_session.commit()
  1078. await db_session.refresh(cost_center)
  1079. item = await queue_item_factory(manual_start=True, cost_center_id=cost_center.id, estimated_cost=None)
  1080. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  1081. assert response.status_code == 400
  1082. assert "Estimated cost is required" in response.json()["detail"]
  1083. @pytest.mark.asyncio
  1084. @pytest.mark.integration
  1085. async def test_start_queue_item_not_found(self, async_client: AsyncClient):
  1086. """Verify 404 for non-existent queue item."""
  1087. response = await async_client.post("/api/v1/queue/9999/start")
  1088. assert response.status_code == 404
  1089. @pytest.mark.asyncio
  1090. @pytest.mark.integration
  1091. async def test_start_non_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  1092. """Verify 400 error when trying to start a non-pending queue item."""
  1093. item = await queue_item_factory(status="printing", manual_start=True)
  1094. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  1095. assert response.status_code == 400
  1096. assert "pending" in response.json()["detail"].lower()
  1097. @pytest.mark.asyncio
  1098. @pytest.mark.integration
  1099. async def test_start_completed_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  1100. """Verify 400 error when trying to start a completed queue item."""
  1101. item = await queue_item_factory(status="completed", manual_start=True)
  1102. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  1103. assert response.status_code == 400
  1104. @pytest.mark.asyncio
  1105. @pytest.mark.integration
  1106. async def test_start_returns_409_on_filament_deficit(
  1107. self,
  1108. async_client: AsyncClient,
  1109. queue_item_factory,
  1110. db_session,
  1111. monkeypatch,
  1112. ):
  1113. """Filament deficit must surface as 409 + structured payload (#1496)."""
  1114. from backend.app.services import filament_deficit as fd_module
  1115. item = await queue_item_factory(manual_start=True)
  1116. async def _fake_deficit(_db, _item):
  1117. return [
  1118. fd_module.FilamentDeficit(
  1119. slot_id=1,
  1120. ams_id=0,
  1121. tray_id=0,
  1122. filament_type="PLA",
  1123. required_grams=270.0,
  1124. remaining_grams=200.0,
  1125. ),
  1126. ]
  1127. monkeypatch.setattr(
  1128. "backend.app.api.routes.print_queue.compute_deficit_for_queue_item",
  1129. _fake_deficit,
  1130. )
  1131. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  1132. assert response.status_code == 409
  1133. body = response.json()
  1134. assert body["detail"]["code"] == "insufficient_filament"
  1135. assert len(body["detail"]["deficit"]) == 1
  1136. assert body["detail"]["deficit"][0]["slot_id"] == 1
  1137. assert body["detail"]["deficit"][0]["required_grams"] == 270.0
  1138. assert body["detail"]["deficit"][0]["remaining_grams"] == 200.0
  1139. # Item still pending, manual_start unchanged.
  1140. await db_session.refresh(item)
  1141. assert item.status == "pending"
  1142. assert item.manual_start is True
  1143. @pytest.mark.asyncio
  1144. @pytest.mark.integration
  1145. async def test_start_with_skip_flag_bypasses_deficit_check(
  1146. self,
  1147. async_client: AsyncClient,
  1148. queue_item_factory,
  1149. db_session,
  1150. monkeypatch,
  1151. ):
  1152. """With skip_filament_check=true the route dispatches even when short (#1496)."""
  1153. from backend.app.services import filament_deficit as fd_module
  1154. item = await queue_item_factory(manual_start=True, filament_short=True)
  1155. called_with = {}
  1156. async def _fake_deficit(_db, _item):
  1157. called_with["called"] = True
  1158. return [
  1159. fd_module.FilamentDeficit(
  1160. slot_id=1,
  1161. ams_id=0,
  1162. tray_id=0,
  1163. filament_type="PLA",
  1164. required_grams=270.0,
  1165. remaining_grams=200.0,
  1166. ),
  1167. ]
  1168. monkeypatch.setattr(
  1169. "backend.app.api.routes.print_queue.compute_deficit_for_queue_item",
  1170. _fake_deficit,
  1171. )
  1172. response = await async_client.post(f"/api/v1/queue/{item.id}/start?skip_filament_check=true")
  1173. assert response.status_code == 200
  1174. body = response.json()
  1175. assert body["manual_start"] is False
  1176. assert body["filament_short"] is False
  1177. # Helper not called on the bypass path — we trust the operator's
  1178. # decision to print anyway.
  1179. assert called_with == {}
  1180. @pytest.mark.asyncio
  1181. @pytest.mark.integration
  1182. async def test_start_with_skip_flag_persists_acknowledgement(
  1183. self,
  1184. async_client: AsyncClient,
  1185. queue_item_factory,
  1186. db_session,
  1187. ):
  1188. """skip_filament_check=true sets the persistent flag on the queue item
  1189. so the scheduler doesn't re-flag it on the next tick (#1698-followup).
  1190. Without persistence the route's flag-clearing only survives until the
  1191. next scheduler tick re-runs the deficit check on identical spool
  1192. state and re-promotes the item — the user has to click Play+Confirm
  1193. every single tick.
  1194. """
  1195. item = await queue_item_factory(manual_start=True, filament_short=True)
  1196. assert item.skip_filament_check is False
  1197. response = await async_client.post(f"/api/v1/queue/{item.id}/start?skip_filament_check=true")
  1198. assert response.status_code == 200
  1199. body = response.json()
  1200. assert body["skip_filament_check"] is True
  1201. await db_session.refresh(item)
  1202. assert item.skip_filament_check is True
  1203. @pytest.mark.asyncio
  1204. @pytest.mark.integration
  1205. async def test_start_without_skip_flag_does_not_set_acknowledgement(
  1206. self,
  1207. async_client: AsyncClient,
  1208. queue_item_factory,
  1209. db_session,
  1210. ):
  1211. """A successful Play click with no deficit must NOT silently set the
  1212. acknowledgement flag — only an explicit Print Anyway should.
  1213. """
  1214. item = await queue_item_factory(manual_start=False, filament_short=False)
  1215. assert item.skip_filament_check is False
  1216. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  1217. assert response.status_code == 200
  1218. await db_session.refresh(item)
  1219. assert item.skip_filament_check is False
  1220. class TestQueueCancelEndpoint:
  1221. """Tests for the /queue/{item_id}/cancel endpoint."""
  1222. @pytest.fixture
  1223. async def printer_factory(self, db_session):
  1224. """Factory to create test printers."""
  1225. async def _create_printer(**kwargs):
  1226. from backend.app.models.printer import Printer
  1227. defaults = {
  1228. "name": "Cancel Test Printer",
  1229. "ip_address": "192.168.1.200",
  1230. "serial_number": "TESTCANCEL001",
  1231. "access_code": "12345678",
  1232. "model": "X1C",
  1233. }
  1234. defaults.update(kwargs)
  1235. printer = Printer(**defaults)
  1236. db_session.add(printer)
  1237. await db_session.commit()
  1238. await db_session.refresh(printer)
  1239. return printer
  1240. return _create_printer
  1241. @pytest.fixture
  1242. async def archive_factory(self, db_session):
  1243. """Factory to create test archives."""
  1244. async def _create_archive(**kwargs):
  1245. from backend.app.models.archive import PrintArchive
  1246. defaults = {
  1247. "filename": "cancel_test.3mf",
  1248. "print_name": "Cancel Test Print",
  1249. "file_path": "/tmp/cancel_test.3mf", # nosec B108 -- a stored path, never opened
  1250. "file_size": 1024,
  1251. "content_hash": "cancelhash001",
  1252. "status": "completed",
  1253. }
  1254. defaults.update(kwargs)
  1255. archive = PrintArchive(**defaults)
  1256. db_session.add(archive)
  1257. await db_session.commit()
  1258. await db_session.refresh(archive)
  1259. return archive
  1260. return _create_archive
  1261. @pytest.fixture
  1262. async def queue_item_factory(self, db_session, printer_factory, archive_factory):
  1263. """Factory to create test queue items."""
  1264. async def _create_queue_item(**kwargs):
  1265. from backend.app.models.print_queue import PrintQueueItem
  1266. if "printer_id" not in kwargs:
  1267. printer = await printer_factory()
  1268. kwargs["printer_id"] = printer.id
  1269. if "archive_id" not in kwargs:
  1270. archive = await archive_factory()
  1271. kwargs["archive_id"] = archive.id
  1272. defaults = {
  1273. "status": "pending",
  1274. "position": 1,
  1275. }
  1276. defaults.update(kwargs)
  1277. item = PrintQueueItem(**defaults)
  1278. db_session.add(item)
  1279. await db_session.commit()
  1280. await db_session.refresh(item)
  1281. return item
  1282. return _create_queue_item
  1283. @pytest.mark.asyncio
  1284. @pytest.mark.integration
  1285. async def test_cancel_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  1286. """Verify cancelling a pending queue item."""
  1287. item = await queue_item_factory(status="pending")
  1288. response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
  1289. assert response.status_code == 200
  1290. assert response.json()["message"] == "Queue item cancelled"
  1291. @pytest.mark.asyncio
  1292. @pytest.mark.integration
  1293. async def test_cancel_non_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  1294. """Verify 400 error when trying to cancel a non-pending queue item."""
  1295. item = await queue_item_factory(status="printing")
  1296. response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
  1297. assert response.status_code == 400
  1298. class TestQueueLibraryFileSupport:
  1299. """Tests for queue items with library_file_id (instead of archive_id)."""
  1300. @pytest.fixture
  1301. async def printer_factory(self, db_session):
  1302. """Factory to create test printers."""
  1303. _counter = [0]
  1304. async def _create_printer(**kwargs):
  1305. from backend.app.models.printer import Printer
  1306. _counter[0] += 1
  1307. counter = _counter[0]
  1308. defaults = {
  1309. "name": f"Library Test Printer {counter}",
  1310. "ip_address": f"192.168.1.{150 + counter}",
  1311. "serial_number": f"TESTLIB{counter:04d}",
  1312. "access_code": "12345678",
  1313. "model": "X1C",
  1314. }
  1315. defaults.update(kwargs)
  1316. printer = Printer(**defaults)
  1317. db_session.add(printer)
  1318. await db_session.commit()
  1319. await db_session.refresh(printer)
  1320. return printer
  1321. return _create_printer
  1322. @pytest.fixture
  1323. async def library_file_factory(self, db_session):
  1324. """Factory to create test library files."""
  1325. _counter = [0]
  1326. async def _create_library_file(**kwargs):
  1327. from backend.app.models.library import LibraryFile
  1328. _counter[0] += 1
  1329. counter = _counter[0]
  1330. defaults = {
  1331. "filename": f"library_test_{counter}.3mf",
  1332. "file_path": f"/test/library/library_test_{counter}.3mf",
  1333. "file_size": 2048,
  1334. "file_type": "3mf",
  1335. "file_metadata": {"print_name": f"Library Print {counter}", "print_time_seconds": 3600},
  1336. }
  1337. defaults.update(kwargs)
  1338. lib_file = LibraryFile(**defaults)
  1339. db_session.add(lib_file)
  1340. await db_session.commit()
  1341. await db_session.refresh(lib_file)
  1342. return lib_file
  1343. return _create_library_file
  1344. @pytest.mark.asyncio
  1345. @pytest.mark.integration
  1346. async def test_add_to_queue_with_library_file(
  1347. self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
  1348. ):
  1349. """Verify item can be added to queue using library_file_id instead of archive_id."""
  1350. printer = await printer_factory()
  1351. lib_file = await library_file_factory()
  1352. data = {
  1353. "printer_id": printer.id,
  1354. "library_file_id": lib_file.id,
  1355. }
  1356. response = await async_client.post("/api/v1/queue/", json=data)
  1357. assert response.status_code == 200
  1358. result = response.json()
  1359. assert result["printer_id"] == printer.id
  1360. assert result["library_file_id"] == lib_file.id
  1361. assert result["archive_id"] is None
  1362. assert result["status"] == "pending"
  1363. assert result["library_file_name"] == "Library Print 1"
  1364. assert result["print_time_seconds"] == 3600
  1365. @pytest.mark.asyncio
  1366. @pytest.mark.integration
  1367. async def test_add_library_file_rejects_cross_model_mismatch(
  1368. self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
  1369. ):
  1370. """Cross-model gate (#2578) also reads sliced_for_model from library file metadata."""
  1371. await printer_factory(model="H2D")
  1372. lib_file = await library_file_factory(file_metadata={"print_name": "Mismatch", "sliced_for_model": "X1C"})
  1373. response = await async_client.post(
  1374. "/api/v1/queue/",
  1375. json={"target_model": "H2D", "library_file_id": lib_file.id},
  1376. )
  1377. assert response.status_code == 400
  1378. assert "sliced for X1C" in response.json()["detail"]
  1379. @pytest.mark.asyncio
  1380. @pytest.mark.integration
  1381. async def test_add_to_queue_library_file_with_options(
  1382. self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
  1383. ):
  1384. """Verify library file queue item can have all options set."""
  1385. printer = await printer_factory()
  1386. lib_file = await library_file_factory()
  1387. data = {
  1388. "printer_id": printer.id,
  1389. "library_file_id": lib_file.id,
  1390. "ams_mapping": [1, 2, -1, -1],
  1391. "plate_id": 2,
  1392. "bed_levelling": "off",
  1393. "timelapse": True,
  1394. "manual_start": True,
  1395. }
  1396. response = await async_client.post("/api/v1/queue/", json=data)
  1397. assert response.status_code == 200
  1398. result = response.json()
  1399. assert result["library_file_id"] == lib_file.id
  1400. assert result["ams_mapping"] == [1, 2, -1, -1]
  1401. assert result["plate_id"] == 2
  1402. assert result["bed_levelling"] == "off"
  1403. assert result["timelapse"] is True
  1404. assert result["manual_start"] is True
  1405. @pytest.mark.asyncio
  1406. @pytest.mark.integration
  1407. async def test_add_to_queue_requires_archive_or_library_file(
  1408. self, async_client: AsyncClient, printer_factory, db_session
  1409. ):
  1410. """Verify 400 error when neither archive_id nor library_file_id provided."""
  1411. printer = await printer_factory()
  1412. data = {
  1413. "printer_id": printer.id,
  1414. }
  1415. response = await async_client.post("/api/v1/queue/", json=data)
  1416. assert response.status_code == 400
  1417. assert (
  1418. "archive_id" in response.json()["detail"].lower() or "library_file_id" in response.json()["detail"].lower()
  1419. )
  1420. @pytest.mark.asyncio
  1421. @pytest.mark.integration
  1422. async def test_update_queue_item_with_library_file(
  1423. self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
  1424. ):
  1425. """Verify queue item with library_file_id can be updated."""
  1426. from backend.app.models.print_queue import PrintQueueItem
  1427. printer = await printer_factory()
  1428. lib_file = await library_file_factory()
  1429. # Create queue item directly
  1430. item = PrintQueueItem(
  1431. printer_id=printer.id,
  1432. library_file_id=lib_file.id,
  1433. status="pending",
  1434. position=1,
  1435. )
  1436. db_session.add(item)
  1437. await db_session.commit()
  1438. await db_session.refresh(item)
  1439. # Update the item
  1440. response = await async_client.patch(
  1441. f"/api/v1/queue/{item.id}",
  1442. json={"auto_off_after": True, "plate_id": 3},
  1443. )
  1444. assert response.status_code == 200
  1445. result = response.json()
  1446. assert result["auto_off_after"] is True
  1447. assert result["plate_id"] == 3
  1448. assert result["library_file_id"] == lib_file.id
  1449. @pytest.mark.asyncio
  1450. @pytest.mark.integration
  1451. async def test_list_queue_includes_library_file_info(
  1452. self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
  1453. ):
  1454. """Verify queue list includes library file metadata."""
  1455. from backend.app.models.print_queue import PrintQueueItem
  1456. printer = await printer_factory()
  1457. lib_file = await library_file_factory(
  1458. file_metadata={"print_name": "Custom Print Name", "print_time_seconds": 7200}
  1459. )
  1460. item = PrintQueueItem(
  1461. printer_id=printer.id,
  1462. library_file_id=lib_file.id,
  1463. status="pending",
  1464. position=1,
  1465. )
  1466. db_session.add(item)
  1467. await db_session.commit()
  1468. response = await async_client.get("/api/v1/queue/")
  1469. assert response.status_code == 200
  1470. items = response.json()
  1471. assert len(items) >= 1
  1472. # Find our item
  1473. our_item = next((i for i in items if i["library_file_id"] == lib_file.id), None)
  1474. assert our_item is not None
  1475. assert our_item["library_file_name"] == "Custom Print Name"
  1476. assert our_item["print_time_seconds"] == 7200
  1477. class TestBulkUpdateEndpoint:
  1478. """Tests for the /queue/bulk endpoint."""
  1479. @pytest.fixture
  1480. async def printer_factory(self, db_session):
  1481. """Factory to create test printers."""
  1482. _counter = [0]
  1483. async def _create_printer(**kwargs):
  1484. from backend.app.models.printer import Printer
  1485. _counter[0] += 1
  1486. counter = _counter[0]
  1487. defaults = {
  1488. "name": f"Bulk Test Printer {counter}",
  1489. "ip_address": f"192.168.1.{150 + counter}",
  1490. "serial_number": f"TESTBULK{counter:04d}",
  1491. "access_code": "12345678",
  1492. "model": "X1C",
  1493. }
  1494. defaults.update(kwargs)
  1495. printer = Printer(**defaults)
  1496. db_session.add(printer)
  1497. await db_session.commit()
  1498. await db_session.refresh(printer)
  1499. return printer
  1500. return _create_printer
  1501. @pytest.fixture
  1502. async def archive_factory(self, db_session):
  1503. """Factory to create test archives."""
  1504. _counter = [0]
  1505. async def _create_archive(**kwargs):
  1506. from backend.app.models.archive import PrintArchive
  1507. _counter[0] += 1
  1508. counter = _counter[0]
  1509. defaults = {
  1510. "filename": f"bulk_test_{counter}.3mf",
  1511. "print_name": f"Bulk Test Print {counter}",
  1512. "file_path": f"/tmp/bulk_test_{counter}.3mf", # nosec B108 -- a stored path, never opened
  1513. "file_size": 1024,
  1514. "content_hash": f"bulkhash{counter:04d}",
  1515. "status": "completed",
  1516. }
  1517. defaults.update(kwargs)
  1518. archive = PrintArchive(**defaults)
  1519. db_session.add(archive)
  1520. await db_session.commit()
  1521. await db_session.refresh(archive)
  1522. return archive
  1523. return _create_archive
  1524. @pytest.fixture
  1525. async def queue_item_factory(self, db_session, printer_factory, archive_factory):
  1526. """Factory to create test queue items."""
  1527. async def _create_item(**kwargs):
  1528. from backend.app.models.print_queue import PrintQueueItem
  1529. if "printer_id" not in kwargs:
  1530. printer = await printer_factory()
  1531. kwargs["printer_id"] = printer.id
  1532. if "archive_id" not in kwargs:
  1533. archive = await archive_factory()
  1534. kwargs["archive_id"] = archive.id
  1535. defaults = {
  1536. "status": "pending",
  1537. "position": 1,
  1538. "bed_levelling": "on",
  1539. "flow_cali": "off",
  1540. "vibration_cali": True,
  1541. }
  1542. defaults.update(kwargs)
  1543. item = PrintQueueItem(**defaults)
  1544. db_session.add(item)
  1545. await db_session.commit()
  1546. await db_session.refresh(item)
  1547. return item
  1548. return _create_item
  1549. @pytest.mark.asyncio
  1550. @pytest.mark.integration
  1551. async def test_bulk_update_single_field(self, async_client: AsyncClient, queue_item_factory, db_session):
  1552. """Verify bulk update can change a single field on multiple items."""
  1553. item1 = await queue_item_factory(bed_levelling="on")
  1554. item2 = await queue_item_factory(bed_levelling="on")
  1555. response = await async_client.patch(
  1556. "/api/v1/queue/bulk",
  1557. json={"item_ids": [item1.id, item2.id], "bed_levelling": "off"},
  1558. )
  1559. assert response.status_code == 200
  1560. result = response.json()
  1561. assert result["updated_count"] == 2
  1562. assert result["skipped_count"] == 0
  1563. # Verify items were updated
  1564. await db_session.refresh(item1)
  1565. await db_session.refresh(item2)
  1566. assert item1.bed_levelling == "off"
  1567. assert item2.bed_levelling == "off"
  1568. @pytest.mark.asyncio
  1569. @pytest.mark.integration
  1570. async def test_bulk_update_multiple_fields(self, async_client: AsyncClient, queue_item_factory, db_session):
  1571. """Verify bulk update can change multiple fields at once."""
  1572. item1 = await queue_item_factory(bed_levelling="on", flow_cali="off", manual_start=False)
  1573. item2 = await queue_item_factory(bed_levelling="on", flow_cali="off", manual_start=False)
  1574. response = await async_client.patch(
  1575. "/api/v1/queue/bulk",
  1576. json={
  1577. "item_ids": [item1.id, item2.id],
  1578. "bed_levelling": "off",
  1579. "flow_cali": "on",
  1580. "manual_start": True,
  1581. },
  1582. )
  1583. assert response.status_code == 200
  1584. result = response.json()
  1585. assert result["updated_count"] == 2
  1586. await db_session.refresh(item1)
  1587. assert item1.bed_levelling == "off"
  1588. assert item1.flow_cali == "on"
  1589. assert item1.manual_start is True
  1590. @pytest.mark.asyncio
  1591. @pytest.mark.integration
  1592. async def test_bulk_update_skips_non_pending(self, async_client: AsyncClient, queue_item_factory, db_session):
  1593. """Verify bulk update skips non-pending items."""
  1594. pending_item = await queue_item_factory(status="pending", bed_levelling="on")
  1595. printing_item = await queue_item_factory(status="printing", bed_levelling="on")
  1596. completed_item = await queue_item_factory(status="completed", bed_levelling="on")
  1597. response = await async_client.patch(
  1598. "/api/v1/queue/bulk",
  1599. json={
  1600. "item_ids": [pending_item.id, printing_item.id, completed_item.id],
  1601. "bed_levelling": "off",
  1602. },
  1603. )
  1604. assert response.status_code == 200
  1605. result = response.json()
  1606. assert result["updated_count"] == 1
  1607. assert result["skipped_count"] == 2
  1608. # Only pending item should be updated
  1609. await db_session.refresh(pending_item)
  1610. await db_session.refresh(printing_item)
  1611. await db_session.refresh(completed_item)
  1612. assert pending_item.bed_levelling == "off"
  1613. assert printing_item.bed_levelling == "on"
  1614. assert completed_item.bed_levelling == "on"
  1615. @pytest.mark.asyncio
  1616. @pytest.mark.integration
  1617. async def test_bulk_update_change_printer(
  1618. self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
  1619. ):
  1620. """Verify bulk update can reassign items to a different printer."""
  1621. new_printer = await printer_factory(name="New Target Printer")
  1622. item1 = await queue_item_factory()
  1623. item2 = await queue_item_factory()
  1624. original_printer_id = item1.printer_id
  1625. response = await async_client.patch(
  1626. "/api/v1/queue/bulk",
  1627. json={"item_ids": [item1.id, item2.id], "printer_id": new_printer.id},
  1628. )
  1629. assert response.status_code == 200
  1630. await db_session.refresh(item1)
  1631. await db_session.refresh(item2)
  1632. assert item1.printer_id == new_printer.id
  1633. assert item2.printer_id == new_printer.id
  1634. assert item1.printer_id != original_printer_id
  1635. @pytest.mark.asyncio
  1636. @pytest.mark.integration
  1637. async def test_bulk_update_empty_item_ids(self, async_client: AsyncClient):
  1638. """Verify 400 error when item_ids is empty."""
  1639. response = await async_client.patch(
  1640. "/api/v1/queue/bulk",
  1641. json={"item_ids": [], "bed_levelling": False},
  1642. )
  1643. assert response.status_code == 400
  1644. assert "no item" in response.json()["detail"].lower()
  1645. @pytest.mark.asyncio
  1646. @pytest.mark.integration
  1647. async def test_bulk_update_no_fields(self, async_client: AsyncClient, queue_item_factory):
  1648. """Verify 400 error when no fields to update."""
  1649. item = await queue_item_factory()
  1650. response = await async_client.patch(
  1651. "/api/v1/queue/bulk",
  1652. json={"item_ids": [item.id]},
  1653. )
  1654. assert response.status_code == 400
  1655. assert "no fields" in response.json()["detail"].lower()
  1656. @pytest.mark.asyncio
  1657. @pytest.mark.integration
  1658. async def test_bulk_update_invalid_printer(self, async_client: AsyncClient, queue_item_factory):
  1659. """Verify 400 error when printer_id doesn't exist."""
  1660. item = await queue_item_factory()
  1661. response = await async_client.patch(
  1662. "/api/v1/queue/bulk",
  1663. json={"item_ids": [item.id], "printer_id": 99999},
  1664. )
  1665. assert response.status_code == 400
  1666. assert "printer not found" in response.json()["detail"].lower()
  1667. @pytest.mark.asyncio
  1668. @pytest.mark.integration
  1669. async def test_bulk_update_cost_center_requires_estimated_cost(
  1670. self, async_client: AsyncClient, queue_item_factory, db_session
  1671. ):
  1672. """Bulk assigning a cost center requires an estimate, same as single-item updates."""
  1673. await enable_billing(db_session)
  1674. item = await queue_item_factory()
  1675. cost_center = CostCenter(name="Bulk Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
  1676. db_session.add(cost_center)
  1677. await db_session.commit()
  1678. await db_session.refresh(cost_center)
  1679. response = await async_client.patch(
  1680. "/api/v1/queue/bulk",
  1681. json={"item_ids": [item.id], "cost_center_id": cost_center.id},
  1682. )
  1683. assert response.status_code == 400
  1684. assert "Estimated cost is required" in response.json()["detail"]
  1685. @pytest.mark.asyncio
  1686. @pytest.mark.integration
  1687. async def test_bulk_update_cost_center_counts_pending_reservations(
  1688. self, async_client: AsyncClient, queue_item_factory, archive_factory, db_session
  1689. ):
  1690. """A forged bulk-update hint cannot weaken the server-derived reservation."""
  1691. await enable_billing(db_session)
  1692. archive = await archive_factory(cost=3.0, filament_used_grams=50.0)
  1693. item = await queue_item_factory(archive_id=archive.id)
  1694. existing = await queue_item_factory()
  1695. cost_center = CostCenter(name="Bulk Reserved CC", is_active=True, is_private=False, monthly_budget=10.0)
  1696. db_session.add(cost_center)
  1697. await db_session.commit()
  1698. await db_session.refresh(cost_center)
  1699. existing.cost_center_id = cost_center.id
  1700. existing.estimated_cost = 8.0
  1701. await db_session.commit()
  1702. response = await async_client.patch(
  1703. "/api/v1/queue/bulk",
  1704. json={"item_ids": [item.id], "cost_center_id": cost_center.id, "estimated_cost": 0.01},
  1705. )
  1706. assert response.status_code == 400
  1707. assert "exceeds available cost center budget" in response.json()["detail"]
  1708. await db_session.refresh(item)
  1709. assert item.cost_center_id is None
  1710. assert item.estimated_cost is None
  1711. class TestTargetLocationFeature:
  1712. """Tests for queue items with target_location (Issue #220)."""
  1713. @pytest.fixture
  1714. async def printer_factory(self, db_session):
  1715. """Factory to create test printers."""
  1716. _counter = [0]
  1717. async def _create_printer(**kwargs):
  1718. from backend.app.models.printer import Printer
  1719. _counter[0] += 1
  1720. counter = _counter[0]
  1721. defaults = {
  1722. "name": f"Location Test Printer {counter}",
  1723. "ip_address": f"192.168.1.{50 + counter}",
  1724. "serial_number": f"TESTLOC{counter:04d}",
  1725. "access_code": "12345678",
  1726. "model": "X1C",
  1727. }
  1728. defaults.update(kwargs)
  1729. printer = Printer(**defaults)
  1730. db_session.add(printer)
  1731. await db_session.commit()
  1732. await db_session.refresh(printer)
  1733. return printer
  1734. return _create_printer
  1735. @pytest.fixture
  1736. async def archive_factory(self, db_session):
  1737. """Factory to create test archives."""
  1738. _counter = [0]
  1739. async def _create_archive(**kwargs):
  1740. from backend.app.models.archive import PrintArchive
  1741. _counter[0] += 1
  1742. counter = _counter[0]
  1743. defaults = {
  1744. "filename": f"location_test_{counter}.3mf",
  1745. "print_name": f"Location Test Print {counter}",
  1746. "file_path": f"/tmp/location_test_{counter}.3mf", # nosec B108 -- a stored path, never opened
  1747. "file_size": 1024,
  1748. "content_hash": f"lochash{counter:08d}",
  1749. "status": "completed",
  1750. }
  1751. defaults.update(kwargs)
  1752. archive = PrintArchive(**defaults)
  1753. db_session.add(archive)
  1754. await db_session.commit()
  1755. await db_session.refresh(archive)
  1756. return archive
  1757. return _create_archive
  1758. @pytest.fixture
  1759. async def queue_item_factory(self, db_session, printer_factory, archive_factory):
  1760. """Factory to create test queue items."""
  1761. _counter = [0]
  1762. async def _create_queue_item(**kwargs):
  1763. from backend.app.models.print_queue import PrintQueueItem
  1764. _counter[0] += 1
  1765. counter = _counter[0]
  1766. if "printer_id" not in kwargs and "target_model" not in kwargs:
  1767. printer = await printer_factory()
  1768. kwargs["printer_id"] = printer.id
  1769. if "archive_id" not in kwargs:
  1770. archive = await archive_factory()
  1771. kwargs["archive_id"] = archive.id
  1772. defaults = {
  1773. "status": "pending",
  1774. "position": counter,
  1775. }
  1776. defaults.update(kwargs)
  1777. item = PrintQueueItem(**defaults)
  1778. db_session.add(item)
  1779. await db_session.commit()
  1780. await db_session.refresh(item)
  1781. return item
  1782. return _create_queue_item
  1783. @pytest.mark.asyncio
  1784. @pytest.mark.integration
  1785. async def test_add_to_queue_with_target_location(
  1786. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  1787. ):
  1788. """Verify item can be added with target_model and target_location."""
  1789. # Create a printer with model X1C so the API can validate
  1790. await printer_factory(model="X1C", location="Office")
  1791. archive = await archive_factory()
  1792. data = {
  1793. "target_model": "X1C",
  1794. "target_location": "Workbench",
  1795. "archive_id": archive.id,
  1796. }
  1797. response = await async_client.post("/api/v1/queue/", json=data)
  1798. assert response.status_code == 200
  1799. result = response.json()
  1800. assert result["target_model"] == "X1C"
  1801. assert result["target_location"] == "Workbench"
  1802. assert result["printer_id"] is None
  1803. @pytest.mark.asyncio
  1804. @pytest.mark.integration
  1805. async def test_add_to_queue_location_without_model_ignored(
  1806. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  1807. ):
  1808. """Verify target_location without target_model is allowed (location is just ignored)."""
  1809. printer = await printer_factory()
  1810. archive = await archive_factory()
  1811. data = {
  1812. "printer_id": printer.id,
  1813. "target_location": "Workbench", # This gets ignored since printer_id is set
  1814. "archive_id": archive.id,
  1815. }
  1816. response = await async_client.post("/api/v1/queue/", json=data)
  1817. # The API accepts this but the location is only used with target_model
  1818. assert response.status_code == 200
  1819. result = response.json()
  1820. assert result["printer_id"] == printer.id
  1821. # Location may or may not be stored since it's meaningless without target_model
  1822. # The important thing is the request succeeds
  1823. @pytest.mark.asyncio
  1824. @pytest.mark.integration
  1825. async def test_queue_item_target_location_in_response(
  1826. self, async_client: AsyncClient, queue_item_factory, db_session
  1827. ):
  1828. """Verify target_location is returned in queue item response."""
  1829. item = await queue_item_factory(
  1830. printer_id=None,
  1831. target_model="X1C",
  1832. target_location="Workshop",
  1833. )
  1834. response = await async_client.get(f"/api/v1/queue/{item.id}")
  1835. assert response.status_code == 200
  1836. result = response.json()
  1837. assert result["target_model"] == "X1C"
  1838. assert result["target_location"] == "Workshop"
  1839. @pytest.mark.asyncio
  1840. @pytest.mark.integration
  1841. async def test_queue_list_includes_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
  1842. """Verify target_location is included in queue list."""
  1843. await queue_item_factory(
  1844. printer_id=None,
  1845. target_model="P1S",
  1846. target_location="Garage",
  1847. )
  1848. response = await async_client.get("/api/v1/queue/")
  1849. assert response.status_code == 200
  1850. items = response.json()
  1851. assert len(items) >= 1
  1852. # Find our item
  1853. our_item = next((i for i in items if i["target_location"] == "Garage"), None)
  1854. assert our_item is not None
  1855. assert our_item["target_model"] == "P1S"
  1856. @pytest.mark.asyncio
  1857. @pytest.mark.integration
  1858. async def test_update_queue_item_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
  1859. """Verify target_location can be updated on existing queue item."""
  1860. item = await queue_item_factory(
  1861. printer_id=None,
  1862. target_model="X1C",
  1863. target_location="Office",
  1864. )
  1865. response = await async_client.patch(
  1866. f"/api/v1/queue/{item.id}",
  1867. json={"target_location": "Basement"},
  1868. )
  1869. assert response.status_code == 200
  1870. result = response.json()
  1871. assert result["target_location"] == "Basement"
  1872. @pytest.mark.asyncio
  1873. @pytest.mark.integration
  1874. async def test_moving_a_job_to_a_location_drops_the_old_printers_mapping(
  1875. self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
  1876. ):
  1877. """#3239: the tray IDs were resolved against A1-003, and the scheduler may
  1878. pick another printer, so they must not travel with the job. Neither does
  1879. a "Print Anyway" given for A1-003's trays."""
  1880. a1_003 = await printer_factory(model="A1", location="Farm")
  1881. item = await queue_item_factory(printer_id=a1_003.id, ams_mapping="[3]", skip_filament_check=True)
  1882. # What the edit dialog sends: ams_mapping omitted, not null.
  1883. response = await async_client.patch(
  1884. f"/api/v1/queue/{item.id}",
  1885. json={"printer_id": None, "target_model": "A1", "target_location": "Farm"},
  1886. )
  1887. assert response.status_code == 200
  1888. result = response.json()
  1889. assert result["ams_mapping"] is None
  1890. assert result["skip_filament_check"] is False
  1891. @pytest.mark.asyncio
  1892. @pytest.mark.integration
  1893. async def test_a_job_on_a_printer_keeps_its_mapping(
  1894. self, async_client: AsyncClient, queue_item_factory, db_session
  1895. ):
  1896. item = await queue_item_factory(ams_mapping="[3]")
  1897. response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"plate_id": 2})
  1898. assert response.status_code == 200
  1899. assert response.json()["ams_mapping"] == [3]
  1900. @pytest.mark.asyncio
  1901. @pytest.mark.integration
  1902. async def test_clear_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
  1903. """Verify target_location can be cleared (set to None)."""
  1904. item = await queue_item_factory(
  1905. printer_id=None,
  1906. target_model="X1C",
  1907. target_location="Office",
  1908. )
  1909. # Note: Setting to empty string should clear it
  1910. response = await async_client.patch(
  1911. f"/api/v1/queue/{item.id}",
  1912. json={"target_location": None},
  1913. )
  1914. assert response.status_code == 200
  1915. result = response.json()
  1916. assert result["target_location"] is None
  1917. # ------------------------------------------------------------------
  1918. # Cross-model dispatch gate (#2578): a G-code 3MF sliced for one model
  1919. # must not be queued for model-based dispatch to an incompatible model.
  1920. # ------------------------------------------------------------------
  1921. @pytest.mark.asyncio
  1922. @pytest.mark.integration
  1923. async def test_add_to_queue_rejects_cross_model_mismatch(
  1924. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  1925. ):
  1926. """X1C-sliced archive + target_model=H2D must be rejected (#2578)."""
  1927. await printer_factory(model="H2D")
  1928. archive = await archive_factory(sliced_for_model="X1C")
  1929. response = await async_client.post(
  1930. "/api/v1/queue/",
  1931. json={"target_model": "H2D", "archive_id": archive.id},
  1932. )
  1933. assert response.status_code == 400
  1934. assert "sliced for X1C" in response.json()["detail"]
  1935. @pytest.mark.asyncio
  1936. @pytest.mark.integration
  1937. async def test_add_to_queue_allows_gcode_family_target(
  1938. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  1939. ):
  1940. """X1C-sliced G-code on a P1S is an intentional mixed-farm workflow —
  1941. same kinematics/volume family, must stay allowed."""
  1942. await printer_factory(model="P1S")
  1943. archive = await archive_factory(sliced_for_model="X1C")
  1944. response = await async_client.post(
  1945. "/api/v1/queue/",
  1946. json={"target_model": "P1S", "archive_id": archive.id},
  1947. )
  1948. assert response.status_code == 200
  1949. assert response.json()["target_model"] == "P1S"
  1950. @pytest.mark.asyncio
  1951. @pytest.mark.integration
  1952. async def test_add_to_queue_without_sliced_metadata_not_blocked(
  1953. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  1954. ):
  1955. """Legacy archives without sliced_for_model can't be validated — must keep working."""
  1956. await printer_factory(model="H2D")
  1957. archive = await archive_factory() # no sliced_for_model
  1958. response = await async_client.post(
  1959. "/api/v1/queue/",
  1960. json={"target_model": "H2D", "archive_id": archive.id},
  1961. )
  1962. assert response.status_code == 200
  1963. @pytest.mark.asyncio
  1964. @pytest.mark.integration
  1965. async def test_update_rejects_cross_model_mismatch(
  1966. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
  1967. ):
  1968. """Editing an item must not be able to introduce an incompatible target either."""
  1969. await printer_factory(model="X1C")
  1970. await printer_factory(model="H2D")
  1971. archive = await archive_factory(sliced_for_model="X1C")
  1972. item = await queue_item_factory(printer_id=None, target_model="X1C", archive_id=archive.id)
  1973. response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"target_model": "H2D"})
  1974. assert response.status_code == 400
  1975. assert "sliced for X1C" in response.json()["detail"]
  1976. @pytest.mark.asyncio
  1977. @pytest.mark.integration
  1978. async def test_update_can_fix_stale_mismatched_target(
  1979. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
  1980. ):
  1981. """A pre-fix DB row with a wrong target (the reporter's rows 78-82) must be
  1982. repairable by editing the target back to the sliced-for model."""
  1983. await printer_factory(model="X1C")
  1984. archive = await archive_factory(sliced_for_model="X1C")
  1985. # Stale mismatched row written directly to the DB (bypasses the API gate)
  1986. item = await queue_item_factory(printer_id=None, target_model="H2D", archive_id=archive.id)
  1987. response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"target_model": "X1C"})
  1988. assert response.status_code == 200
  1989. assert response.json()["target_model"] == "X1C"
  1990. class TestAbortedStatusNormalisation:
  1991. """Tests for issue #558: 'aborted' queue status causes 500 error."""
  1992. @pytest.fixture
  1993. async def printer_factory(self, db_session):
  1994. """Factory to create test printers."""
  1995. _counter = [0]
  1996. async def _create_printer(**kwargs):
  1997. from backend.app.models.printer import Printer
  1998. _counter[0] += 1
  1999. counter = _counter[0]
  2000. defaults = {
  2001. "name": f"Abort Test Printer {counter}",
  2002. "ip_address": f"192.168.1.{60 + counter}",
  2003. "serial_number": f"TESTABORT{counter:04d}",
  2004. "access_code": "12345678",
  2005. "model": "P1S",
  2006. }
  2007. defaults.update(kwargs)
  2008. printer = Printer(**defaults)
  2009. db_session.add(printer)
  2010. await db_session.commit()
  2011. await db_session.refresh(printer)
  2012. return printer
  2013. return _create_printer
  2014. @pytest.fixture
  2015. async def archive_factory(self, db_session):
  2016. """Factory to create test archives."""
  2017. _counter = [0]
  2018. async def _create_archive(**kwargs):
  2019. from backend.app.models.archive import PrintArchive
  2020. _counter[0] += 1
  2021. counter = _counter[0]
  2022. defaults = {
  2023. "filename": f"abort_test_{counter}.3mf",
  2024. "print_name": f"Abort Test Print {counter}",
  2025. "file_path": f"/tmp/abort_test_{counter}.3mf", # nosec B108 -- a stored path, never opened
  2026. "file_size": 1024,
  2027. "content_hash": f"aborthash{counter:06d}",
  2028. "status": "completed",
  2029. }
  2030. defaults.update(kwargs)
  2031. archive = PrintArchive(**defaults)
  2032. db_session.add(archive)
  2033. await db_session.commit()
  2034. await db_session.refresh(archive)
  2035. return archive
  2036. return _create_archive
  2037. @pytest.fixture
  2038. async def queue_item_factory(self, db_session, printer_factory, archive_factory):
  2039. """Factory to create test queue items."""
  2040. _counter = [0]
  2041. async def _create_queue_item(**kwargs):
  2042. from backend.app.models.print_queue import PrintQueueItem
  2043. _counter[0] += 1
  2044. counter = _counter[0]
  2045. if "printer_id" not in kwargs:
  2046. printer = await printer_factory()
  2047. kwargs["printer_id"] = printer.id
  2048. if "archive_id" not in kwargs:
  2049. archive = await archive_factory()
  2050. kwargs["archive_id"] = archive.id
  2051. defaults = {
  2052. "status": "pending",
  2053. "position": counter,
  2054. }
  2055. defaults.update(kwargs)
  2056. item = PrintQueueItem(**defaults)
  2057. db_session.add(item)
  2058. await db_session.commit()
  2059. await db_session.refresh(item)
  2060. return item
  2061. return _create_queue_item
  2062. @pytest.mark.asyncio
  2063. @pytest.mark.integration
  2064. async def test_on_print_complete_normalises_aborted_to_cancelled(
  2065. self, queue_item_factory, archive_factory, db_session
  2066. ):
  2067. """Verify the completion handler maps 'aborted' → 'cancelled' for queue items."""
  2068. import asyncio
  2069. from unittest.mock import AsyncMock, MagicMock, patch
  2070. archive = await archive_factory(filename="Abort_Me.gcode.3mf")
  2071. item = await queue_item_factory(status="printing", archive_id=archive.id)
  2072. # Build a mock session whose execute returns our item. `get` has to
  2073. # answer with the item's real archive: the handler checks the completion
  2074. # is about this row's print before closing it, and an archive that names
  2075. # a different subtask is exactly what it refuses.
  2076. mock_result = MagicMock()
  2077. mock_result.scalars.return_value.all.return_value = [item]
  2078. mock_session = AsyncMock()
  2079. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  2080. mock_session.__aexit__ = AsyncMock(return_value=False)
  2081. mock_session.execute = AsyncMock(return_value=mock_result)
  2082. mock_session.get = AsyncMock(return_value=archive)
  2083. mock_session.commit = AsyncMock()
  2084. tasks_before = set(asyncio.all_tasks())
  2085. with (
  2086. patch("backend.app.main.async_session", return_value=mock_session),
  2087. patch("backend.app.core.database.async_session", return_value=mock_session),
  2088. patch("backend.app.main.ws_manager") as mock_ws,
  2089. patch("backend.app.main.mqtt_relay") as mock_relay,
  2090. patch("backend.app.main.notification_service") as mock_notif,
  2091. patch("backend.app.main.smart_plug_manager") as mock_plug,
  2092. patch("backend.app.main.printer_manager") as mock_pm,
  2093. ):
  2094. mock_ws.send_print_complete = AsyncMock()
  2095. mock_ws.broadcast = AsyncMock()
  2096. mock_relay.on_print_complete = AsyncMock()
  2097. mock_relay.on_queue_job_completed = AsyncMock()
  2098. mock_notif.on_print_complete = AsyncMock()
  2099. mock_plug.on_print_complete = AsyncMock()
  2100. mock_pm.get_printer.return_value = None
  2101. from backend.app.main import on_print_complete
  2102. await on_print_complete(
  2103. item.printer_id,
  2104. {
  2105. "status": "aborted",
  2106. "filename": "test.gcode",
  2107. "subtask_name": "Abort_Me",
  2108. "timelapse_was_active": False,
  2109. },
  2110. )
  2111. # Cancel background tasks before leaving mock context
  2112. for task in asyncio.all_tasks() - tasks_before:
  2113. task.cancel()
  2114. try:
  2115. await task
  2116. except (asyncio.CancelledError, Exception):
  2117. pass
  2118. # The item status should be normalised to 'cancelled', not 'aborted'
  2119. assert item.status == "cancelled"
  2120. @pytest.mark.asyncio
  2121. @pytest.mark.integration
  2122. async def test_startup_fixup_converts_aborted_to_cancelled(self, queue_item_factory, db_session):
  2123. """Verify the startup fixup converts existing 'aborted' rows to 'cancelled'."""
  2124. from sqlalchemy import select
  2125. from backend.app.models.print_queue import PrintQueueItem
  2126. # Create items with various statuses including 'aborted'
  2127. item_aborted = await queue_item_factory(status="pending")
  2128. item_pending = await queue_item_factory(status="pending")
  2129. # Manually set the invalid status
  2130. item_aborted.status = "aborted"
  2131. db_session.add(item_aborted)
  2132. await db_session.commit()
  2133. # Run the fixup query (same logic as lifespan)
  2134. result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.status == "aborted"))
  2135. aborted_items = result.scalars().all()
  2136. for i in aborted_items:
  2137. i.status = "cancelled"
  2138. await db_session.commit()
  2139. # Verify: no more 'aborted' items
  2140. result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.status == "aborted"))
  2141. assert len(result.scalars().all()) == 0
  2142. # The previously aborted item should now be 'cancelled'
  2143. await db_session.refresh(item_aborted)
  2144. assert item_aborted.status == "cancelled"
  2145. # The pending item should be unchanged
  2146. await db_session.refresh(item_pending)
  2147. assert item_pending.status == "pending"
  2148. @pytest.mark.asyncio
  2149. @pytest.mark.integration
  2150. async def test_leaves_a_printing_item_alone_when_the_completion_is_for_another_print(
  2151. self, queue_item_factory, archive_factory, db_session
  2152. ):
  2153. """A completion naming a different subtask must not close this row.
  2154. The handler looks its row up by printer and ``status='printing'`` only,
  2155. so before this guard any completion delivered for the printer closed
  2156. whatever was printing on it. That is how a live 14-hour job was marked
  2157. completed 18 minutes in -- by an event that had nothing to do with it --
  2158. which also stranded the second plate of its batch, since the queue will
  2159. not dispatch while the printer is still running.
  2160. """
  2161. import asyncio
  2162. from unittest.mock import AsyncMock, MagicMock, patch
  2163. archive = await archive_factory(filename="AMS_Rack.gcode.3mf")
  2164. item = await queue_item_factory(status="printing", archive_id=archive.id)
  2165. mock_result = MagicMock()
  2166. mock_result.scalars.return_value.all.return_value = [item]
  2167. mock_session = AsyncMock()
  2168. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  2169. mock_session.__aexit__ = AsyncMock(return_value=False)
  2170. mock_session.execute = AsyncMock(return_value=mock_result)
  2171. mock_session.get = AsyncMock(return_value=archive)
  2172. mock_session.commit = AsyncMock()
  2173. tasks_before = set(asyncio.all_tasks())
  2174. with (
  2175. patch("backend.app.main.async_session", return_value=mock_session),
  2176. patch("backend.app.core.database.async_session", return_value=mock_session),
  2177. patch("backend.app.main.ws_manager") as mock_ws,
  2178. patch("backend.app.main.mqtt_relay") as mock_relay,
  2179. patch("backend.app.main.notification_service") as mock_notif,
  2180. patch("backend.app.main.smart_plug_manager") as mock_plug,
  2181. patch("backend.app.main.printer_manager") as mock_pm,
  2182. ):
  2183. mock_ws.send_print_complete = AsyncMock()
  2184. mock_ws.broadcast = AsyncMock()
  2185. mock_relay.on_print_complete = AsyncMock()
  2186. mock_relay.on_queue_job_completed = AsyncMock()
  2187. mock_notif.on_print_complete = AsyncMock()
  2188. mock_plug.on_print_complete = AsyncMock()
  2189. mock_pm.get_printer.return_value = None
  2190. from backend.app.main import on_print_complete
  2191. await on_print_complete(
  2192. item.printer_id,
  2193. {
  2194. "status": "completed",
  2195. "filename": "/data/Metadata/plate_1.gcode",
  2196. "subtask_name": "Some_Other_Print",
  2197. "timelapse_was_active": False,
  2198. },
  2199. )
  2200. for task in asyncio.all_tasks() - tasks_before:
  2201. task.cancel()
  2202. try:
  2203. await task
  2204. except (asyncio.CancelledError, Exception):
  2205. pass
  2206. assert item.status == "printing"
  2207. assert item.completed_at is None
  2208. @pytest.mark.asyncio
  2209. @pytest.mark.integration
  2210. async def test_completed_status_passes_through_unchanged(self, queue_item_factory, archive_factory, db_session):
  2211. """Verify normal statuses like 'completed' are not affected by normalisation."""
  2212. import asyncio
  2213. from unittest.mock import AsyncMock, MagicMock, patch
  2214. archive = await archive_factory(filename="Finish_Me.gcode.3mf")
  2215. item = await queue_item_factory(status="printing", archive_id=archive.id)
  2216. mock_result = MagicMock()
  2217. mock_result.scalars.return_value.all.return_value = [item]
  2218. mock_session = AsyncMock()
  2219. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  2220. mock_session.__aexit__ = AsyncMock(return_value=False)
  2221. mock_session.execute = AsyncMock(return_value=mock_result)
  2222. mock_session.get = AsyncMock(return_value=archive)
  2223. mock_session.commit = AsyncMock()
  2224. tasks_before = set(asyncio.all_tasks())
  2225. with (
  2226. patch("backend.app.main.async_session", return_value=mock_session),
  2227. patch("backend.app.core.database.async_session", return_value=mock_session),
  2228. patch("backend.app.main.ws_manager") as mock_ws,
  2229. patch("backend.app.main.mqtt_relay") as mock_relay,
  2230. patch("backend.app.main.notification_service") as mock_notif,
  2231. patch("backend.app.main.smart_plug_manager") as mock_plug,
  2232. patch("backend.app.main.printer_manager") as mock_pm,
  2233. ):
  2234. mock_ws.send_print_complete = AsyncMock()
  2235. mock_ws.broadcast = AsyncMock()
  2236. mock_relay.on_print_complete = AsyncMock()
  2237. mock_relay.on_queue_job_completed = AsyncMock()
  2238. mock_notif.on_print_complete = AsyncMock()
  2239. mock_plug.on_print_complete = AsyncMock()
  2240. mock_pm.get_printer.return_value = None
  2241. from backend.app.main import on_print_complete
  2242. await on_print_complete(
  2243. item.printer_id,
  2244. {
  2245. "status": "completed",
  2246. "filename": "test.gcode",
  2247. "subtask_name": "Finish_Me",
  2248. "timelapse_was_active": False,
  2249. },
  2250. )
  2251. # Cancel background tasks before leaving mock context
  2252. for task in asyncio.all_tasks() - tasks_before:
  2253. task.cancel()
  2254. try:
  2255. await task
  2256. except (asyncio.CancelledError, Exception):
  2257. pass
  2258. assert item.status == "completed"
  2259. # ========================================================================
  2260. # Library file usage tracking on print completion (#1008)
  2261. #
  2262. # These exercise the _bump_library_file_usage_if_completed helper directly
  2263. # rather than invoking the whole on_print_complete handler — that path
  2264. # spawns background asyncio tasks (notifications, MQTT relay, smart-plug)
  2265. # that are expensive to mock and have nothing to do with the bump logic.
  2266. # ========================================================================
  2267. @pytest.mark.asyncio
  2268. @pytest.mark.integration
  2269. async def test_bump_library_file_usage_on_completed(self, printer_factory, db_session):
  2270. """Successful completion increments print_count and stamps last_printed_at."""
  2271. from datetime import datetime, timezone
  2272. from backend.app.main import _bump_library_file_usage_if_completed
  2273. from backend.app.models.library import LibraryFile
  2274. from backend.app.models.print_queue import PrintQueueItem
  2275. printer = await printer_factory()
  2276. lib_file = LibraryFile(
  2277. filename="benchy.gcode.3mf",
  2278. file_path="/data/library/benchy.gcode.3mf",
  2279. file_type="gcode.3mf",
  2280. file_size=1024,
  2281. print_count=0,
  2282. last_printed_at=None,
  2283. )
  2284. db_session.add(lib_file)
  2285. await db_session.commit()
  2286. await db_session.refresh(lib_file)
  2287. item = PrintQueueItem(
  2288. printer_id=printer.id,
  2289. library_file_id=lib_file.id,
  2290. status="printing",
  2291. position=1,
  2292. )
  2293. before = datetime.now(timezone.utc).replace(tzinfo=None)
  2294. await _bump_library_file_usage_if_completed(db_session, item, "completed")
  2295. await db_session.commit()
  2296. await db_session.refresh(lib_file)
  2297. assert lib_file.print_count == 1
  2298. assert lib_file.last_printed_at is not None
  2299. assert lib_file.last_printed_at >= before
  2300. @pytest.mark.asyncio
  2301. @pytest.mark.integration
  2302. async def test_bump_library_file_usage_repeated_prints_increment_count(self, printer_factory, db_session):
  2303. """Each successful completion bumps print_count cumulatively."""
  2304. from backend.app.main import _bump_library_file_usage_if_completed
  2305. from backend.app.models.library import LibraryFile
  2306. from backend.app.models.print_queue import PrintQueueItem
  2307. printer = await printer_factory()
  2308. lib_file = LibraryFile(
  2309. filename="repeat.gcode.3mf",
  2310. file_path="/data/library/repeat.gcode.3mf",
  2311. file_type="gcode.3mf",
  2312. file_size=1024,
  2313. print_count=0,
  2314. )
  2315. db_session.add(lib_file)
  2316. await db_session.commit()
  2317. await db_session.refresh(lib_file)
  2318. item = PrintQueueItem(
  2319. printer_id=printer.id,
  2320. library_file_id=lib_file.id,
  2321. status="printing",
  2322. position=1,
  2323. )
  2324. for _ in range(3):
  2325. await _bump_library_file_usage_if_completed(db_session, item, "completed")
  2326. await db_session.commit()
  2327. await db_session.refresh(lib_file)
  2328. assert lib_file.print_count == 3
  2329. @pytest.mark.asyncio
  2330. @pytest.mark.integration
  2331. @pytest.mark.parametrize("terminal_status", ["failed", "cancelled"])
  2332. async def test_bump_library_file_usage_skips_non_completed(self, printer_factory, db_session, terminal_status):
  2333. """Failed and cancelled prints must NOT count as usage."""
  2334. from backend.app.main import _bump_library_file_usage_if_completed
  2335. from backend.app.models.library import LibraryFile
  2336. from backend.app.models.print_queue import PrintQueueItem
  2337. printer = await printer_factory()
  2338. lib_file = LibraryFile(
  2339. filename="broken.gcode.3mf",
  2340. file_path="/data/library/broken.gcode.3mf",
  2341. file_type="gcode.3mf",
  2342. file_size=1024,
  2343. print_count=0,
  2344. last_printed_at=None,
  2345. )
  2346. db_session.add(lib_file)
  2347. await db_session.commit()
  2348. await db_session.refresh(lib_file)
  2349. item = PrintQueueItem(
  2350. printer_id=printer.id,
  2351. library_file_id=lib_file.id,
  2352. status="printing",
  2353. position=1,
  2354. )
  2355. await _bump_library_file_usage_if_completed(db_session, item, terminal_status)
  2356. await db_session.commit()
  2357. await db_session.refresh(lib_file)
  2358. assert lib_file.print_count == 0
  2359. assert lib_file.last_printed_at is None
  2360. @pytest.mark.asyncio
  2361. @pytest.mark.integration
  2362. async def test_bump_library_file_usage_skips_when_no_library_file_id(
  2363. self, printer_factory, archive_factory, db_session
  2364. ):
  2365. """Queue items without library_file_id (e.g. archive reprints) are a no-op."""
  2366. from backend.app.main import _bump_library_file_usage_if_completed
  2367. from backend.app.models.print_queue import PrintQueueItem
  2368. printer = await printer_factory()
  2369. archive = await archive_factory()
  2370. item = PrintQueueItem(
  2371. printer_id=printer.id,
  2372. library_file_id=None,
  2373. archive_id=archive.id,
  2374. status="printing",
  2375. position=1,
  2376. )
  2377. # Must not raise.
  2378. await _bump_library_file_usage_if_completed(db_session, item, "completed")
  2379. # ========================================================================
  2380. # Batch quantity tests
  2381. # ========================================================================
  2382. @pytest.mark.asyncio
  2383. @pytest.mark.integration
  2384. async def test_add_to_queue_quantity_default(
  2385. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  2386. ):
  2387. """Verify quantity=1 (default) creates a single item with no batch."""
  2388. printer = await printer_factory()
  2389. archive = await archive_factory()
  2390. data = {
  2391. "printer_id": printer.id,
  2392. "archive_id": archive.id,
  2393. }
  2394. response = await async_client.post("/api/v1/queue/", json=data)
  2395. assert response.status_code == 200
  2396. result = response.json()
  2397. assert result["batch_id"] is None
  2398. assert result["batch_name"] is None
  2399. @pytest.mark.asyncio
  2400. @pytest.mark.integration
  2401. async def test_add_to_queue_quantity_one_explicit(
  2402. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  2403. ):
  2404. """Verify quantity=1 explicitly creates a single item with no batch."""
  2405. printer = await printer_factory()
  2406. archive = await archive_factory()
  2407. data = {
  2408. "printer_id": printer.id,
  2409. "archive_id": archive.id,
  2410. "quantity": 1,
  2411. }
  2412. response = await async_client.post("/api/v1/queue/", json=data)
  2413. assert response.status_code == 200
  2414. result = response.json()
  2415. assert result["batch_id"] is None
  2416. assert result["batch_name"] is None
  2417. @pytest.mark.asyncio
  2418. @pytest.mark.integration
  2419. async def test_add_to_queue_quantity_creates_batch(
  2420. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  2421. ):
  2422. """Verify quantity > 1 creates a batch and multiple queue items."""
  2423. printer = await printer_factory()
  2424. archive = await archive_factory()
  2425. data = {
  2426. "printer_id": printer.id,
  2427. "archive_id": archive.id,
  2428. "quantity": 3,
  2429. }
  2430. response = await async_client.post("/api/v1/queue/", json=data)
  2431. assert response.status_code == 200
  2432. result = response.json()
  2433. # First item is returned, linked to a batch
  2434. assert result["batch_id"] is not None
  2435. assert result["batch_name"] is not None
  2436. assert "×3" in result["batch_name"]
  2437. # Verify all 3 items were created
  2438. list_response = await async_client.get("/api/v1/queue/")
  2439. items = list_response.json()
  2440. batch_items = [i for i in items if i["batch_id"] == result["batch_id"]]
  2441. assert len(batch_items) == 3
  2442. # All items should have the same settings
  2443. for item in batch_items:
  2444. assert item["printer_id"] == printer.id
  2445. assert item["archive_id"] == archive.id
  2446. assert item["status"] == "pending"
  2447. @pytest.mark.asyncio
  2448. @pytest.mark.integration
  2449. async def test_add_to_queue_quantity_sequential_positions(
  2450. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  2451. ):
  2452. """Verify batch items get sequential positions."""
  2453. printer = await printer_factory()
  2454. archive = await archive_factory()
  2455. data = {
  2456. "printer_id": printer.id,
  2457. "archive_id": archive.id,
  2458. "quantity": 3,
  2459. }
  2460. response = await async_client.post("/api/v1/queue/", json=data)
  2461. assert response.status_code == 200
  2462. batch_id = response.json()["batch_id"]
  2463. list_response = await async_client.get("/api/v1/queue/")
  2464. items = list_response.json()
  2465. batch_items = sorted(
  2466. [i for i in items if i["batch_id"] == batch_id],
  2467. key=lambda i: i["position"],
  2468. )
  2469. positions = [i["position"] for i in batch_items]
  2470. assert positions == [positions[0], positions[0] + 1, positions[0] + 2]
  2471. @pytest.mark.asyncio
  2472. @pytest.mark.integration
  2473. async def test_add_to_queue_insert_position_shifts_existing_items(
  2474. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  2475. ):
  2476. """Verify priority insertion shifts existing pending items in the same printer queue."""
  2477. printer = await printer_factory()
  2478. first = await archive_factory(print_name="First")
  2479. second = await archive_factory(print_name="Second")
  2480. priority = await archive_factory(print_name="Priority")
  2481. assert (
  2482. await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": first.id})
  2483. ).status_code == 200
  2484. assert (
  2485. await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": second.id})
  2486. ).status_code == 200
  2487. response = await async_client.post(
  2488. "/api/v1/queue/",
  2489. json={
  2490. "printer_id": printer.id,
  2491. "archive_id": priority.id,
  2492. "insert_position": 1,
  2493. },
  2494. )
  2495. assert response.status_code == 200
  2496. list_response = await async_client.get(f"/api/v1/queue/?printer_id={printer.id}")
  2497. items = sorted(list_response.json(), key=lambda item: item["position"])
  2498. assert [item["archive_id"] for item in items[:3]] == [priority.id, first.id, second.id]
  2499. assert [item["position"] for item in items[:3]] == [1, 2, 3]
  2500. @pytest.mark.asyncio
  2501. @pytest.mark.integration
  2502. async def test_add_to_queue_insert_position_quantity_shifts_existing_by_quantity(
  2503. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  2504. ):
  2505. """ASAP batch insertion shifts existing pending items by the inserted quantity."""
  2506. printer = await printer_factory()
  2507. first = await archive_factory(print_name="First")
  2508. second = await archive_factory(print_name="Second")
  2509. priority = await archive_factory(print_name="Priority")
  2510. assert (
  2511. await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": first.id})
  2512. ).status_code == 200
  2513. assert (
  2514. await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": second.id})
  2515. ).status_code == 200
  2516. response = await async_client.post(
  2517. "/api/v1/queue/",
  2518. json={
  2519. "printer_id": printer.id,
  2520. "archive_id": priority.id,
  2521. "quantity": 3,
  2522. "insert_position": 1,
  2523. },
  2524. )
  2525. assert response.status_code == 200
  2526. batch_id = response.json()["batch_id"]
  2527. list_response = await async_client.get(f"/api/v1/queue/?printer_id={printer.id}")
  2528. items = sorted(list_response.json(), key=lambda item: item["position"])
  2529. assert [item["archive_id"] for item in items] == [
  2530. priority.id,
  2531. priority.id,
  2532. priority.id,
  2533. first.id,
  2534. second.id,
  2535. ]
  2536. assert [item["position"] for item in items] == [1, 2, 3, 4, 5]
  2537. assert [item["batch_id"] for item in items[:3]] == [batch_id, batch_id, batch_id]
  2538. @pytest.mark.asyncio
  2539. @pytest.mark.integration
  2540. async def test_add_to_queue_positions_are_one_sequence_across_printers(
  2541. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  2542. ):
  2543. """Positions are shared by pinned and unassigned items (#3200).
  2544. The queue page lists and reorders pending items as one list and the
  2545. scheduler dispatches in that order, so a pinned item added after two
  2546. unassigned ones lands after them, and an insert at the top shifts
  2547. everything, not only its own printer's items.
  2548. """
  2549. printer = await printer_factory()
  2550. unassigned_first = await archive_factory(print_name="Unassigned First")
  2551. unassigned_second = await archive_factory(print_name="Unassigned Second")
  2552. assigned = await archive_factory(print_name="Assigned")
  2553. priority = await archive_factory(print_name="Unassigned Priority")
  2554. assert (await async_client.post("/api/v1/queue/", json={"archive_id": unassigned_first.id})).status_code == 200
  2555. assert (await async_client.post("/api/v1/queue/", json={"archive_id": unassigned_second.id})).status_code == 200
  2556. assigned_response = await async_client.post(
  2557. "/api/v1/queue/",
  2558. json={"printer_id": printer.id, "archive_id": assigned.id},
  2559. )
  2560. assert assigned_response.status_code == 200
  2561. assert assigned_response.json()["position"] == 3
  2562. response = await async_client.post(
  2563. "/api/v1/queue/",
  2564. json={
  2565. "archive_id": priority.id,
  2566. "insert_position": 1,
  2567. },
  2568. )
  2569. assert response.status_code == 200
  2570. all_items = sorted((await async_client.get("/api/v1/queue/")).json(), key=lambda item: item["position"])
  2571. assert [item["archive_id"] for item in all_items] == [
  2572. priority.id,
  2573. unassigned_first.id,
  2574. unassigned_second.id,
  2575. assigned.id,
  2576. ]
  2577. assert [item["position"] for item in all_items] == [1, 2, 3, 4]
  2578. @pytest.mark.asyncio
  2579. @pytest.mark.integration
  2580. async def test_add_to_queue_insert_position_greater_than_max_appends_without_gap(
  2581. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  2582. ):
  2583. """Oversized explicit insert_position appends at max+1 instead of creating sparse positions."""
  2584. printer = await printer_factory()
  2585. first = await archive_factory(print_name="First")
  2586. second = await archive_factory(print_name="Second")
  2587. appended = await archive_factory(print_name="Append")
  2588. assert (
  2589. await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": first.id})
  2590. ).status_code == 200
  2591. assert (
  2592. await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": second.id})
  2593. ).status_code == 200
  2594. response = await async_client.post(
  2595. "/api/v1/queue/",
  2596. json={
  2597. "printer_id": printer.id,
  2598. "archive_id": appended.id,
  2599. "insert_position": 99,
  2600. },
  2601. )
  2602. assert response.status_code == 200
  2603. assert response.json()["position"] == 3
  2604. list_response = await async_client.get(f"/api/v1/queue/?printer_id={printer.id}")
  2605. items = sorted(list_response.json(), key=lambda item: item["position"])
  2606. assert [item["archive_id"] for item in items] == [first.id, second.id, appended.id]
  2607. assert [item["position"] for item in items] == [1, 2, 3]
  2608. @pytest.mark.asyncio
  2609. @pytest.mark.integration
  2610. async def test_add_to_queue_consecutive_asap_inserts_stack_in_submission_order(
  2611. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  2612. ):
  2613. """Consecutive ASAP inserts to the same printer preserve the client submission order."""
  2614. printer = await printer_factory()
  2615. existing = await archive_factory(print_name="Existing")
  2616. first_asap = await archive_factory(print_name="First ASAP")
  2617. second_asap = await archive_factory(print_name="Second ASAP")
  2618. assert (
  2619. await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": existing.id})
  2620. ).status_code == 200
  2621. first_response = await async_client.post(
  2622. "/api/v1/queue/",
  2623. json={
  2624. "printer_id": printer.id,
  2625. "archive_id": first_asap.id,
  2626. "insert_position": 1,
  2627. },
  2628. )
  2629. assert first_response.status_code == 200
  2630. second_response = await async_client.post(
  2631. "/api/v1/queue/",
  2632. json={
  2633. "printer_id": printer.id,
  2634. "archive_id": second_asap.id,
  2635. "insert_position": 2,
  2636. },
  2637. )
  2638. assert second_response.status_code == 200
  2639. list_response = await async_client.get(f"/api/v1/queue/?printer_id={printer.id}")
  2640. items = sorted(list_response.json(), key=lambda item: item["position"])
  2641. assert [item["archive_id"] for item in items] == [first_asap.id, second_asap.id, existing.id]
  2642. assert [item["position"] for item in items] == [1, 2, 3]
  2643. @pytest.mark.asyncio
  2644. @pytest.mark.integration
  2645. async def test_add_to_queue_quantity_with_print_options(
  2646. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  2647. ):
  2648. """Verify print options are applied to all batch items."""
  2649. printer = await printer_factory()
  2650. archive = await archive_factory()
  2651. data = {
  2652. "printer_id": printer.id,
  2653. "archive_id": archive.id,
  2654. "quantity": 2,
  2655. "bed_levelling": "off",
  2656. "timelapse": True,
  2657. }
  2658. response = await async_client.post("/api/v1/queue/", json=data)
  2659. assert response.status_code == 200
  2660. batch_id = response.json()["batch_id"]
  2661. list_response = await async_client.get("/api/v1/queue/")
  2662. batch_items = [i for i in list_response.json() if i["batch_id"] == batch_id]
  2663. assert len(batch_items) == 2
  2664. for item in batch_items:
  2665. assert item["bed_levelling"] == "off"
  2666. assert item["timelapse"] is True
  2667. @pytest.mark.asyncio
  2668. @pytest.mark.integration
  2669. async def test_get_batch(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
  2670. """Verify batch can be retrieved with progress stats."""
  2671. printer = await printer_factory()
  2672. archive = await archive_factory()
  2673. # Create a batch of 3
  2674. data = {
  2675. "printer_id": printer.id,
  2676. "archive_id": archive.id,
  2677. "quantity": 3,
  2678. }
  2679. response = await async_client.post("/api/v1/queue/", json=data)
  2680. batch_id = response.json()["batch_id"]
  2681. # Get batch
  2682. response = await async_client.get(f"/api/v1/queue/batches/{batch_id}")
  2683. assert response.status_code == 200
  2684. result = response.json()
  2685. assert result["id"] == batch_id
  2686. assert result["quantity"] == 3
  2687. assert result["status"] == "active"
  2688. assert result["pending_count"] == 3
  2689. assert result["printing_count"] == 0
  2690. assert result["completed_count"] == 0
  2691. @pytest.mark.asyncio
  2692. @pytest.mark.integration
  2693. async def test_list_batches(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
  2694. """Verify batches can be listed."""
  2695. printer = await printer_factory()
  2696. archive = await archive_factory()
  2697. # Create two batches
  2698. for qty in [2, 3]:
  2699. await async_client.post(
  2700. "/api/v1/queue/",
  2701. json={"printer_id": printer.id, "archive_id": archive.id, "quantity": qty},
  2702. )
  2703. response = await async_client.get("/api/v1/queue/batches")
  2704. assert response.status_code == 200
  2705. batches = response.json()
  2706. assert len(batches) >= 2
  2707. @pytest.mark.asyncio
  2708. @pytest.mark.integration
  2709. async def test_cancel_batch(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
  2710. """Verify cancelling a batch cancels all pending items."""
  2711. printer = await printer_factory()
  2712. archive = await archive_factory()
  2713. data = {
  2714. "printer_id": printer.id,
  2715. "archive_id": archive.id,
  2716. "quantity": 3,
  2717. }
  2718. response = await async_client.post("/api/v1/queue/", json=data)
  2719. batch_id = response.json()["batch_id"]
  2720. # Cancel the batch
  2721. response = await async_client.delete(f"/api/v1/queue/batches/{batch_id}")
  2722. assert response.status_code == 200
  2723. # Verify all items are cancelled
  2724. list_response = await async_client.get("/api/v1/queue/")
  2725. batch_items = [i for i in list_response.json() if i["batch_id"] == batch_id]
  2726. for item in batch_items:
  2727. assert item["status"] == "cancelled"
  2728. # Verify batch status
  2729. batch_response = await async_client.get(f"/api/v1/queue/batches/{batch_id}")
  2730. assert batch_response.json()["status"] == "cancelled"
  2731. @pytest.mark.asyncio
  2732. @pytest.mark.integration
  2733. async def test_get_batch_not_found(self, async_client: AsyncClient):
  2734. """Verify 404 for non-existent batch."""
  2735. response = await async_client.get("/api/v1/queue/batches/9999")
  2736. assert response.status_code == 404
  2737. # ========================================================================
  2738. # Queue redesign: create-empty + group-existing + ungroup
  2739. # ========================================================================
  2740. @pytest.mark.asyncio
  2741. @pytest.mark.integration
  2742. async def test_create_empty_batch_for_client_side_grouping(
  2743. self, async_client: AsyncClient, printer_factory, archive_factory
  2744. ):
  2745. """Verify POST /queue/batches without item_ids creates an empty batch
  2746. whose id can be passed on subsequent /queue/ POSTs (the multi-plate
  2747. auto-batch flow). Subsequent items must end up with the same batch_id."""
  2748. printer = await printer_factory()
  2749. archive = await archive_factory()
  2750. # 1. Pre-create batch
  2751. batch_resp = await async_client.post(
  2752. "/api/v1/queue/batches",
  2753. json={"name": "Plates · 2 plates", "archive_id": archive.id},
  2754. )
  2755. assert batch_resp.status_code == 200
  2756. batch = batch_resp.json()
  2757. assert batch["status"] == "active"
  2758. batch_id = batch["id"]
  2759. # 2. Add two items referencing that batch
  2760. for plate_id in (1, 2):
  2761. item_resp = await async_client.post(
  2762. "/api/v1/queue/",
  2763. json={
  2764. "printer_id": printer.id,
  2765. "archive_id": archive.id,
  2766. "plate_id": plate_id,
  2767. "batch_id": batch_id,
  2768. },
  2769. )
  2770. assert item_resp.status_code == 200
  2771. assert item_resp.json()["batch_id"] == batch_id
  2772. # 3. Verify batch now has 2 pending children
  2773. list_resp = await async_client.get("/api/v1/queue/")
  2774. siblings = [i for i in list_resp.json() if i["batch_id"] == batch_id]
  2775. assert len(siblings) == 2
  2776. assert {i["plate_id"] for i in siblings} == {1, 2}
  2777. @pytest.mark.asyncio
  2778. @pytest.mark.integration
  2779. async def test_group_existing_items_as_batch(
  2780. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory
  2781. ):
  2782. """Verify POST /queue/batches with item_ids assigns batch_id to
  2783. existing pending items (the 'Group as batch' UI action)."""
  2784. printer = await printer_factory()
  2785. archive = await archive_factory()
  2786. item_a = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
  2787. item_b = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
  2788. resp = await async_client.post(
  2789. "/api/v1/queue/batches",
  2790. json={"name": "Manual group", "item_ids": [item_a.id, item_b.id]},
  2791. )
  2792. assert resp.status_code == 200
  2793. batch_id = resp.json()["id"]
  2794. list_resp = await async_client.get("/api/v1/queue/")
  2795. grouped = [i for i in list_resp.json() if i["batch_id"] == batch_id]
  2796. assert {i["id"] for i in grouped} == {item_a.id, item_b.id}
  2797. @pytest.mark.asyncio
  2798. @pytest.mark.integration
  2799. async def test_group_skips_non_pending_items(
  2800. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory
  2801. ):
  2802. """Verify grouping doesn't pull in already-completed/cancelled items."""
  2803. printer = await printer_factory()
  2804. archive = await archive_factory()
  2805. pending = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
  2806. completed = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="completed")
  2807. resp = await async_client.post(
  2808. "/api/v1/queue/batches",
  2809. json={"name": "Mixed", "item_ids": [pending.id, completed.id]},
  2810. )
  2811. assert resp.status_code == 200
  2812. batch_id = resp.json()["id"]
  2813. list_resp = await async_client.get("/api/v1/queue/")
  2814. grouped = [i for i in list_resp.json() if i["batch_id"] == batch_id]
  2815. assert {i["id"] for i in grouped} == {pending.id}
  2816. @pytest.mark.asyncio
  2817. @pytest.mark.integration
  2818. async def test_create_batch_requires_name(self, async_client: AsyncClient):
  2819. """Verify empty / whitespace-only name is rejected with 400."""
  2820. resp = await async_client.post("/api/v1/queue/batches", json={"name": " "})
  2821. assert resp.status_code == 400
  2822. @pytest.mark.asyncio
  2823. @pytest.mark.integration
  2824. async def test_ungroup_batch_clears_batch_id_and_deletes_row(
  2825. self, async_client: AsyncClient, printer_factory, archive_factory
  2826. ):
  2827. """Verify POST /queue/batches/{id}/ungroup clears batch_id from all
  2828. members and deletes the batch row when nothing remains assigned."""
  2829. printer = await printer_factory()
  2830. archive = await archive_factory()
  2831. # Create batch with two items via the existing quantity flow
  2832. add_resp = await async_client.post(
  2833. "/api/v1/queue/",
  2834. json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2},
  2835. )
  2836. batch_id = add_resp.json()["batch_id"]
  2837. # Ungroup
  2838. ungroup_resp = await async_client.post(f"/api/v1/queue/batches/{batch_id}/ungroup")
  2839. assert ungroup_resp.status_code == 200
  2840. assert ungroup_resp.json()["ungrouped_count"] == 2
  2841. # Verify items still exist but no longer batched
  2842. list_resp = await async_client.get("/api/v1/queue/")
  2843. ex_members = [i for i in list_resp.json() if i["batch_id"] == batch_id]
  2844. assert ex_members == []
  2845. # Batch row was deleted
  2846. get_resp = await async_client.get(f"/api/v1/queue/batches/{batch_id}")
  2847. assert get_resp.status_code == 404
  2848. @pytest.mark.asyncio
  2849. @pytest.mark.integration
  2850. async def test_add_to_queue_with_unknown_batch_id_404(
  2851. self, async_client: AsyncClient, printer_factory, archive_factory
  2852. ):
  2853. """Verify addToQueue with a non-existent batch_id is rejected."""
  2854. printer = await printer_factory()
  2855. archive = await archive_factory()
  2856. resp = await async_client.post(
  2857. "/api/v1/queue/",
  2858. json={
  2859. "printer_id": printer.id,
  2860. "archive_id": archive.id,
  2861. "batch_id": 99999,
  2862. },
  2863. )
  2864. assert resp.status_code == 404
  2865. # ========================================================================
  2866. # Soft-deleted archive handling (#1348 follow-up)
  2867. # ========================================================================
  2868. @pytest.mark.asyncio
  2869. @pytest.mark.integration
  2870. async def test_soft_delete_archive_deletes_all_related_queue_items(
  2871. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
  2872. ):
  2873. """Soft-deleting an archive removes every related queue item, regardless
  2874. of status (#1734). Pre-#1734 only ``pending`` rows were flipped to
  2875. ``cancelled`` and stayed in the DB, surprising users who expected the
  2876. queue lines to disappear with the archive — especially on multi-plate
  2877. Send All uploads (#1733), where ONE archive backed N queue items and
  2878. soft-deleting the archive left N "cancelled" rows behind. The change
  2879. keeps the printing guard (a row with ``status='printing'`` blocks the
  2880. delete one layer up at the API route), so we never delete the row of
  2881. an actively-running print here.
  2882. Print history lives in ``PrintLogEntry`` (FK ``ON DELETE SET NULL``) —
  2883. the audit trail survives independently of the queue rows.
  2884. """
  2885. from sqlalchemy import select
  2886. from backend.app.models.print_queue import PrintQueueItem
  2887. from backend.app.services.archive import ArchiveService
  2888. printer = await printer_factory()
  2889. archive = await archive_factory(thumbnail_path="archives/test/test/thumbnail.png")
  2890. pending = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
  2891. completed = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="completed")
  2892. service = ArchiveService(db_session)
  2893. assert await service.soft_delete_archive(archive.id) is True
  2894. # Every queue row that referenced this archive is gone — both the
  2895. # pending and the completed rows. Print history (PrintLogEntry) is
  2896. # the authoritative record and is preserved by the FK SET NULL.
  2897. remaining = (
  2898. (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_([pending.id, completed.id]))))
  2899. .scalars()
  2900. .all()
  2901. )
  2902. assert remaining == [], (
  2903. "Soft-deleting the archive must delete every related queue row, "
  2904. f"got {[(r.id, r.status) for r in remaining]} still present"
  2905. )
  2906. @pytest.mark.asyncio
  2907. @pytest.mark.integration
  2908. async def test_queue_api_hides_archive_surface_when_soft_deleted(
  2909. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
  2910. ):
  2911. """Queue serializer must NOT populate archive_thumbnail / archive_name
  2912. when the archive is soft-deleted — otherwise the frontend renders a
  2913. broken <img> and 404-storms the thumbnail / plates / plate-thumbnail
  2914. endpoints. archive_deleted=True signals the soft-deleted state so
  2915. the UI can render a 'source deleted' badge."""
  2916. from datetime import datetime, timezone
  2917. printer = await printer_factory()
  2918. archive = await archive_factory(
  2919. print_name="Test Print",
  2920. thumbnail_path="archives/test/test/thumbnail.png",
  2921. deleted_at=datetime.now(timezone.utc), # Pre-soft-deleted
  2922. )
  2923. item = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="cancelled")
  2924. resp = await async_client.get("/api/v1/queue/")
  2925. assert resp.status_code == 200
  2926. body = resp.json()
  2927. row = next((r for r in body if r["id"] == item.id), None)
  2928. assert row is not None
  2929. assert row["archive_deleted"] is True
  2930. assert row["archive_thumbnail"] is None, "must not expose stale thumbnail path for soft-deleted archive"
  2931. assert row["archive_name"] is None
  2932. @pytest.mark.asyncio
  2933. @pytest.mark.integration
  2934. async def test_queue_api_still_exposes_archive_surface_when_live(
  2935. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
  2936. ):
  2937. """Sanity guard: the soft-delete suppression must not affect live
  2938. archives. archive_name / archive_thumbnail still flow through and
  2939. archive_deleted stays False."""
  2940. printer = await printer_factory()
  2941. archive = await archive_factory(
  2942. print_name="Live Archive",
  2943. thumbnail_path="archives/test/live/thumbnail.png",
  2944. )
  2945. item = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
  2946. resp = await async_client.get("/api/v1/queue/")
  2947. assert resp.status_code == 200
  2948. row = next((r for r in resp.json() if r["id"] == item.id), None)
  2949. assert row is not None
  2950. assert row["archive_deleted"] is False
  2951. assert row["archive_name"] == "Live Archive"
  2952. assert row["archive_thumbnail"] == "archives/test/live/thumbnail.png"
  2953. class TestResumeQueueAfterFailure:
  2954. """Integration tests for POST /api/v1/queue/printer/{id}/resume (#1818)."""
  2955. @pytest.fixture
  2956. async def printer_factory(self, db_session):
  2957. _counter = [0]
  2958. async def _create_printer(**kwargs):
  2959. from backend.app.models.printer import Printer
  2960. _counter[0] += 1
  2961. counter = _counter[0]
  2962. defaults = {
  2963. "name": f"Resume Printer {counter}",
  2964. "ip_address": f"192.168.42.{100 + counter}",
  2965. "serial_number": f"RESUMESERIAL{counter:04d}",
  2966. "access_code": "12345678",
  2967. "model": "P1S",
  2968. }
  2969. defaults.update(kwargs)
  2970. printer = Printer(**defaults)
  2971. db_session.add(printer)
  2972. await db_session.commit()
  2973. await db_session.refresh(printer)
  2974. return printer
  2975. return _create_printer
  2976. @pytest.fixture
  2977. async def archive_factory(self, db_session):
  2978. _counter = [0]
  2979. async def _create_archive(**kwargs):
  2980. from backend.app.models.archive import PrintArchive
  2981. _counter[0] += 1
  2982. counter = _counter[0]
  2983. defaults = {
  2984. "filename": f"resume_print_{counter}.3mf",
  2985. "print_name": f"Resume Print {counter}",
  2986. "file_path": f"/tmp/resume_print_{counter}.3mf", # nosec B108
  2987. "file_size": 1024,
  2988. "content_hash": f"resumehash{counter:08d}",
  2989. "status": "completed",
  2990. }
  2991. defaults.update(kwargs)
  2992. archive = PrintArchive(**defaults)
  2993. db_session.add(archive)
  2994. await db_session.commit()
  2995. await db_session.refresh(archive)
  2996. return archive
  2997. return _create_archive
  2998. async def _add_item(self, db_session, printer, archive_factory, **kwargs):
  2999. from backend.app.models.print_queue import PrintQueueItem
  3000. archive = await archive_factory()
  3001. defaults = {
  3002. "printer_id": printer.id,
  3003. "archive_id": archive.id,
  3004. "status": "pending",
  3005. "require_previous_success": True,
  3006. }
  3007. defaults.update(kwargs)
  3008. item = PrintQueueItem(**defaults)
  3009. db_session.add(item)
  3010. await db_session.commit()
  3011. await db_session.refresh(item)
  3012. return item
  3013. @pytest.mark.asyncio
  3014. @pytest.mark.integration
  3015. async def test_resume_unknown_printer_returns_404(self, async_client: AsyncClient):
  3016. resp = await async_client.post("/api/v1/queue/printer/999999/resume")
  3017. assert resp.status_code == 404
  3018. @pytest.mark.asyncio
  3019. @pytest.mark.integration
  3020. async def test_resume_no_op_on_clean_queue(self, async_client: AsyncClient, printer_factory):
  3021. """Calling resume on a printer with no failures and no skipped items
  3022. returns zero counts — endpoint is idempotent and safe to spam."""
  3023. printer = await printer_factory()
  3024. resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
  3025. assert resp.status_code == 200
  3026. assert resp.json() == {"acknowledged": 0, "restored": 0}
  3027. @pytest.mark.asyncio
  3028. @pytest.mark.integration
  3029. async def test_resume_acknowledges_failed_and_restores_skipped(
  3030. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  3031. ):
  3032. """Reporter's scenario: failed predecessor + N skipped downstream items.
  3033. Resume sets gate_acknowledged on the failure and flips skipped → pending."""
  3034. from sqlalchemy import select
  3035. from backend.app.models.print_queue import PrintQueueItem
  3036. printer = await printer_factory()
  3037. failed = await self._add_item(db_session, printer, archive_factory, status="failed")
  3038. skipped_1 = await self._add_item(
  3039. db_session,
  3040. printer,
  3041. archive_factory,
  3042. status="skipped",
  3043. error_message="Previous print failed or was aborted",
  3044. )
  3045. skipped_2 = await self._add_item(
  3046. db_session,
  3047. printer,
  3048. archive_factory,
  3049. status="skipped",
  3050. error_message="Previous print failed or was aborted",
  3051. )
  3052. resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
  3053. assert resp.status_code == 200
  3054. assert resp.json() == {"acknowledged": 1, "restored": 2}
  3055. failed_id = failed.id
  3056. skipped_ids = [skipped_1.id, skipped_2.id]
  3057. db_session.expire_all()
  3058. result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == failed_id))
  3059. assert result.scalar_one().gate_acknowledged is True
  3060. for sid in skipped_ids:
  3061. result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == sid))
  3062. row = result.scalar_one()
  3063. assert row.status == "pending"
  3064. assert row.error_message is None
  3065. assert row.completed_at is None
  3066. @pytest.mark.asyncio
  3067. @pytest.mark.integration
  3068. async def test_resume_preserves_skipped_items_with_other_reasons(
  3069. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  3070. ):
  3071. """Skipped items whose error_message is something OTHER than the
  3072. gate string (e.g. filament-deficit promotion, future skip reasons)
  3073. must not be touched — they encode different user intent."""
  3074. from sqlalchemy import select
  3075. from backend.app.models.print_queue import PrintQueueItem
  3076. printer = await printer_factory()
  3077. gate_skip = await self._add_item(
  3078. db_session,
  3079. printer,
  3080. archive_factory,
  3081. status="skipped",
  3082. error_message="Previous print failed or was aborted",
  3083. )
  3084. other_skip = await self._add_item(
  3085. db_session,
  3086. printer,
  3087. archive_factory,
  3088. status="skipped",
  3089. error_message="User skipped via UI",
  3090. )
  3091. gate_id = gate_skip.id
  3092. other_id = other_skip.id
  3093. resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
  3094. assert resp.json() == {"acknowledged": 0, "restored": 1}
  3095. db_session.expire_all()
  3096. result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == gate_id))
  3097. assert result.scalar_one().status == "pending"
  3098. result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == other_id))
  3099. assert result.scalar_one().status == "skipped"
  3100. @pytest.mark.asyncio
  3101. @pytest.mark.integration
  3102. async def test_resume_scoped_to_printer(
  3103. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  3104. ):
  3105. """A resume on printer A must not clear printer B's gate — farms run
  3106. each printer's queue independently."""
  3107. from sqlalchemy import select
  3108. from backend.app.models.print_queue import PrintQueueItem
  3109. p1 = await printer_factory()
  3110. p2 = await printer_factory()
  3111. failed_p1 = await self._add_item(db_session, p1, archive_factory, status="failed")
  3112. failed_p2 = await self._add_item(db_session, p2, archive_factory, status="failed")
  3113. failed_p1_id = failed_p1.id
  3114. failed_p2_id = failed_p2.id
  3115. resp = await async_client.post(f"/api/v1/queue/printer/{p1.id}/resume")
  3116. assert resp.json() == {"acknowledged": 1, "restored": 0}
  3117. db_session.expire_all()
  3118. result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == failed_p1_id))
  3119. assert result.scalar_one().gate_acknowledged is True
  3120. result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == failed_p2_id))
  3121. assert result.scalar_one().gate_acknowledged is False
  3122. @pytest.mark.asyncio
  3123. @pytest.mark.integration
  3124. async def test_resume_handles_aborted_status(
  3125. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  3126. ):
  3127. """Aborted prints (printer-detected mid-print failure) gate the same
  3128. way failed prints do and must also be acknowledgeable."""
  3129. from sqlalchemy import select
  3130. from backend.app.models.print_queue import PrintQueueItem
  3131. printer = await printer_factory()
  3132. aborted = await self._add_item(db_session, printer, archive_factory, status="aborted")
  3133. aborted_id = aborted.id
  3134. resp = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
  3135. assert resp.json() == {"acknowledged": 1, "restored": 0}
  3136. db_session.expire_all()
  3137. result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == aborted_id))
  3138. assert result.scalar_one().gate_acknowledged is True
  3139. @pytest.mark.asyncio
  3140. @pytest.mark.integration
  3141. async def test_resume_idempotent_second_call_is_no_op(
  3142. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  3143. ):
  3144. """Calling resume twice on the same printer doesn't re-acknowledge
  3145. the same failure — the second call sees acknowledged=0, restored=0."""
  3146. printer = await printer_factory()
  3147. await self._add_item(db_session, printer, archive_factory, status="failed")
  3148. await self._add_item(
  3149. db_session,
  3150. printer,
  3151. archive_factory,
  3152. status="skipped",
  3153. error_message="Previous print failed or was aborted",
  3154. )
  3155. first = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
  3156. assert first.json() == {"acknowledged": 1, "restored": 1}
  3157. second = await async_client.post(f"/api/v1/queue/printer/{printer.id}/resume")
  3158. assert second.json() == {"acknowledged": 0, "restored": 0}
  3159. class TestReorderEndpoint:
  3160. """Tests for the /queue/reorder endpoint (#1625-followup duplicate-position validator)."""
  3161. @pytest.fixture
  3162. async def printer_factory(self, db_session):
  3163. async def _create(**kwargs):
  3164. from backend.app.models.printer import Printer
  3165. defaults = {
  3166. "name": "Reorder Test Printer",
  3167. "ip_address": "192.168.1.220",
  3168. "serial_number": "TESTREORDER001",
  3169. "access_code": "12345678",
  3170. "model": "X1C",
  3171. }
  3172. defaults.update(kwargs)
  3173. printer = Printer(**defaults)
  3174. db_session.add(printer)
  3175. await db_session.commit()
  3176. await db_session.refresh(printer)
  3177. return printer
  3178. return _create
  3179. @pytest.fixture
  3180. async def archive_factory(self, db_session):
  3181. _counter = [0]
  3182. async def _create(**kwargs):
  3183. from backend.app.models.archive import PrintArchive
  3184. _counter[0] += 1
  3185. defaults = {
  3186. "filename": f"reorder_{_counter[0]}.3mf",
  3187. "print_name": f"Reorder {_counter[0]}",
  3188. "file_path": f"/tmp/reorder_{_counter[0]}.3mf", # nosec B108
  3189. "file_size": 1024,
  3190. "content_hash": f"reorderhash{_counter[0]:06d}",
  3191. "status": "completed",
  3192. }
  3193. defaults.update(kwargs)
  3194. archive = PrintArchive(**defaults)
  3195. db_session.add(archive)
  3196. await db_session.commit()
  3197. await db_session.refresh(archive)
  3198. return archive
  3199. return _create
  3200. @pytest.mark.asyncio
  3201. @pytest.mark.integration
  3202. async def test_reorder_rejects_duplicate_positions(
  3203. self, async_client: AsyncClient, db_session, printer_factory, archive_factory
  3204. ):
  3205. """Reorder payload with duplicate positions → 422 at schema layer.
  3206. Regression guard: pre-fix, a buggy client sending two items at the
  3207. same position would leave the queue in an inconsistent state (the
  3208. scheduler's ORDER BY (printer_id, position) tie would be broken by
  3209. physical row order — non-deterministic dispatch order).
  3210. """
  3211. from backend.app.models.print_queue import PrintQueueItem
  3212. printer = await printer_factory()
  3213. a1 = await archive_factory()
  3214. a2 = await archive_factory()
  3215. item1 = PrintQueueItem(printer_id=printer.id, archive_id=a1.id, status="pending", position=1)
  3216. item2 = PrintQueueItem(printer_id=printer.id, archive_id=a2.id, status="pending", position=2)
  3217. db_session.add_all([item1, item2])
  3218. await db_session.commit()
  3219. await db_session.refresh(item1)
  3220. await db_session.refresh(item2)
  3221. response = await async_client.post(
  3222. "/api/v1/queue/reorder",
  3223. json={
  3224. "items": [
  3225. {"id": item1.id, "position": 1},
  3226. {"id": item2.id, "position": 1}, # duplicate
  3227. ]
  3228. },
  3229. )
  3230. assert response.status_code == 422
  3231. body = response.json()
  3232. # Pydantic v2 wraps custom validator errors; the message must mention "Duplicate"
  3233. # so the FE can surface the actionable detail.
  3234. assert any("duplicate" in str(err).lower() for err in body.get("detail", []))
  3235. @pytest.mark.asyncio
  3236. @pytest.mark.integration
  3237. async def test_reorder_accepts_unique_positions(
  3238. self, async_client: AsyncClient, db_session, printer_factory, archive_factory
  3239. ):
  3240. """Reorder with unique positions succeeds and updates them in DB."""
  3241. from backend.app.models.print_queue import PrintQueueItem
  3242. printer = await printer_factory()
  3243. a1 = await archive_factory()
  3244. a2 = await archive_factory()
  3245. item1 = PrintQueueItem(printer_id=printer.id, archive_id=a1.id, status="pending", position=1)
  3246. item2 = PrintQueueItem(printer_id=printer.id, archive_id=a2.id, status="pending", position=2)
  3247. db_session.add_all([item1, item2])
  3248. await db_session.commit()
  3249. await db_session.refresh(item1)
  3250. await db_session.refresh(item2)
  3251. response = await async_client.post(
  3252. "/api/v1/queue/reorder",
  3253. json={
  3254. "items": [
  3255. {"id": item1.id, "position": 2},
  3256. {"id": item2.id, "position": 1},
  3257. ]
  3258. },
  3259. )
  3260. assert response.status_code == 200
  3261. await db_session.refresh(item1)
  3262. await db_session.refresh(item2)
  3263. assert item1.position == 2
  3264. assert item2.position == 1
  3265. class TestForceColorOverridesAreScopedToThePlate:
  3266. """Queueing several plates of one 3MF must not make each plate wait on the
  3267. colours of its siblings (#2551).
  3268. The print dialog builds one override list from every selected plate and posts
  3269. that same list with each plate's item, so the API is what has to keep only the
  3270. slots the plate prints -- a ``force_color_match`` entry blocks dispatch until
  3271. the printer has that exact colour loaded.
  3272. """
  3273. THREE_PLATES = """<?xml version="1.0" encoding="UTF-8"?>
  3274. <config>
  3275. <plate>
  3276. <metadata key="index" value="1"/>
  3277. <filament id="1" used_g="50.0" type="PLA" color="#0B2C7A"/>
  3278. </plate>
  3279. <plate>
  3280. <metadata key="index" value="2"/>
  3281. <filament id="2" used_g="40.0" type="PLA" color="#9B9EA0"/>
  3282. </plate>
  3283. <plate>
  3284. <metadata key="index" value="3"/>
  3285. <filament id="3" used_g="30.0" type="PLA" color="#F4EE2A"/>
  3286. </plate>
  3287. </config>
  3288. """
  3289. # What the dialog posts for every plate: the union of all three plates'
  3290. # filaments, each one force-matched.
  3291. ALL_THREE_COLORS = [
  3292. {"slot_id": 1, "type": "PLA", "color": "#0B2C7A", "color_name": "Army Blue", "force_color_match": True},
  3293. {"slot_id": 2, "type": "PLA", "color": "#9B9EA0", "color_name": "Ash Grey", "force_color_match": True},
  3294. {"slot_id": 3, "type": "PLA", "color": "#F4EE2A", "color_name": "Sunshine Yellow", "force_color_match": True},
  3295. ]
  3296. @pytest.fixture
  3297. async def multi_plate_archive(self, db_session, tmp_path):
  3298. """An archive whose 3MF really exists on disk, one colour per plate."""
  3299. import zipfile
  3300. from backend.app.models.archive import PrintArchive
  3301. file_path = tmp_path / "three_plates.gcode.3mf"
  3302. with zipfile.ZipFile(file_path, "w") as zf:
  3303. zf.writestr("Metadata/slice_info.config", self.THREE_PLATES)
  3304. # Sliced, or the queue refuses it (#3435). One plate's G-code only:
  3305. # with more, a job naming no plate would print the default one
  3306. # rather than the whole file these tests queue.
  3307. zf.writestr("Metadata/plate_1.gcode", "; plate 1")
  3308. archive = PrintArchive(
  3309. filename="three_plates.gcode.3mf",
  3310. print_name="Three Plates",
  3311. file_path=str(file_path),
  3312. file_size=file_path.stat().st_size,
  3313. content_hash="platehash0001",
  3314. status="completed",
  3315. )
  3316. db_session.add(archive)
  3317. await db_session.commit()
  3318. await db_session.refresh(archive)
  3319. return archive
  3320. @pytest.fixture
  3321. async def x1c(self, db_session):
  3322. from backend.app.models.printer import Printer
  3323. printer = Printer(
  3324. name="Force Color X1C",
  3325. ip_address="192.168.1.210",
  3326. serial_number="FORCECOLOR01",
  3327. access_code="12345678",
  3328. model="X1C",
  3329. )
  3330. db_session.add(printer)
  3331. await db_session.commit()
  3332. await db_session.refresh(printer)
  3333. return printer
  3334. @pytest.mark.asyncio
  3335. @pytest.mark.integration
  3336. async def test_each_plate_keeps_only_the_colour_it_prints(
  3337. self, async_client: AsyncClient, multi_plate_archive, x1c
  3338. ):
  3339. """The bug: plate 1 prints Army Blue only, but was stored demanding all three."""
  3340. stored = {}
  3341. for plate_id in (1, 2, 3):
  3342. response = await async_client.post(
  3343. "/api/v1/queue/",
  3344. json={
  3345. "target_model": "X1C",
  3346. "archive_id": multi_plate_archive.id,
  3347. "plate_id": plate_id,
  3348. "filament_overrides": self.ALL_THREE_COLORS,
  3349. },
  3350. )
  3351. assert response.status_code == 200
  3352. stored[plate_id] = response.json()["filament_overrides"]
  3353. assert [o["color_name"] for o in stored[1]] == ["Army Blue"]
  3354. assert [o["color_name"] for o in stored[2]] == ["Ash Grey"]
  3355. assert [o["color_name"] for o in stored[3]] == ["Sunshine Yellow"]
  3356. # The slot each entry maps to has to survive narrowing untouched, or the
  3357. # dispatch-time AMS mapping would key the override onto the wrong slot.
  3358. assert [o["slot_id"] for o in stored[2]] == [2]
  3359. @pytest.mark.asyncio
  3360. @pytest.mark.integration
  3361. async def test_whole_file_queue_keeps_every_colour(self, async_client: AsyncClient, multi_plate_archive, x1c):
  3362. """No plate_id means the job prints the whole file, so every colour is needed."""
  3363. response = await async_client.post(
  3364. "/api/v1/queue/",
  3365. json={
  3366. "target_model": "X1C",
  3367. "archive_id": multi_plate_archive.id,
  3368. "filament_overrides": self.ALL_THREE_COLORS,
  3369. },
  3370. )
  3371. assert response.status_code == 200
  3372. assert len(response.json()["filament_overrides"]) == 3
  3373. @pytest.mark.asyncio
  3374. @pytest.mark.integration
  3375. async def test_unreadable_3mf_keeps_every_colour(self, async_client: AsyncClient, db_session, tmp_path, x1c):
  3376. """When the plate's slots can't be read, keep the overrides rather than drop them.
  3377. An item waiting on a colour it doesn't need is visible and fixable; one that
  3378. silently lost its forced colour would dispatch in the wrong filament.
  3379. """
  3380. from backend.app.models.archive import PrintArchive
  3381. file_path = tmp_path / "not_a_zip.gcode.3mf"
  3382. file_path.write_text("this is not a 3mf")
  3383. archive = PrintArchive(
  3384. filename="not_a_zip.gcode.3mf",
  3385. print_name="Corrupt",
  3386. file_path=str(file_path),
  3387. file_size=file_path.stat().st_size,
  3388. content_hash="platehash0002",
  3389. status="completed",
  3390. )
  3391. db_session.add(archive)
  3392. await db_session.commit()
  3393. await db_session.refresh(archive)
  3394. response = await async_client.post(
  3395. "/api/v1/queue/",
  3396. json={
  3397. "target_model": "X1C",
  3398. "archive_id": archive.id,
  3399. "plate_id": 1,
  3400. "filament_overrides": self.ALL_THREE_COLORS,
  3401. },
  3402. )
  3403. assert response.status_code == 200
  3404. assert len(response.json()["filament_overrides"]) == 3
  3405. @pytest.mark.asyncio
  3406. @pytest.mark.integration
  3407. async def test_required_types_stay_scoped_to_the_plate(self, async_client: AsyncClient, db_session, tmp_path, x1c):
  3408. """Override types are merged into required_filament_types, so a shared list
  3409. also widened the type gate -- a PLA-only plate demanded PETG as well."""
  3410. import zipfile
  3411. from backend.app.models.archive import PrintArchive
  3412. xml = """<?xml version="1.0" encoding="UTF-8"?>
  3413. <config>
  3414. <plate>
  3415. <metadata key="index" value="1"/>
  3416. <filament id="1" used_g="50.0" type="PLA" color="#0B2C7A"/>
  3417. </plate>
  3418. <plate>
  3419. <metadata key="index" value="2"/>
  3420. <filament id="2" used_g="40.0" type="PETG" color="#9B9EA0"/>
  3421. </plate>
  3422. </config>
  3423. """
  3424. file_path = tmp_path / "mixed_types.gcode.3mf"
  3425. with zipfile.ZipFile(file_path, "w") as zf:
  3426. zf.writestr("Metadata/slice_info.config", xml)
  3427. zf.writestr("Metadata/plate_1.gcode", "; plate 1") # sliced (#3435)
  3428. archive = PrintArchive(
  3429. filename="mixed_types.gcode.3mf",
  3430. print_name="Mixed",
  3431. file_path=str(file_path),
  3432. file_size=file_path.stat().st_size,
  3433. content_hash="platehash0003",
  3434. status="completed",
  3435. )
  3436. db_session.add(archive)
  3437. await db_session.commit()
  3438. await db_session.refresh(archive)
  3439. response = await async_client.post(
  3440. "/api/v1/queue/",
  3441. json={
  3442. "target_model": "X1C",
  3443. "archive_id": archive.id,
  3444. "plate_id": 1,
  3445. "filament_overrides": [
  3446. {"slot_id": 1, "type": "PLA", "color": "#0B2C7A", "force_color_match": True},
  3447. {"slot_id": 2, "type": "PETG", "color": "#9B9EA0", "force_color_match": True},
  3448. ],
  3449. },
  3450. )
  3451. assert response.status_code == 200
  3452. assert response.json()["required_filament_types"] == ["PLA"]
  3453. @pytest.mark.asyncio
  3454. @pytest.mark.integration
  3455. async def test_editing_an_item_narrows_the_overrides_too(
  3456. self, async_client: AsyncClient, db_session, multi_plate_archive, x1c
  3457. ):
  3458. """The edit dialog posts the same shared list, so PATCH narrows it as well."""
  3459. from backend.app.models.print_queue import PrintQueueItem
  3460. item = PrintQueueItem(
  3461. target_model="X1C",
  3462. archive_id=multi_plate_archive.id,
  3463. plate_id=2,
  3464. status="pending",
  3465. position=1,
  3466. )
  3467. db_session.add(item)
  3468. await db_session.commit()
  3469. await db_session.refresh(item)
  3470. response = await async_client.patch(
  3471. f"/api/v1/queue/{item.id}",
  3472. json={"filament_overrides": self.ALL_THREE_COLORS},
  3473. )
  3474. assert response.status_code == 200
  3475. assert [o["color_name"] for o in response.json()["filament_overrides"]] == ["Ash Grey"]
  3476. @pytest.mark.asyncio
  3477. @pytest.mark.integration
  3478. async def test_editing_the_plate_renarrows_against_the_new_plate(
  3479. self, async_client: AsyncClient, db_session, multi_plate_archive, x1c
  3480. ):
  3481. """Moving an item to another plate must re-scope its colours to that plate."""
  3482. from backend.app.models.print_queue import PrintQueueItem
  3483. item = PrintQueueItem(
  3484. target_model="X1C",
  3485. archive_id=multi_plate_archive.id,
  3486. plate_id=1,
  3487. status="pending",
  3488. position=1,
  3489. )
  3490. db_session.add(item)
  3491. await db_session.commit()
  3492. await db_session.refresh(item)
  3493. response = await async_client.patch(
  3494. f"/api/v1/queue/{item.id}",
  3495. json={"plate_id": 3, "filament_overrides": self.ALL_THREE_COLORS},
  3496. )
  3497. assert response.status_code == 200
  3498. assert [o["color_name"] for o in response.json()["filament_overrides"]] == ["Sunshine Yellow"]
  3499. @pytest.mark.asyncio
  3500. async def test_stop_offline_reconciles_linked_archive_status_2603(
  3501. async_client: AsyncClient, printer_factory, archive_factory, db_session
  3502. ):
  3503. """Stopping a printing item while the printer is offline must also close out its
  3504. archive (#2603).
  3505. When the stop command reaches the printer, the later MQTT completion event flips
  3506. the archive to cancelled. When the printer is offline no such event ever arrives,
  3507. so without this the archive stays "printing" forever while the queue row is
  3508. already cancelled — the reporter's archive 436. The offline branch reconciles the
  3509. archive directly.
  3510. """
  3511. from unittest.mock import MagicMock, patch
  3512. from backend.app.models.print_queue import PrintQueueItem
  3513. printer = await printer_factory(name="Offline printer")
  3514. archive = await archive_factory(
  3515. printer.id, status="printing", plate_id=22, filename="heart 3.gcode.3mf", with_run=False
  3516. )
  3517. item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing")
  3518. db_session.add(item)
  3519. await db_session.commit()
  3520. await db_session.refresh(item)
  3521. # stop_print returns False => printer offline / not connected.
  3522. with patch(
  3523. "backend.app.services.printer_manager.printer_manager.stop_print",
  3524. MagicMock(return_value=False),
  3525. ):
  3526. resp = await async_client.post(f"/api/v1/queue/{item.id}/stop")
  3527. assert resp.status_code == 200
  3528. await db_session.refresh(item)
  3529. await db_session.refresh(archive)
  3530. assert item.status == "cancelled"
  3531. assert archive.status == "cancelled", "an offline stop must reconcile the archive, not leave it 'printing'"
  3532. assert archive.completed_at is not None
  3533. # A vocabulary key (#2974), not prose: the stop's detail is on the queue row.
  3534. assert archive.failure_reason == "userCancelled"
  3535. @pytest.mark.asyncio
  3536. async def test_stop_offline_logs_the_run_with_the_items_plate(
  3537. async_client: AsyncClient, printer_factory, archive_factory, db_session
  3538. ):
  3539. """The run's own plate is the queue item's: the archive's is only its
  3540. latest run's, and a reprint of another plate may not have changed it."""
  3541. from unittest.mock import MagicMock, patch
  3542. from sqlalchemy import select
  3543. from backend.app.models.print_log import PrintLogEntry
  3544. from backend.app.models.print_queue import PrintQueueItem
  3545. printer = await printer_factory(name="Offline printer")
  3546. archive = await archive_factory(printer.id, status="printing", plate_id=1, with_run=False)
  3547. item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing", plate_id=3)
  3548. db_session.add(item)
  3549. await db_session.commit()
  3550. with patch(
  3551. "backend.app.services.printer_manager.printer_manager.stop_print",
  3552. MagicMock(return_value=False),
  3553. ):
  3554. resp = await async_client.post(f"/api/v1/queue/{item.id}/stop")
  3555. assert resp.status_code == 200
  3556. plates = list(
  3557. await db_session.scalars(select(PrintLogEntry.plate_id).where(PrintLogEntry.archive_id == archive.id))
  3558. )
  3559. assert plates == [3]
  3560. @pytest.mark.asyncio
  3561. async def test_stop_online_leaves_archive_for_mqtt_to_reconcile_2603(
  3562. async_client: AsyncClient, printer_factory, archive_factory, db_session
  3563. ):
  3564. """When the stop command reaches the printer, the archive is left to the MQTT
  3565. completion path — the offline reconcile must NOT fire and pre-empt it."""
  3566. from unittest.mock import MagicMock, patch
  3567. from backend.app.models.print_queue import PrintQueueItem
  3568. printer = await printer_factory(name="Online printer")
  3569. archive = await archive_factory(printer.id, status="printing", filename="heart 3.gcode.3mf", with_run=False)
  3570. item = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing")
  3571. db_session.add(item)
  3572. await db_session.commit()
  3573. await db_session.refresh(item)
  3574. with patch(
  3575. "backend.app.services.printer_manager.printer_manager.stop_print",
  3576. MagicMock(return_value=True),
  3577. ):
  3578. resp = await async_client.post(f"/api/v1/queue/{item.id}/stop")
  3579. assert resp.status_code == 200
  3580. await db_session.refresh(item)
  3581. await db_session.refresh(archive)
  3582. assert item.status == "cancelled"
  3583. assert archive.status == "printing", "an online stop must leave the archive for the MQTT completion path"