test_print_queue_api.py 116 KB

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