test_print_queue_api.py 137 KB

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