test_print_queue_api.py 148 KB

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