test_print_queue_api.py 135 KB

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