test_print_queue_api.py 95 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546
  1. """Integration tests for Print Queue API endpoints."""
  2. import pytest
  3. from httpx import AsyncClient
  4. from sqlalchemy import select
  5. from backend.app.models.finance import CostCenter
  6. from backend.app.models.settings import Settings
  7. async def enable_billing(db_session):
  8. setting = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
  9. if setting is None:
  10. db_session.add(Settings(key="billing_enabled", value="true"))
  11. else:
  12. setting.value = "true"
  13. await db_session.commit()
  14. class TestPrintQueueAPI:
  15. """Integration tests for /api/v1/queue endpoints."""
  16. @pytest.fixture
  17. async def printer_factory(self, db_session):
  18. """Factory to create test printers."""
  19. _counter = [0]
  20. async def _create_printer(**kwargs):
  21. from backend.app.models.printer import Printer
  22. _counter[0] += 1
  23. counter = _counter[0]
  24. defaults = {
  25. "name": f"Test Printer {counter}",
  26. "ip_address": f"192.168.1.{100 + counter}",
  27. "serial_number": f"TESTSERIAL{counter:04d}",
  28. "access_code": "12345678",
  29. "model": "X1C",
  30. }
  31. defaults.update(kwargs)
  32. printer = Printer(**defaults)
  33. db_session.add(printer)
  34. await db_session.commit()
  35. await db_session.refresh(printer)
  36. return printer
  37. return _create_printer
  38. @pytest.fixture
  39. async def archive_factory(self, db_session):
  40. """Factory to create test archives."""
  41. _counter = [0]
  42. async def _create_archive(**kwargs):
  43. from backend.app.models.archive import PrintArchive
  44. _counter[0] += 1
  45. counter = _counter[0]
  46. defaults = {
  47. "filename": f"test_print_{counter}.3mf",
  48. "print_name": f"Test Print {counter}",
  49. "file_path": f"/tmp/test_print_{counter}.3mf",
  50. "file_size": 1024,
  51. "content_hash": f"testhash{counter:08d}",
  52. "status": "completed",
  53. }
  54. defaults.update(kwargs)
  55. archive = PrintArchive(**defaults)
  56. db_session.add(archive)
  57. await db_session.commit()
  58. await db_session.refresh(archive)
  59. return archive
  60. return _create_archive
  61. @pytest.fixture
  62. async def queue_item_factory(self, db_session, printer_factory, archive_factory):
  63. """Factory to create test queue items."""
  64. _counter = [0]
  65. async def _create_queue_item(**kwargs):
  66. from backend.app.models.print_queue import PrintQueueItem
  67. _counter[0] += 1
  68. counter = _counter[0]
  69. # Create printer and archive if not provided
  70. if "printer_id" not in kwargs:
  71. printer = await printer_factory()
  72. kwargs["printer_id"] = printer.id
  73. if "archive_id" not in kwargs:
  74. archive = await archive_factory()
  75. kwargs["archive_id"] = archive.id
  76. defaults = {
  77. "status": "pending",
  78. "position": counter,
  79. }
  80. defaults.update(kwargs)
  81. item = PrintQueueItem(**defaults)
  82. db_session.add(item)
  83. await db_session.commit()
  84. await db_session.refresh(item)
  85. return item
  86. return _create_queue_item
  87. @pytest.mark.asyncio
  88. @pytest.mark.integration
  89. async def test_list_queue_empty(self, async_client: AsyncClient):
  90. """Verify empty list when no queue items exist."""
  91. response = await async_client.get("/api/v1/queue/")
  92. assert response.status_code == 200
  93. assert isinstance(response.json(), list)
  94. @pytest.mark.asyncio
  95. @pytest.mark.integration
  96. async def test_add_to_queue(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
  97. """Verify item can be added to queue."""
  98. printer = await printer_factory()
  99. archive = await archive_factory()
  100. data = {
  101. "printer_id": printer.id,
  102. "archive_id": archive.id,
  103. }
  104. response = await async_client.post("/api/v1/queue/", json=data)
  105. assert response.status_code == 200
  106. result = response.json()
  107. assert result["printer_id"] == printer.id
  108. assert result["archive_id"] == archive.id
  109. assert result["status"] == "pending"
  110. assert result["manual_start"] is False
  111. @pytest.mark.asyncio
  112. @pytest.mark.integration
  113. async def test_add_to_queue_with_cost_center_id(
  114. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  115. ):
  116. """Verify item can be added to queue with cost_center_id."""
  117. await enable_billing(db_session)
  118. printer = await printer_factory()
  119. archive = await archive_factory()
  120. cost_center = CostCenter(name="Queue CC", is_active=True, is_private=False)
  121. db_session.add(cost_center)
  122. await db_session.commit()
  123. await db_session.refresh(cost_center)
  124. response = await async_client.post(
  125. "/api/v1/queue/",
  126. json={
  127. "printer_id": printer.id,
  128. "archive_id": archive.id,
  129. "cost_center_id": cost_center.id,
  130. "estimated_cost": 1.25,
  131. },
  132. )
  133. assert response.status_code == 200
  134. result = response.json()
  135. assert result["cost_center_id"] == cost_center.id
  136. assert result["estimated_cost"] == 1.25
  137. from backend.app.models.print_queue import PrintQueueItem
  138. row = await db_session.scalar(select(PrintQueueItem).where(PrintQueueItem.id == result["id"]))
  139. assert row is not None
  140. assert row.cost_center_id == cost_center.id
  141. assert row.estimated_cost == 1.25
  142. @pytest.mark.asyncio
  143. @pytest.mark.integration
  144. async def test_add_to_queue_with_cost_center_requires_estimated_cost(
  145. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  146. ):
  147. """Cost-center queue items require an estimated cost for budget checks."""
  148. await enable_billing(db_session)
  149. printer = await printer_factory()
  150. archive = await archive_factory()
  151. cost_center = CostCenter(name="Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
  152. db_session.add(cost_center)
  153. await db_session.commit()
  154. await db_session.refresh(cost_center)
  155. response = await async_client.post(
  156. "/api/v1/queue/",
  157. json={
  158. "printer_id": printer.id,
  159. "archive_id": archive.id,
  160. "cost_center_id": cost_center.id,
  161. },
  162. )
  163. assert response.status_code == 400
  164. assert "Estimated cost is required" in response.json()["detail"]
  165. @pytest.mark.asyncio
  166. @pytest.mark.integration
  167. async def test_add_to_queue_rejects_when_estimated_cost_exceeds_budget(
  168. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  169. ):
  170. """Queue creation is rejected if the estimated cost exceeds remaining budget."""
  171. await enable_billing(db_session)
  172. printer = await printer_factory()
  173. archive = await archive_factory()
  174. cost_center = CostCenter(name="Tiny Budget CC", is_active=True, is_private=False, monthly_budget=1.0)
  175. db_session.add(cost_center)
  176. await db_session.commit()
  177. await db_session.refresh(cost_center)
  178. response = await async_client.post(
  179. "/api/v1/queue/",
  180. json={
  181. "printer_id": printer.id,
  182. "archive_id": archive.id,
  183. "cost_center_id": cost_center.id,
  184. "estimated_cost": 2.0,
  185. },
  186. )
  187. assert response.status_code == 400
  188. assert "exceeds available cost center budget" in response.json()["detail"]
  189. @pytest.mark.asyncio
  190. @pytest.mark.integration
  191. async def test_add_to_queue_requires_cost_center_when_billing_enabled(
  192. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  193. ):
  194. """Billing enforcement rejects queue jobs that omit cost center."""
  195. await enable_billing(db_session)
  196. printer = await printer_factory()
  197. archive = await archive_factory()
  198. response = await async_client.post(
  199. "/api/v1/queue/",
  200. json={
  201. "printer_id": printer.id,
  202. "archive_id": archive.id,
  203. },
  204. )
  205. assert response.status_code == 400
  206. assert "Cost center is required" in response.json()["detail"]
  207. @pytest.mark.asyncio
  208. @pytest.mark.integration
  209. async def test_add_to_queue_counts_pending_queue_reservations_against_budget(
  210. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
  211. ):
  212. """Open queue items reserve budget until they leave pending/printing states."""
  213. await enable_billing(db_session)
  214. printer = await printer_factory()
  215. archive = await archive_factory()
  216. cost_center = CostCenter(name="Reserved Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
  217. db_session.add(cost_center)
  218. await db_session.commit()
  219. await db_session.refresh(cost_center)
  220. await queue_item_factory(
  221. printer_id=printer.id,
  222. archive_id=archive.id,
  223. cost_center_id=cost_center.id,
  224. estimated_cost=8.0,
  225. status="pending",
  226. )
  227. response = await async_client.post(
  228. "/api/v1/queue/",
  229. json={
  230. "printer_id": printer.id,
  231. "archive_id": archive.id,
  232. "cost_center_id": cost_center.id,
  233. "estimated_cost": 3.0,
  234. },
  235. )
  236. assert response.status_code == 400
  237. assert "exceeds available cost center budget" in response.json()["detail"]
  238. @pytest.mark.asyncio
  239. @pytest.mark.integration
  240. async def test_add_to_queue_with_manual_start(
  241. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  242. ):
  243. """Verify item can be added to queue with manual_start=True."""
  244. printer = await printer_factory()
  245. archive = await archive_factory()
  246. data = {
  247. "printer_id": printer.id,
  248. "archive_id": archive.id,
  249. "manual_start": True,
  250. }
  251. response = await async_client.post("/api/v1/queue/", json=data)
  252. assert response.status_code == 200
  253. result = response.json()
  254. assert result["printer_id"] == printer.id
  255. assert result["archive_id"] == archive.id
  256. assert result["status"] == "pending"
  257. assert result["manual_start"] is True
  258. @pytest.mark.asyncio
  259. @pytest.mark.integration
  260. async def test_add_to_queue_with_skip_filament_check(
  261. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  262. ):
  263. """PrintModal "Print Anyway" persists skip_filament_check on creation (#1698-followup)."""
  264. printer = await printer_factory()
  265. archive = await archive_factory()
  266. data = {
  267. "printer_id": printer.id,
  268. "archive_id": archive.id,
  269. "skip_filament_check": True,
  270. }
  271. response = await async_client.post("/api/v1/queue/", json=data)
  272. assert response.status_code == 200
  273. result = response.json()
  274. assert result["skip_filament_check"] is True
  275. @pytest.mark.asyncio
  276. @pytest.mark.integration
  277. async def test_add_to_queue_skip_filament_check_defaults_false(
  278. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  279. ):
  280. """Default add-to-queue has skip_filament_check=False — no silent bypass."""
  281. printer = await printer_factory()
  282. archive = await archive_factory()
  283. data = {"printer_id": printer.id, "archive_id": archive.id}
  284. response = await async_client.post("/api/v1/queue/", json=data)
  285. assert response.status_code == 200
  286. result = response.json()
  287. assert result["skip_filament_check"] is False
  288. @pytest.mark.asyncio
  289. @pytest.mark.integration
  290. async def test_update_queue_item_cost_center_id(
  291. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
  292. ):
  293. """Verify a pending queue item can be updated with cost_center_id."""
  294. await enable_billing(db_session)
  295. printer = await printer_factory()
  296. archive = await archive_factory()
  297. item = await queue_item_factory(printer_id=printer.id, archive_id=archive.id)
  298. cost_center = CostCenter(name="Update CC", is_active=True, is_private=False)
  299. db_session.add(cost_center)
  300. await db_session.commit()
  301. await db_session.refresh(cost_center)
  302. response = await async_client.patch(
  303. f"/api/v1/queue/{item.id}",
  304. json={"cost_center_id": cost_center.id, "estimated_cost": 1.25},
  305. )
  306. assert response.status_code == 200
  307. assert response.json()["cost_center_id"] == cost_center.id
  308. assert response.json()["estimated_cost"] == 1.25
  309. @pytest.mark.asyncio
  310. @pytest.mark.integration
  311. async def test_add_to_queue_with_project_id(
  312. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  313. ):
  314. """#932: queue items created from the project view carry project_id forward."""
  315. from backend.app.models.project import Project
  316. printer = await printer_factory()
  317. archive = await archive_factory()
  318. project = Project(name="Queue Project")
  319. db_session.add(project)
  320. await db_session.commit()
  321. await db_session.refresh(project)
  322. data = {
  323. "printer_id": printer.id,
  324. "archive_id": archive.id,
  325. "project_id": project.id,
  326. }
  327. response = await async_client.post("/api/v1/queue/", json=data)
  328. assert response.status_code == 200
  329. result = response.json()
  330. # The response schema may or may not echo project_id; the stored row is
  331. # what matters, so verify via DB.
  332. from sqlalchemy import select
  333. from backend.app.models.print_queue import PrintQueueItem
  334. row = (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == result["id"]))).scalar_one()
  335. assert row.project_id == project.id
  336. @pytest.mark.asyncio
  337. @pytest.mark.integration
  338. async def test_add_to_queue_invalid_project_id_returns_404(
  339. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  340. ):
  341. """#932: bogus project_id must be rejected before the FK constraint fires.
  342. Regression guard for the pre-check added to add_to_queue. Without the
  343. validation, a nonexistent project_id would reach db.commit() and raise
  344. an IntegrityError → 500. The pre-check must convert that to a 404 so
  345. the UI gets a clean error it can surface.
  346. """
  347. printer = await printer_factory()
  348. archive = await archive_factory()
  349. data = {
  350. "printer_id": printer.id,
  351. "archive_id": archive.id,
  352. "project_id": 999999, # nonexistent
  353. }
  354. response = await async_client.post("/api/v1/queue/", json=data)
  355. assert response.status_code == 404
  356. assert "project" in response.json()["detail"].lower()
  357. @pytest.mark.asyncio
  358. @pytest.mark.integration
  359. async def test_add_to_queue_with_ams_mapping(
  360. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  361. ):
  362. """Verify item can be added to queue with ams_mapping."""
  363. printer = await printer_factory()
  364. archive = await archive_factory()
  365. data = {
  366. "printer_id": printer.id,
  367. "archive_id": archive.id,
  368. "ams_mapping": [5, -1, 2, -1], # Slot 1 -> tray 5, slot 3 -> tray 2
  369. }
  370. response = await async_client.post("/api/v1/queue/", json=data)
  371. assert response.status_code == 200
  372. result = response.json()
  373. assert result["printer_id"] == printer.id
  374. assert result["archive_id"] == archive.id
  375. assert result["ams_mapping"] == [5, -1, 2, -1]
  376. @pytest.mark.asyncio
  377. @pytest.mark.integration
  378. async def test_add_to_queue_with_plate_id(
  379. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  380. ):
  381. """Verify item can be added to queue with plate_id for multi-plate 3MF."""
  382. printer = await printer_factory()
  383. archive = await archive_factory()
  384. data = {
  385. "printer_id": printer.id,
  386. "archive_id": archive.id,
  387. "plate_id": 3,
  388. }
  389. response = await async_client.post("/api/v1/queue/", json=data)
  390. assert response.status_code == 200
  391. result = response.json()
  392. assert result["plate_id"] == 3
  393. @pytest.mark.asyncio
  394. @pytest.mark.integration
  395. async def test_add_to_queue_with_print_options(
  396. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  397. ):
  398. """Verify item can be added to queue with print options."""
  399. printer = await printer_factory()
  400. archive = await archive_factory()
  401. data = {
  402. "printer_id": printer.id,
  403. "archive_id": archive.id,
  404. "bed_levelling": False,
  405. "flow_cali": True,
  406. "vibration_cali": False,
  407. "layer_inspect": True,
  408. "timelapse": True,
  409. "use_ams": False,
  410. }
  411. response = await async_client.post("/api/v1/queue/", json=data)
  412. assert response.status_code == 200
  413. result = response.json()
  414. assert result["bed_levelling"] is False
  415. assert result["flow_cali"] is True
  416. assert result["vibration_cali"] is False
  417. assert result["layer_inspect"] is True
  418. assert result["timelapse"] is True
  419. assert result["use_ams"] is False
  420. @pytest.mark.asyncio
  421. @pytest.mark.integration
  422. async def test_update_queue_item_plate_id(self, async_client: AsyncClient, queue_item_factory, db_session):
  423. """Verify queue item plate_id can be updated."""
  424. item = await queue_item_factory()
  425. response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"plate_id": 5})
  426. assert response.status_code == 200
  427. result = response.json()
  428. assert result["plate_id"] == 5
  429. @pytest.mark.asyncio
  430. @pytest.mark.integration
  431. async def test_update_queue_item_print_options(self, async_client: AsyncClient, queue_item_factory, db_session):
  432. """Verify queue item print options can be updated."""
  433. item = await queue_item_factory()
  434. response = await async_client.patch(
  435. f"/api/v1/queue/{item.id}",
  436. json={
  437. "bed_levelling": False,
  438. "timelapse": True,
  439. },
  440. )
  441. assert response.status_code == 200
  442. result = response.json()
  443. assert result["bed_levelling"] is False
  444. assert result["timelapse"] is True
  445. @pytest.mark.asyncio
  446. @pytest.mark.integration
  447. async def test_get_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  448. """Verify single queue item can be retrieved."""
  449. item = await queue_item_factory()
  450. response = await async_client.get(f"/api/v1/queue/{item.id}")
  451. assert response.status_code == 200
  452. assert response.json()["id"] == item.id
  453. @pytest.mark.asyncio
  454. @pytest.mark.integration
  455. async def test_get_queue_item_not_found(self, async_client: AsyncClient):
  456. """Verify 404 for non-existent queue item."""
  457. response = await async_client.get("/api/v1/queue/9999")
  458. assert response.status_code == 404
  459. @pytest.mark.asyncio
  460. @pytest.mark.integration
  461. async def test_update_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  462. """Verify queue item can be updated."""
  463. item = await queue_item_factory()
  464. response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"auto_off_after": True})
  465. assert response.status_code == 200
  466. result = response.json()
  467. assert result["auto_off_after"] is True
  468. @pytest.mark.asyncio
  469. @pytest.mark.integration
  470. async def test_update_queue_item_manual_start(self, async_client: AsyncClient, queue_item_factory, db_session):
  471. """Verify queue item manual_start can be updated."""
  472. item = await queue_item_factory(manual_start=False)
  473. response = await async_client.patch(f"/api/v1/queue/{item.id}", json={"manual_start": True})
  474. assert response.status_code == 200
  475. result = response.json()
  476. assert result["manual_start"] is True
  477. @pytest.mark.asyncio
  478. @pytest.mark.integration
  479. async def test_delete_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  480. """Verify queue item can be deleted."""
  481. item = await queue_item_factory()
  482. response = await async_client.delete(f"/api/v1/queue/{item.id}")
  483. assert response.status_code == 200
  484. assert response.json()["message"] == "Queue item deleted"
  485. @pytest.mark.asyncio
  486. @pytest.mark.integration
  487. async def test_delete_queue_item_releases_reserved_budget(
  488. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
  489. ):
  490. """Deleting a pending cost-center queue item releases its reserved budget."""
  491. await enable_billing(db_session)
  492. printer = await printer_factory()
  493. archive = await archive_factory()
  494. cost_center = CostCenter(
  495. name="Delete Releases Budget CC", is_active=True, is_private=False, monthly_budget=10.0
  496. )
  497. db_session.add(cost_center)
  498. await db_session.commit()
  499. await db_session.refresh(cost_center)
  500. item = await queue_item_factory(
  501. printer_id=printer.id,
  502. archive_id=archive.id,
  503. cost_center_id=cost_center.id,
  504. estimated_cost=8.0,
  505. status="pending",
  506. )
  507. blocked = await async_client.post(
  508. "/api/v1/queue/",
  509. json={
  510. "printer_id": printer.id,
  511. "archive_id": archive.id,
  512. "cost_center_id": cost_center.id,
  513. "estimated_cost": 3.0,
  514. },
  515. )
  516. assert blocked.status_code == 400
  517. deleted = await async_client.delete(f"/api/v1/queue/{item.id}")
  518. assert deleted.status_code == 200
  519. allowed = await async_client.post(
  520. "/api/v1/queue/",
  521. json={
  522. "printer_id": printer.id,
  523. "archive_id": archive.id,
  524. "cost_center_id": cost_center.id,
  525. "estimated_cost": 3.0,
  526. },
  527. )
  528. assert allowed.status_code == 200
  529. @pytest.mark.asyncio
  530. @pytest.mark.integration
  531. async def test_delete_queue_item_not_found(self, async_client: AsyncClient):
  532. """Verify 404 for deleting non-existent queue item."""
  533. response = await async_client.delete("/api/v1/queue/9999")
  534. assert response.status_code == 404
  535. class TestQueueStartEndpoint:
  536. """Tests for the /queue/{item_id}/start endpoint."""
  537. @pytest.fixture
  538. async def printer_factory(self, db_session):
  539. """Factory to create test printers."""
  540. _counter = [0]
  541. async def _create_printer(**kwargs):
  542. from backend.app.models.printer import Printer
  543. _counter[0] += 1
  544. counter = _counter[0]
  545. defaults = {
  546. "name": f"Test Printer {counter}",
  547. "ip_address": f"192.168.1.{100 + counter}",
  548. "serial_number": f"TESTSERIAL{counter:04d}",
  549. "access_code": "12345678",
  550. "model": "X1C",
  551. }
  552. defaults.update(kwargs)
  553. printer = Printer(**defaults)
  554. db_session.add(printer)
  555. await db_session.commit()
  556. await db_session.refresh(printer)
  557. return printer
  558. return _create_printer
  559. @pytest.fixture
  560. async def archive_factory(self, db_session):
  561. """Factory to create test archives."""
  562. _counter = [0]
  563. async def _create_archive(**kwargs):
  564. from backend.app.models.archive import PrintArchive
  565. _counter[0] += 1
  566. counter = _counter[0]
  567. defaults = {
  568. "filename": f"test_print_{counter}.3mf",
  569. "print_name": f"Test Print {counter}",
  570. "file_path": f"/tmp/test_print_{counter}.3mf",
  571. "file_size": 1024,
  572. "content_hash": f"testhash{counter:08d}",
  573. "status": "completed",
  574. }
  575. defaults.update(kwargs)
  576. archive = PrintArchive(**defaults)
  577. db_session.add(archive)
  578. await db_session.commit()
  579. await db_session.refresh(archive)
  580. return archive
  581. return _create_archive
  582. @pytest.fixture
  583. async def queue_item_factory(self, db_session, printer_factory, archive_factory):
  584. """Factory to create test queue items."""
  585. _counter = [0]
  586. async def _create_queue_item(**kwargs):
  587. from backend.app.models.print_queue import PrintQueueItem
  588. _counter[0] += 1
  589. counter = _counter[0]
  590. if "printer_id" not in kwargs:
  591. printer = await printer_factory()
  592. kwargs["printer_id"] = printer.id
  593. if "archive_id" not in kwargs:
  594. archive = await archive_factory()
  595. kwargs["archive_id"] = archive.id
  596. defaults = {
  597. "status": "pending",
  598. "position": counter,
  599. }
  600. defaults.update(kwargs)
  601. item = PrintQueueItem(**defaults)
  602. db_session.add(item)
  603. await db_session.commit()
  604. await db_session.refresh(item)
  605. return item
  606. return _create_queue_item
  607. @pytest.mark.asyncio
  608. @pytest.mark.integration
  609. async def test_start_staged_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  610. """Verify starting a staged (manual_start=True) queue item clears the flag."""
  611. item = await queue_item_factory(manual_start=True)
  612. assert item.manual_start is True
  613. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  614. assert response.status_code == 200
  615. result = response.json()
  616. assert result["manual_start"] is False
  617. assert result["status"] == "pending"
  618. @pytest.mark.asyncio
  619. @pytest.mark.integration
  620. async def test_start_non_staged_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  621. """Verify starting a non-staged queue item still works (idempotent)."""
  622. item = await queue_item_factory(manual_start=False)
  623. assert item.manual_start is False
  624. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  625. assert response.status_code == 200
  626. result = response.json()
  627. assert result["manual_start"] is False
  628. @pytest.mark.asyncio
  629. @pytest.mark.integration
  630. async def test_start_queue_item_with_cost_center_requires_estimated_cost(
  631. self, async_client: AsyncClient, queue_item_factory, db_session
  632. ):
  633. """Starting a cost-center queue item requires a stored estimate."""
  634. await enable_billing(db_session)
  635. cost_center = CostCenter(name="Start Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
  636. db_session.add(cost_center)
  637. await db_session.commit()
  638. await db_session.refresh(cost_center)
  639. item = await queue_item_factory(manual_start=True, cost_center_id=cost_center.id, estimated_cost=None)
  640. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  641. assert response.status_code == 400
  642. assert "Estimated cost is required" in response.json()["detail"]
  643. @pytest.mark.asyncio
  644. @pytest.mark.integration
  645. async def test_start_queue_item_not_found(self, async_client: AsyncClient):
  646. """Verify 404 for non-existent queue item."""
  647. response = await async_client.post("/api/v1/queue/9999/start")
  648. assert response.status_code == 404
  649. @pytest.mark.asyncio
  650. @pytest.mark.integration
  651. async def test_start_non_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  652. """Verify 400 error when trying to start a non-pending queue item."""
  653. item = await queue_item_factory(status="printing", manual_start=True)
  654. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  655. assert response.status_code == 400
  656. assert "pending" in response.json()["detail"].lower()
  657. @pytest.mark.asyncio
  658. @pytest.mark.integration
  659. async def test_start_completed_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  660. """Verify 400 error when trying to start a completed queue item."""
  661. item = await queue_item_factory(status="completed", manual_start=True)
  662. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  663. assert response.status_code == 400
  664. @pytest.mark.asyncio
  665. @pytest.mark.integration
  666. async def test_start_returns_409_on_filament_deficit(
  667. self,
  668. async_client: AsyncClient,
  669. queue_item_factory,
  670. db_session,
  671. monkeypatch,
  672. ):
  673. """Filament deficit must surface as 409 + structured payload (#1496)."""
  674. from backend.app.services import filament_deficit as fd_module
  675. item = await queue_item_factory(manual_start=True)
  676. async def _fake_deficit(_db, _item):
  677. return [
  678. fd_module.FilamentDeficit(
  679. slot_id=1,
  680. ams_id=0,
  681. tray_id=0,
  682. filament_type="PLA",
  683. required_grams=270.0,
  684. remaining_grams=200.0,
  685. ),
  686. ]
  687. monkeypatch.setattr(
  688. "backend.app.api.routes.print_queue.compute_deficit_for_queue_item",
  689. _fake_deficit,
  690. )
  691. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  692. assert response.status_code == 409
  693. body = response.json()
  694. assert body["detail"]["code"] == "insufficient_filament"
  695. assert len(body["detail"]["deficit"]) == 1
  696. assert body["detail"]["deficit"][0]["slot_id"] == 1
  697. assert body["detail"]["deficit"][0]["required_grams"] == 270.0
  698. assert body["detail"]["deficit"][0]["remaining_grams"] == 200.0
  699. # Item still pending, manual_start unchanged.
  700. await db_session.refresh(item)
  701. assert item.status == "pending"
  702. assert item.manual_start is True
  703. @pytest.mark.asyncio
  704. @pytest.mark.integration
  705. async def test_start_with_skip_flag_bypasses_deficit_check(
  706. self,
  707. async_client: AsyncClient,
  708. queue_item_factory,
  709. db_session,
  710. monkeypatch,
  711. ):
  712. """With skip_filament_check=true the route dispatches even when short (#1496)."""
  713. from backend.app.services import filament_deficit as fd_module
  714. item = await queue_item_factory(manual_start=True, filament_short=True)
  715. called_with = {}
  716. async def _fake_deficit(_db, _item):
  717. called_with["called"] = True
  718. return [
  719. fd_module.FilamentDeficit(
  720. slot_id=1,
  721. ams_id=0,
  722. tray_id=0,
  723. filament_type="PLA",
  724. required_grams=270.0,
  725. remaining_grams=200.0,
  726. ),
  727. ]
  728. monkeypatch.setattr(
  729. "backend.app.api.routes.print_queue.compute_deficit_for_queue_item",
  730. _fake_deficit,
  731. )
  732. response = await async_client.post(f"/api/v1/queue/{item.id}/start?skip_filament_check=true")
  733. assert response.status_code == 200
  734. body = response.json()
  735. assert body["manual_start"] is False
  736. assert body["filament_short"] is False
  737. # Helper not called on the bypass path — we trust the operator's
  738. # decision to print anyway.
  739. assert called_with == {}
  740. @pytest.mark.asyncio
  741. @pytest.mark.integration
  742. async def test_start_with_skip_flag_persists_acknowledgement(
  743. self,
  744. async_client: AsyncClient,
  745. queue_item_factory,
  746. db_session,
  747. ):
  748. """skip_filament_check=true sets the persistent flag on the queue item
  749. so the scheduler doesn't re-flag it on the next tick (#1698-followup).
  750. Without persistence the route's flag-clearing only survives until the
  751. next scheduler tick re-runs the deficit check on identical spool
  752. state and re-promotes the item — the user has to click Play+Confirm
  753. every single tick.
  754. """
  755. item = await queue_item_factory(manual_start=True, filament_short=True)
  756. assert item.skip_filament_check is False
  757. response = await async_client.post(f"/api/v1/queue/{item.id}/start?skip_filament_check=true")
  758. assert response.status_code == 200
  759. body = response.json()
  760. assert body["skip_filament_check"] is True
  761. await db_session.refresh(item)
  762. assert item.skip_filament_check is True
  763. @pytest.mark.asyncio
  764. @pytest.mark.integration
  765. async def test_start_without_skip_flag_does_not_set_acknowledgement(
  766. self,
  767. async_client: AsyncClient,
  768. queue_item_factory,
  769. db_session,
  770. ):
  771. """A successful Play click with no deficit must NOT silently set the
  772. acknowledgement flag — only an explicit Print Anyway should.
  773. """
  774. item = await queue_item_factory(manual_start=False, filament_short=False)
  775. assert item.skip_filament_check is False
  776. response = await async_client.post(f"/api/v1/queue/{item.id}/start")
  777. assert response.status_code == 200
  778. await db_session.refresh(item)
  779. assert item.skip_filament_check is False
  780. class TestQueueCancelEndpoint:
  781. """Tests for the /queue/{item_id}/cancel endpoint."""
  782. @pytest.fixture
  783. async def printer_factory(self, db_session):
  784. """Factory to create test printers."""
  785. async def _create_printer(**kwargs):
  786. from backend.app.models.printer import Printer
  787. defaults = {
  788. "name": "Cancel Test Printer",
  789. "ip_address": "192.168.1.200",
  790. "serial_number": "TESTCANCEL001",
  791. "access_code": "12345678",
  792. "model": "X1C",
  793. }
  794. defaults.update(kwargs)
  795. printer = Printer(**defaults)
  796. db_session.add(printer)
  797. await db_session.commit()
  798. await db_session.refresh(printer)
  799. return printer
  800. return _create_printer
  801. @pytest.fixture
  802. async def archive_factory(self, db_session):
  803. """Factory to create test archives."""
  804. async def _create_archive(**kwargs):
  805. from backend.app.models.archive import PrintArchive
  806. defaults = {
  807. "filename": "cancel_test.3mf",
  808. "print_name": "Cancel Test Print",
  809. "file_path": "/tmp/cancel_test.3mf",
  810. "file_size": 1024,
  811. "content_hash": "cancelhash001",
  812. "status": "completed",
  813. }
  814. defaults.update(kwargs)
  815. archive = PrintArchive(**defaults)
  816. db_session.add(archive)
  817. await db_session.commit()
  818. await db_session.refresh(archive)
  819. return archive
  820. return _create_archive
  821. @pytest.fixture
  822. async def queue_item_factory(self, db_session, printer_factory, archive_factory):
  823. """Factory to create test queue items."""
  824. async def _create_queue_item(**kwargs):
  825. from backend.app.models.print_queue import PrintQueueItem
  826. if "printer_id" not in kwargs:
  827. printer = await printer_factory()
  828. kwargs["printer_id"] = printer.id
  829. if "archive_id" not in kwargs:
  830. archive = await archive_factory()
  831. kwargs["archive_id"] = archive.id
  832. defaults = {
  833. "status": "pending",
  834. "position": 1,
  835. }
  836. defaults.update(kwargs)
  837. item = PrintQueueItem(**defaults)
  838. db_session.add(item)
  839. await db_session.commit()
  840. await db_session.refresh(item)
  841. return item
  842. return _create_queue_item
  843. @pytest.mark.asyncio
  844. @pytest.mark.integration
  845. async def test_cancel_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  846. """Verify cancelling a pending queue item."""
  847. item = await queue_item_factory(status="pending")
  848. response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
  849. assert response.status_code == 200
  850. assert response.json()["message"] == "Queue item cancelled"
  851. @pytest.mark.asyncio
  852. @pytest.mark.integration
  853. async def test_cancel_non_pending_queue_item(self, async_client: AsyncClient, queue_item_factory, db_session):
  854. """Verify 400 error when trying to cancel a non-pending queue item."""
  855. item = await queue_item_factory(status="printing")
  856. response = await async_client.post(f"/api/v1/queue/{item.id}/cancel")
  857. assert response.status_code == 400
  858. class TestQueueLibraryFileSupport:
  859. """Tests for queue items with library_file_id (instead of archive_id)."""
  860. @pytest.fixture
  861. async def printer_factory(self, db_session):
  862. """Factory to create test printers."""
  863. _counter = [0]
  864. async def _create_printer(**kwargs):
  865. from backend.app.models.printer import Printer
  866. _counter[0] += 1
  867. counter = _counter[0]
  868. defaults = {
  869. "name": f"Library Test Printer {counter}",
  870. "ip_address": f"192.168.1.{150 + counter}",
  871. "serial_number": f"TESTLIB{counter:04d}",
  872. "access_code": "12345678",
  873. "model": "X1C",
  874. }
  875. defaults.update(kwargs)
  876. printer = Printer(**defaults)
  877. db_session.add(printer)
  878. await db_session.commit()
  879. await db_session.refresh(printer)
  880. return printer
  881. return _create_printer
  882. @pytest.fixture
  883. async def library_file_factory(self, db_session):
  884. """Factory to create test library files."""
  885. _counter = [0]
  886. async def _create_library_file(**kwargs):
  887. from backend.app.models.library import LibraryFile
  888. _counter[0] += 1
  889. counter = _counter[0]
  890. defaults = {
  891. "filename": f"library_test_{counter}.3mf",
  892. "file_path": f"/test/library/library_test_{counter}.3mf",
  893. "file_size": 2048,
  894. "file_type": "3mf",
  895. "file_metadata": {"print_name": f"Library Print {counter}", "print_time_seconds": 3600},
  896. }
  897. defaults.update(kwargs)
  898. lib_file = LibraryFile(**defaults)
  899. db_session.add(lib_file)
  900. await db_session.commit()
  901. await db_session.refresh(lib_file)
  902. return lib_file
  903. return _create_library_file
  904. @pytest.mark.asyncio
  905. @pytest.mark.integration
  906. async def test_add_to_queue_with_library_file(
  907. self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
  908. ):
  909. """Verify item can be added to queue using library_file_id instead of archive_id."""
  910. printer = await printer_factory()
  911. lib_file = await library_file_factory()
  912. data = {
  913. "printer_id": printer.id,
  914. "library_file_id": lib_file.id,
  915. }
  916. response = await async_client.post("/api/v1/queue/", json=data)
  917. assert response.status_code == 200
  918. result = response.json()
  919. assert result["printer_id"] == printer.id
  920. assert result["library_file_id"] == lib_file.id
  921. assert result["archive_id"] is None
  922. assert result["status"] == "pending"
  923. assert result["library_file_name"] == "Library Print 1"
  924. assert result["print_time_seconds"] == 3600
  925. @pytest.mark.asyncio
  926. @pytest.mark.integration
  927. async def test_add_to_queue_library_file_with_options(
  928. self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
  929. ):
  930. """Verify library file queue item can have all options set."""
  931. printer = await printer_factory()
  932. lib_file = await library_file_factory()
  933. data = {
  934. "printer_id": printer.id,
  935. "library_file_id": lib_file.id,
  936. "ams_mapping": [1, 2, -1, -1],
  937. "plate_id": 2,
  938. "bed_levelling": False,
  939. "timelapse": True,
  940. "manual_start": True,
  941. }
  942. response = await async_client.post("/api/v1/queue/", json=data)
  943. assert response.status_code == 200
  944. result = response.json()
  945. assert result["library_file_id"] == lib_file.id
  946. assert result["ams_mapping"] == [1, 2, -1, -1]
  947. assert result["plate_id"] == 2
  948. assert result["bed_levelling"] is False
  949. assert result["timelapse"] is True
  950. assert result["manual_start"] is True
  951. @pytest.mark.asyncio
  952. @pytest.mark.integration
  953. async def test_add_to_queue_requires_archive_or_library_file(
  954. self, async_client: AsyncClient, printer_factory, db_session
  955. ):
  956. """Verify 400 error when neither archive_id nor library_file_id provided."""
  957. printer = await printer_factory()
  958. data = {
  959. "printer_id": printer.id,
  960. }
  961. response = await async_client.post("/api/v1/queue/", json=data)
  962. assert response.status_code == 400
  963. assert (
  964. "archive_id" in response.json()["detail"].lower() or "library_file_id" in response.json()["detail"].lower()
  965. )
  966. @pytest.mark.asyncio
  967. @pytest.mark.integration
  968. async def test_update_queue_item_with_library_file(
  969. self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
  970. ):
  971. """Verify queue item with library_file_id can be updated."""
  972. from backend.app.models.print_queue import PrintQueueItem
  973. printer = await printer_factory()
  974. lib_file = await library_file_factory()
  975. # Create queue item directly
  976. item = PrintQueueItem(
  977. printer_id=printer.id,
  978. library_file_id=lib_file.id,
  979. status="pending",
  980. position=1,
  981. )
  982. db_session.add(item)
  983. await db_session.commit()
  984. await db_session.refresh(item)
  985. # Update the item
  986. response = await async_client.patch(
  987. f"/api/v1/queue/{item.id}",
  988. json={"auto_off_after": True, "plate_id": 3},
  989. )
  990. assert response.status_code == 200
  991. result = response.json()
  992. assert result["auto_off_after"] is True
  993. assert result["plate_id"] == 3
  994. assert result["library_file_id"] == lib_file.id
  995. @pytest.mark.asyncio
  996. @pytest.mark.integration
  997. async def test_list_queue_includes_library_file_info(
  998. self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
  999. ):
  1000. """Verify queue list includes library file metadata."""
  1001. from backend.app.models.print_queue import PrintQueueItem
  1002. printer = await printer_factory()
  1003. lib_file = await library_file_factory(
  1004. file_metadata={"print_name": "Custom Print Name", "print_time_seconds": 7200}
  1005. )
  1006. item = PrintQueueItem(
  1007. printer_id=printer.id,
  1008. library_file_id=lib_file.id,
  1009. status="pending",
  1010. position=1,
  1011. )
  1012. db_session.add(item)
  1013. await db_session.commit()
  1014. response = await async_client.get("/api/v1/queue/")
  1015. assert response.status_code == 200
  1016. items = response.json()
  1017. assert len(items) >= 1
  1018. # Find our item
  1019. our_item = next((i for i in items if i["library_file_id"] == lib_file.id), None)
  1020. assert our_item is not None
  1021. assert our_item["library_file_name"] == "Custom Print Name"
  1022. assert our_item["print_time_seconds"] == 7200
  1023. class TestBulkUpdateEndpoint:
  1024. """Tests for the /queue/bulk endpoint."""
  1025. @pytest.fixture
  1026. async def printer_factory(self, db_session):
  1027. """Factory to create test printers."""
  1028. _counter = [0]
  1029. async def _create_printer(**kwargs):
  1030. from backend.app.models.printer import Printer
  1031. _counter[0] += 1
  1032. counter = _counter[0]
  1033. defaults = {
  1034. "name": f"Bulk Test Printer {counter}",
  1035. "ip_address": f"192.168.1.{150 + counter}",
  1036. "serial_number": f"TESTBULK{counter:04d}",
  1037. "access_code": "12345678",
  1038. "model": "X1C",
  1039. }
  1040. defaults.update(kwargs)
  1041. printer = Printer(**defaults)
  1042. db_session.add(printer)
  1043. await db_session.commit()
  1044. await db_session.refresh(printer)
  1045. return printer
  1046. return _create_printer
  1047. @pytest.fixture
  1048. async def archive_factory(self, db_session):
  1049. """Factory to create test archives."""
  1050. _counter = [0]
  1051. async def _create_archive(**kwargs):
  1052. from backend.app.models.archive import PrintArchive
  1053. _counter[0] += 1
  1054. counter = _counter[0]
  1055. defaults = {
  1056. "filename": f"bulk_test_{counter}.3mf",
  1057. "print_name": f"Bulk Test Print {counter}",
  1058. "file_path": f"/tmp/bulk_test_{counter}.3mf",
  1059. "file_size": 1024,
  1060. "content_hash": f"bulkhash{counter:04d}",
  1061. "status": "completed",
  1062. }
  1063. defaults.update(kwargs)
  1064. archive = PrintArchive(**defaults)
  1065. db_session.add(archive)
  1066. await db_session.commit()
  1067. await db_session.refresh(archive)
  1068. return archive
  1069. return _create_archive
  1070. @pytest.fixture
  1071. async def queue_item_factory(self, db_session, printer_factory, archive_factory):
  1072. """Factory to create test queue items."""
  1073. async def _create_item(**kwargs):
  1074. from backend.app.models.print_queue import PrintQueueItem
  1075. if "printer_id" not in kwargs:
  1076. printer = await printer_factory()
  1077. kwargs["printer_id"] = printer.id
  1078. if "archive_id" not in kwargs:
  1079. archive = await archive_factory()
  1080. kwargs["archive_id"] = archive.id
  1081. defaults = {
  1082. "status": "pending",
  1083. "position": 1,
  1084. "bed_levelling": True,
  1085. "flow_cali": False,
  1086. "vibration_cali": True,
  1087. }
  1088. defaults.update(kwargs)
  1089. item = PrintQueueItem(**defaults)
  1090. db_session.add(item)
  1091. await db_session.commit()
  1092. await db_session.refresh(item)
  1093. return item
  1094. return _create_item
  1095. @pytest.mark.asyncio
  1096. @pytest.mark.integration
  1097. async def test_bulk_update_single_field(self, async_client: AsyncClient, queue_item_factory, db_session):
  1098. """Verify bulk update can change a single field on multiple items."""
  1099. item1 = await queue_item_factory(bed_levelling=True)
  1100. item2 = await queue_item_factory(bed_levelling=True)
  1101. response = await async_client.patch(
  1102. "/api/v1/queue/bulk",
  1103. json={"item_ids": [item1.id, item2.id], "bed_levelling": False},
  1104. )
  1105. assert response.status_code == 200
  1106. result = response.json()
  1107. assert result["updated_count"] == 2
  1108. assert result["skipped_count"] == 0
  1109. # Verify items were updated
  1110. await db_session.refresh(item1)
  1111. await db_session.refresh(item2)
  1112. assert item1.bed_levelling is False
  1113. assert item2.bed_levelling is False
  1114. @pytest.mark.asyncio
  1115. @pytest.mark.integration
  1116. async def test_bulk_update_multiple_fields(self, async_client: AsyncClient, queue_item_factory, db_session):
  1117. """Verify bulk update can change multiple fields at once."""
  1118. item1 = await queue_item_factory(bed_levelling=True, flow_cali=False, manual_start=False)
  1119. item2 = await queue_item_factory(bed_levelling=True, flow_cali=False, manual_start=False)
  1120. response = await async_client.patch(
  1121. "/api/v1/queue/bulk",
  1122. json={
  1123. "item_ids": [item1.id, item2.id],
  1124. "bed_levelling": False,
  1125. "flow_cali": True,
  1126. "manual_start": True,
  1127. },
  1128. )
  1129. assert response.status_code == 200
  1130. result = response.json()
  1131. assert result["updated_count"] == 2
  1132. await db_session.refresh(item1)
  1133. assert item1.bed_levelling is False
  1134. assert item1.flow_cali is True
  1135. assert item1.manual_start is True
  1136. @pytest.mark.asyncio
  1137. @pytest.mark.integration
  1138. async def test_bulk_update_skips_non_pending(self, async_client: AsyncClient, queue_item_factory, db_session):
  1139. """Verify bulk update skips non-pending items."""
  1140. pending_item = await queue_item_factory(status="pending", bed_levelling=True)
  1141. printing_item = await queue_item_factory(status="printing", bed_levelling=True)
  1142. completed_item = await queue_item_factory(status="completed", bed_levelling=True)
  1143. response = await async_client.patch(
  1144. "/api/v1/queue/bulk",
  1145. json={
  1146. "item_ids": [pending_item.id, printing_item.id, completed_item.id],
  1147. "bed_levelling": False,
  1148. },
  1149. )
  1150. assert response.status_code == 200
  1151. result = response.json()
  1152. assert result["updated_count"] == 1
  1153. assert result["skipped_count"] == 2
  1154. # Only pending item should be updated
  1155. await db_session.refresh(pending_item)
  1156. await db_session.refresh(printing_item)
  1157. await db_session.refresh(completed_item)
  1158. assert pending_item.bed_levelling is False
  1159. assert printing_item.bed_levelling is True
  1160. assert completed_item.bed_levelling is True
  1161. @pytest.mark.asyncio
  1162. @pytest.mark.integration
  1163. async def test_bulk_update_change_printer(
  1164. self, async_client: AsyncClient, queue_item_factory, printer_factory, db_session
  1165. ):
  1166. """Verify bulk update can reassign items to a different printer."""
  1167. new_printer = await printer_factory(name="New Target Printer")
  1168. item1 = await queue_item_factory()
  1169. item2 = await queue_item_factory()
  1170. original_printer_id = item1.printer_id
  1171. response = await async_client.patch(
  1172. "/api/v1/queue/bulk",
  1173. json={"item_ids": [item1.id, item2.id], "printer_id": new_printer.id},
  1174. )
  1175. assert response.status_code == 200
  1176. await db_session.refresh(item1)
  1177. await db_session.refresh(item2)
  1178. assert item1.printer_id == new_printer.id
  1179. assert item2.printer_id == new_printer.id
  1180. assert item1.printer_id != original_printer_id
  1181. @pytest.mark.asyncio
  1182. @pytest.mark.integration
  1183. async def test_bulk_update_empty_item_ids(self, async_client: AsyncClient):
  1184. """Verify 400 error when item_ids is empty."""
  1185. response = await async_client.patch(
  1186. "/api/v1/queue/bulk",
  1187. json={"item_ids": [], "bed_levelling": False},
  1188. )
  1189. assert response.status_code == 400
  1190. assert "no item" in response.json()["detail"].lower()
  1191. @pytest.mark.asyncio
  1192. @pytest.mark.integration
  1193. async def test_bulk_update_no_fields(self, async_client: AsyncClient, queue_item_factory):
  1194. """Verify 400 error when no fields to update."""
  1195. item = await queue_item_factory()
  1196. response = await async_client.patch(
  1197. "/api/v1/queue/bulk",
  1198. json={"item_ids": [item.id]},
  1199. )
  1200. assert response.status_code == 400
  1201. assert "no fields" in response.json()["detail"].lower()
  1202. @pytest.mark.asyncio
  1203. @pytest.mark.integration
  1204. async def test_bulk_update_invalid_printer(self, async_client: AsyncClient, queue_item_factory):
  1205. """Verify 400 error when printer_id doesn't exist."""
  1206. item = await queue_item_factory()
  1207. response = await async_client.patch(
  1208. "/api/v1/queue/bulk",
  1209. json={"item_ids": [item.id], "printer_id": 99999},
  1210. )
  1211. assert response.status_code == 400
  1212. assert "printer not found" in response.json()["detail"].lower()
  1213. @pytest.mark.asyncio
  1214. @pytest.mark.integration
  1215. async def test_bulk_update_cost_center_requires_estimated_cost(
  1216. self, async_client: AsyncClient, queue_item_factory, db_session
  1217. ):
  1218. """Bulk assigning a cost center requires an estimate, same as single-item updates."""
  1219. await enable_billing(db_session)
  1220. item = await queue_item_factory()
  1221. cost_center = CostCenter(name="Bulk Budget CC", is_active=True, is_private=False, monthly_budget=10.0)
  1222. db_session.add(cost_center)
  1223. await db_session.commit()
  1224. await db_session.refresh(cost_center)
  1225. response = await async_client.patch(
  1226. "/api/v1/queue/bulk",
  1227. json={"item_ids": [item.id], "cost_center_id": cost_center.id},
  1228. )
  1229. assert response.status_code == 400
  1230. assert "Estimated cost is required" in response.json()["detail"]
  1231. @pytest.mark.asyncio
  1232. @pytest.mark.integration
  1233. async def test_bulk_update_cost_center_counts_pending_reservations(
  1234. self, async_client: AsyncClient, queue_item_factory, db_session
  1235. ):
  1236. """Bulk updates cannot reserve more than the available cost-center budget."""
  1237. await enable_billing(db_session)
  1238. item = await queue_item_factory()
  1239. existing = await queue_item_factory()
  1240. cost_center = CostCenter(name="Bulk Reserved CC", is_active=True, is_private=False, monthly_budget=10.0)
  1241. db_session.add(cost_center)
  1242. await db_session.commit()
  1243. await db_session.refresh(cost_center)
  1244. existing.cost_center_id = cost_center.id
  1245. existing.estimated_cost = 8.0
  1246. await db_session.commit()
  1247. response = await async_client.patch(
  1248. "/api/v1/queue/bulk",
  1249. json={"item_ids": [item.id], "cost_center_id": cost_center.id, "estimated_cost": 3.0},
  1250. )
  1251. assert response.status_code == 400
  1252. assert "exceeds available cost center budget" in response.json()["detail"]
  1253. await db_session.refresh(item)
  1254. assert item.cost_center_id is None
  1255. assert item.estimated_cost is None
  1256. class TestTargetLocationFeature:
  1257. """Tests for queue items with target_location (Issue #220)."""
  1258. @pytest.fixture
  1259. async def printer_factory(self, db_session):
  1260. """Factory to create test printers."""
  1261. _counter = [0]
  1262. async def _create_printer(**kwargs):
  1263. from backend.app.models.printer import Printer
  1264. _counter[0] += 1
  1265. counter = _counter[0]
  1266. defaults = {
  1267. "name": f"Location Test Printer {counter}",
  1268. "ip_address": f"192.168.1.{50 + counter}",
  1269. "serial_number": f"TESTLOC{counter:04d}",
  1270. "access_code": "12345678",
  1271. "model": "X1C",
  1272. }
  1273. defaults.update(kwargs)
  1274. printer = Printer(**defaults)
  1275. db_session.add(printer)
  1276. await db_session.commit()
  1277. await db_session.refresh(printer)
  1278. return printer
  1279. return _create_printer
  1280. @pytest.fixture
  1281. async def archive_factory(self, db_session):
  1282. """Factory to create test archives."""
  1283. _counter = [0]
  1284. async def _create_archive(**kwargs):
  1285. from backend.app.models.archive import PrintArchive
  1286. _counter[0] += 1
  1287. counter = _counter[0]
  1288. defaults = {
  1289. "filename": f"location_test_{counter}.3mf",
  1290. "print_name": f"Location Test Print {counter}",
  1291. "file_path": f"/tmp/location_test_{counter}.3mf",
  1292. "file_size": 1024,
  1293. "content_hash": f"lochash{counter:08d}",
  1294. "status": "completed",
  1295. }
  1296. defaults.update(kwargs)
  1297. archive = PrintArchive(**defaults)
  1298. db_session.add(archive)
  1299. await db_session.commit()
  1300. await db_session.refresh(archive)
  1301. return archive
  1302. return _create_archive
  1303. @pytest.fixture
  1304. async def queue_item_factory(self, db_session, printer_factory, archive_factory):
  1305. """Factory to create test queue items."""
  1306. _counter = [0]
  1307. async def _create_queue_item(**kwargs):
  1308. from backend.app.models.print_queue import PrintQueueItem
  1309. _counter[0] += 1
  1310. counter = _counter[0]
  1311. if "printer_id" not in kwargs and "target_model" not in kwargs:
  1312. printer = await printer_factory()
  1313. kwargs["printer_id"] = printer.id
  1314. if "archive_id" not in kwargs:
  1315. archive = await archive_factory()
  1316. kwargs["archive_id"] = archive.id
  1317. defaults = {
  1318. "status": "pending",
  1319. "position": counter,
  1320. }
  1321. defaults.update(kwargs)
  1322. item = PrintQueueItem(**defaults)
  1323. db_session.add(item)
  1324. await db_session.commit()
  1325. await db_session.refresh(item)
  1326. return item
  1327. return _create_queue_item
  1328. @pytest.mark.asyncio
  1329. @pytest.mark.integration
  1330. async def test_add_to_queue_with_target_location(
  1331. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  1332. ):
  1333. """Verify item can be added with target_model and target_location."""
  1334. # Create a printer with model X1C so the API can validate
  1335. await printer_factory(model="X1C", location="Office")
  1336. archive = await archive_factory()
  1337. data = {
  1338. "target_model": "X1C",
  1339. "target_location": "Workbench",
  1340. "archive_id": archive.id,
  1341. }
  1342. response = await async_client.post("/api/v1/queue/", json=data)
  1343. assert response.status_code == 200
  1344. result = response.json()
  1345. assert result["target_model"] == "X1C"
  1346. assert result["target_location"] == "Workbench"
  1347. assert result["printer_id"] is None
  1348. @pytest.mark.asyncio
  1349. @pytest.mark.integration
  1350. async def test_add_to_queue_location_without_model_ignored(
  1351. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  1352. ):
  1353. """Verify target_location without target_model is allowed (location is just ignored)."""
  1354. printer = await printer_factory()
  1355. archive = await archive_factory()
  1356. data = {
  1357. "printer_id": printer.id,
  1358. "target_location": "Workbench", # This gets ignored since printer_id is set
  1359. "archive_id": archive.id,
  1360. }
  1361. response = await async_client.post("/api/v1/queue/", json=data)
  1362. # The API accepts this but the location is only used with target_model
  1363. assert response.status_code == 200
  1364. result = response.json()
  1365. assert result["printer_id"] == printer.id
  1366. # Location may or may not be stored since it's meaningless without target_model
  1367. # The important thing is the request succeeds
  1368. @pytest.mark.asyncio
  1369. @pytest.mark.integration
  1370. async def test_queue_item_target_location_in_response(
  1371. self, async_client: AsyncClient, queue_item_factory, db_session
  1372. ):
  1373. """Verify target_location is returned in queue item response."""
  1374. item = await queue_item_factory(
  1375. printer_id=None,
  1376. target_model="X1C",
  1377. target_location="Workshop",
  1378. )
  1379. response = await async_client.get(f"/api/v1/queue/{item.id}")
  1380. assert response.status_code == 200
  1381. result = response.json()
  1382. assert result["target_model"] == "X1C"
  1383. assert result["target_location"] == "Workshop"
  1384. @pytest.mark.asyncio
  1385. @pytest.mark.integration
  1386. async def test_queue_list_includes_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
  1387. """Verify target_location is included in queue list."""
  1388. await queue_item_factory(
  1389. printer_id=None,
  1390. target_model="P1S",
  1391. target_location="Garage",
  1392. )
  1393. response = await async_client.get("/api/v1/queue/")
  1394. assert response.status_code == 200
  1395. items = response.json()
  1396. assert len(items) >= 1
  1397. # Find our item
  1398. our_item = next((i for i in items if i["target_location"] == "Garage"), None)
  1399. assert our_item is not None
  1400. assert our_item["target_model"] == "P1S"
  1401. @pytest.mark.asyncio
  1402. @pytest.mark.integration
  1403. async def test_update_queue_item_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
  1404. """Verify target_location can be updated on existing queue item."""
  1405. item = await queue_item_factory(
  1406. printer_id=None,
  1407. target_model="X1C",
  1408. target_location="Office",
  1409. )
  1410. response = await async_client.patch(
  1411. f"/api/v1/queue/{item.id}",
  1412. json={"target_location": "Basement"},
  1413. )
  1414. assert response.status_code == 200
  1415. result = response.json()
  1416. assert result["target_location"] == "Basement"
  1417. @pytest.mark.asyncio
  1418. @pytest.mark.integration
  1419. async def test_clear_target_location(self, async_client: AsyncClient, queue_item_factory, db_session):
  1420. """Verify target_location can be cleared (set to None)."""
  1421. item = await queue_item_factory(
  1422. printer_id=None,
  1423. target_model="X1C",
  1424. target_location="Office",
  1425. )
  1426. # Note: Setting to empty string should clear it
  1427. response = await async_client.patch(
  1428. f"/api/v1/queue/{item.id}",
  1429. json={"target_location": None},
  1430. )
  1431. assert response.status_code == 200
  1432. result = response.json()
  1433. assert result["target_location"] is None
  1434. class TestAbortedStatusNormalisation:
  1435. """Tests for issue #558: 'aborted' queue status causes 500 error."""
  1436. @pytest.fixture
  1437. async def printer_factory(self, db_session):
  1438. """Factory to create test printers."""
  1439. _counter = [0]
  1440. async def _create_printer(**kwargs):
  1441. from backend.app.models.printer import Printer
  1442. _counter[0] += 1
  1443. counter = _counter[0]
  1444. defaults = {
  1445. "name": f"Abort Test Printer {counter}",
  1446. "ip_address": f"192.168.1.{60 + counter}",
  1447. "serial_number": f"TESTABORT{counter:04d}",
  1448. "access_code": "12345678",
  1449. "model": "P1S",
  1450. }
  1451. defaults.update(kwargs)
  1452. printer = Printer(**defaults)
  1453. db_session.add(printer)
  1454. await db_session.commit()
  1455. await db_session.refresh(printer)
  1456. return printer
  1457. return _create_printer
  1458. @pytest.fixture
  1459. async def archive_factory(self, db_session):
  1460. """Factory to create test archives."""
  1461. _counter = [0]
  1462. async def _create_archive(**kwargs):
  1463. from backend.app.models.archive import PrintArchive
  1464. _counter[0] += 1
  1465. counter = _counter[0]
  1466. defaults = {
  1467. "filename": f"abort_test_{counter}.3mf",
  1468. "print_name": f"Abort Test Print {counter}",
  1469. "file_path": f"/tmp/abort_test_{counter}.3mf",
  1470. "file_size": 1024,
  1471. "content_hash": f"aborthash{counter:06d}",
  1472. "status": "completed",
  1473. }
  1474. defaults.update(kwargs)
  1475. archive = PrintArchive(**defaults)
  1476. db_session.add(archive)
  1477. await db_session.commit()
  1478. await db_session.refresh(archive)
  1479. return archive
  1480. return _create_archive
  1481. @pytest.fixture
  1482. async def queue_item_factory(self, db_session, printer_factory, archive_factory):
  1483. """Factory to create test queue items."""
  1484. _counter = [0]
  1485. async def _create_queue_item(**kwargs):
  1486. from backend.app.models.print_queue import PrintQueueItem
  1487. _counter[0] += 1
  1488. counter = _counter[0]
  1489. if "printer_id" not in kwargs:
  1490. printer = await printer_factory()
  1491. kwargs["printer_id"] = printer.id
  1492. if "archive_id" not in kwargs:
  1493. archive = await archive_factory()
  1494. kwargs["archive_id"] = archive.id
  1495. defaults = {
  1496. "status": "pending",
  1497. "position": counter,
  1498. }
  1499. defaults.update(kwargs)
  1500. item = PrintQueueItem(**defaults)
  1501. db_session.add(item)
  1502. await db_session.commit()
  1503. await db_session.refresh(item)
  1504. return item
  1505. return _create_queue_item
  1506. @pytest.mark.asyncio
  1507. @pytest.mark.integration
  1508. async def test_on_print_complete_normalises_aborted_to_cancelled(self, queue_item_factory, db_session):
  1509. """Verify the completion handler maps 'aborted' → 'cancelled' for queue items."""
  1510. import asyncio
  1511. from unittest.mock import AsyncMock, MagicMock, patch
  1512. item = await queue_item_factory(status="printing")
  1513. # Build a mock session whose execute returns our item
  1514. mock_result = MagicMock()
  1515. mock_result.scalars.return_value.all.return_value = [item]
  1516. mock_session = AsyncMock()
  1517. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  1518. mock_session.__aexit__ = AsyncMock(return_value=False)
  1519. mock_session.execute = AsyncMock(return_value=mock_result)
  1520. mock_session.commit = AsyncMock()
  1521. tasks_before = set(asyncio.all_tasks())
  1522. with (
  1523. patch("backend.app.main.async_session", return_value=mock_session),
  1524. patch("backend.app.core.database.async_session", return_value=mock_session),
  1525. patch("backend.app.main.ws_manager") as mock_ws,
  1526. patch("backend.app.main.mqtt_relay") as mock_relay,
  1527. patch("backend.app.main.notification_service") as mock_notif,
  1528. patch("backend.app.main.smart_plug_manager") as mock_plug,
  1529. patch("backend.app.main.printer_manager") as mock_pm,
  1530. ):
  1531. mock_ws.send_print_complete = AsyncMock()
  1532. mock_ws.broadcast = AsyncMock()
  1533. mock_relay.on_print_complete = AsyncMock()
  1534. mock_relay.on_queue_job_completed = AsyncMock()
  1535. mock_notif.on_print_complete = AsyncMock()
  1536. mock_plug.on_print_complete = AsyncMock()
  1537. mock_pm.get_printer.return_value = None
  1538. from backend.app.main import on_print_complete
  1539. await on_print_complete(
  1540. item.printer_id,
  1541. {
  1542. "status": "aborted",
  1543. "filename": "test.gcode",
  1544. "subtask_name": "Test",
  1545. "timelapse_was_active": False,
  1546. },
  1547. )
  1548. # Cancel background tasks before leaving mock context
  1549. for task in asyncio.all_tasks() - tasks_before:
  1550. task.cancel()
  1551. try:
  1552. await task
  1553. except (asyncio.CancelledError, Exception):
  1554. pass
  1555. # The item status should be normalised to 'cancelled', not 'aborted'
  1556. assert item.status == "cancelled"
  1557. @pytest.mark.asyncio
  1558. @pytest.mark.integration
  1559. async def test_startup_fixup_converts_aborted_to_cancelled(self, queue_item_factory, db_session):
  1560. """Verify the startup fixup converts existing 'aborted' rows to 'cancelled'."""
  1561. from sqlalchemy import select
  1562. from backend.app.models.print_queue import PrintQueueItem
  1563. # Create items with various statuses including 'aborted'
  1564. item_aborted = await queue_item_factory(status="pending")
  1565. item_pending = await queue_item_factory(status="pending")
  1566. # Manually set the invalid status
  1567. item_aborted.status = "aborted"
  1568. db_session.add(item_aborted)
  1569. await db_session.commit()
  1570. # Run the fixup query (same logic as lifespan)
  1571. result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.status == "aborted"))
  1572. aborted_items = result.scalars().all()
  1573. for i in aborted_items:
  1574. i.status = "cancelled"
  1575. await db_session.commit()
  1576. # Verify: no more 'aborted' items
  1577. result = await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.status == "aborted"))
  1578. assert len(result.scalars().all()) == 0
  1579. # The previously aborted item should now be 'cancelled'
  1580. await db_session.refresh(item_aborted)
  1581. assert item_aborted.status == "cancelled"
  1582. # The pending item should be unchanged
  1583. await db_session.refresh(item_pending)
  1584. assert item_pending.status == "pending"
  1585. @pytest.mark.asyncio
  1586. @pytest.mark.integration
  1587. async def test_completed_status_passes_through_unchanged(self, queue_item_factory, db_session):
  1588. """Verify normal statuses like 'completed' are not affected by normalisation."""
  1589. import asyncio
  1590. from unittest.mock import AsyncMock, MagicMock, patch
  1591. item = await queue_item_factory(status="printing")
  1592. mock_result = MagicMock()
  1593. mock_result.scalars.return_value.all.return_value = [item]
  1594. mock_session = AsyncMock()
  1595. mock_session.__aenter__ = AsyncMock(return_value=mock_session)
  1596. mock_session.__aexit__ = AsyncMock(return_value=False)
  1597. mock_session.execute = AsyncMock(return_value=mock_result)
  1598. mock_session.commit = AsyncMock()
  1599. tasks_before = set(asyncio.all_tasks())
  1600. with (
  1601. patch("backend.app.main.async_session", return_value=mock_session),
  1602. patch("backend.app.core.database.async_session", return_value=mock_session),
  1603. patch("backend.app.main.ws_manager") as mock_ws,
  1604. patch("backend.app.main.mqtt_relay") as mock_relay,
  1605. patch("backend.app.main.notification_service") as mock_notif,
  1606. patch("backend.app.main.smart_plug_manager") as mock_plug,
  1607. patch("backend.app.main.printer_manager") as mock_pm,
  1608. ):
  1609. mock_ws.send_print_complete = AsyncMock()
  1610. mock_ws.broadcast = AsyncMock()
  1611. mock_relay.on_print_complete = AsyncMock()
  1612. mock_relay.on_queue_job_completed = AsyncMock()
  1613. mock_notif.on_print_complete = AsyncMock()
  1614. mock_plug.on_print_complete = AsyncMock()
  1615. mock_pm.get_printer.return_value = None
  1616. from backend.app.main import on_print_complete
  1617. await on_print_complete(
  1618. item.printer_id,
  1619. {
  1620. "status": "completed",
  1621. "filename": "test.gcode",
  1622. "subtask_name": "Test",
  1623. "timelapse_was_active": False,
  1624. },
  1625. )
  1626. # Cancel background tasks before leaving mock context
  1627. for task in asyncio.all_tasks() - tasks_before:
  1628. task.cancel()
  1629. try:
  1630. await task
  1631. except (asyncio.CancelledError, Exception):
  1632. pass
  1633. assert item.status == "completed"
  1634. # ========================================================================
  1635. # Library file usage tracking on print completion (#1008)
  1636. #
  1637. # These exercise the _bump_library_file_usage_if_completed helper directly
  1638. # rather than invoking the whole on_print_complete handler — that path
  1639. # spawns background asyncio tasks (notifications, MQTT relay, smart-plug)
  1640. # that are expensive to mock and have nothing to do with the bump logic.
  1641. # ========================================================================
  1642. @pytest.mark.asyncio
  1643. @pytest.mark.integration
  1644. async def test_bump_library_file_usage_on_completed(self, printer_factory, db_session):
  1645. """Successful completion increments print_count and stamps last_printed_at."""
  1646. from datetime import datetime, timezone
  1647. from backend.app.main import _bump_library_file_usage_if_completed
  1648. from backend.app.models.library import LibraryFile
  1649. from backend.app.models.print_queue import PrintQueueItem
  1650. printer = await printer_factory()
  1651. lib_file = LibraryFile(
  1652. filename="benchy.gcode.3mf",
  1653. file_path="/data/library/benchy.gcode.3mf",
  1654. file_type="gcode.3mf",
  1655. file_size=1024,
  1656. print_count=0,
  1657. last_printed_at=None,
  1658. )
  1659. db_session.add(lib_file)
  1660. await db_session.commit()
  1661. await db_session.refresh(lib_file)
  1662. item = PrintQueueItem(
  1663. printer_id=printer.id,
  1664. library_file_id=lib_file.id,
  1665. status="printing",
  1666. position=1,
  1667. )
  1668. before = datetime.now(timezone.utc).replace(tzinfo=None)
  1669. await _bump_library_file_usage_if_completed(db_session, item, "completed")
  1670. await db_session.commit()
  1671. await db_session.refresh(lib_file)
  1672. assert lib_file.print_count == 1
  1673. assert lib_file.last_printed_at is not None
  1674. assert lib_file.last_printed_at >= before
  1675. @pytest.mark.asyncio
  1676. @pytest.mark.integration
  1677. async def test_bump_library_file_usage_repeated_prints_increment_count(self, printer_factory, db_session):
  1678. """Each successful completion bumps print_count cumulatively."""
  1679. from backend.app.main import _bump_library_file_usage_if_completed
  1680. from backend.app.models.library import LibraryFile
  1681. from backend.app.models.print_queue import PrintQueueItem
  1682. printer = await printer_factory()
  1683. lib_file = LibraryFile(
  1684. filename="repeat.gcode.3mf",
  1685. file_path="/data/library/repeat.gcode.3mf",
  1686. file_type="gcode.3mf",
  1687. file_size=1024,
  1688. print_count=0,
  1689. )
  1690. db_session.add(lib_file)
  1691. await db_session.commit()
  1692. await db_session.refresh(lib_file)
  1693. item = PrintQueueItem(
  1694. printer_id=printer.id,
  1695. library_file_id=lib_file.id,
  1696. status="printing",
  1697. position=1,
  1698. )
  1699. for _ in range(3):
  1700. await _bump_library_file_usage_if_completed(db_session, item, "completed")
  1701. await db_session.commit()
  1702. await db_session.refresh(lib_file)
  1703. assert lib_file.print_count == 3
  1704. @pytest.mark.asyncio
  1705. @pytest.mark.integration
  1706. @pytest.mark.parametrize("terminal_status", ["failed", "cancelled"])
  1707. async def test_bump_library_file_usage_skips_non_completed(self, printer_factory, db_session, terminal_status):
  1708. """Failed and cancelled prints must NOT count as usage."""
  1709. from backend.app.main import _bump_library_file_usage_if_completed
  1710. from backend.app.models.library import LibraryFile
  1711. from backend.app.models.print_queue import PrintQueueItem
  1712. printer = await printer_factory()
  1713. lib_file = LibraryFile(
  1714. filename="broken.gcode.3mf",
  1715. file_path="/data/library/broken.gcode.3mf",
  1716. file_type="gcode.3mf",
  1717. file_size=1024,
  1718. print_count=0,
  1719. last_printed_at=None,
  1720. )
  1721. db_session.add(lib_file)
  1722. await db_session.commit()
  1723. await db_session.refresh(lib_file)
  1724. item = PrintQueueItem(
  1725. printer_id=printer.id,
  1726. library_file_id=lib_file.id,
  1727. status="printing",
  1728. position=1,
  1729. )
  1730. await _bump_library_file_usage_if_completed(db_session, item, terminal_status)
  1731. await db_session.commit()
  1732. await db_session.refresh(lib_file)
  1733. assert lib_file.print_count == 0
  1734. assert lib_file.last_printed_at is None
  1735. @pytest.mark.asyncio
  1736. @pytest.mark.integration
  1737. async def test_bump_library_file_usage_skips_when_no_library_file_id(
  1738. self, printer_factory, archive_factory, db_session
  1739. ):
  1740. """Queue items without library_file_id (e.g. archive reprints) are a no-op."""
  1741. from backend.app.main import _bump_library_file_usage_if_completed
  1742. from backend.app.models.print_queue import PrintQueueItem
  1743. printer = await printer_factory()
  1744. archive = await archive_factory()
  1745. item = PrintQueueItem(
  1746. printer_id=printer.id,
  1747. library_file_id=None,
  1748. archive_id=archive.id,
  1749. status="printing",
  1750. position=1,
  1751. )
  1752. # Must not raise.
  1753. await _bump_library_file_usage_if_completed(db_session, item, "completed")
  1754. # ========================================================================
  1755. # Batch quantity tests
  1756. # ========================================================================
  1757. @pytest.mark.asyncio
  1758. @pytest.mark.integration
  1759. async def test_add_to_queue_quantity_default(
  1760. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  1761. ):
  1762. """Verify quantity=1 (default) creates a single item with no batch."""
  1763. printer = await printer_factory()
  1764. archive = await archive_factory()
  1765. data = {
  1766. "printer_id": printer.id,
  1767. "archive_id": archive.id,
  1768. }
  1769. response = await async_client.post("/api/v1/queue/", json=data)
  1770. assert response.status_code == 200
  1771. result = response.json()
  1772. assert result["batch_id"] is None
  1773. assert result["batch_name"] is None
  1774. @pytest.mark.asyncio
  1775. @pytest.mark.integration
  1776. async def test_add_to_queue_quantity_one_explicit(
  1777. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  1778. ):
  1779. """Verify quantity=1 explicitly creates a single item with no batch."""
  1780. printer = await printer_factory()
  1781. archive = await archive_factory()
  1782. data = {
  1783. "printer_id": printer.id,
  1784. "archive_id": archive.id,
  1785. "quantity": 1,
  1786. }
  1787. response = await async_client.post("/api/v1/queue/", json=data)
  1788. assert response.status_code == 200
  1789. result = response.json()
  1790. assert result["batch_id"] is None
  1791. assert result["batch_name"] is None
  1792. @pytest.mark.asyncio
  1793. @pytest.mark.integration
  1794. async def test_add_to_queue_quantity_creates_batch(
  1795. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  1796. ):
  1797. """Verify quantity > 1 creates a batch and multiple queue items."""
  1798. printer = await printer_factory()
  1799. archive = await archive_factory()
  1800. data = {
  1801. "printer_id": printer.id,
  1802. "archive_id": archive.id,
  1803. "quantity": 3,
  1804. }
  1805. response = await async_client.post("/api/v1/queue/", json=data)
  1806. assert response.status_code == 200
  1807. result = response.json()
  1808. # First item is returned, linked to a batch
  1809. assert result["batch_id"] is not None
  1810. assert result["batch_name"] is not None
  1811. assert "×3" in result["batch_name"]
  1812. # Verify all 3 items were created
  1813. list_response = await async_client.get("/api/v1/queue/")
  1814. items = list_response.json()
  1815. batch_items = [i for i in items if i["batch_id"] == result["batch_id"]]
  1816. assert len(batch_items) == 3
  1817. # All items should have the same settings
  1818. for item in batch_items:
  1819. assert item["printer_id"] == printer.id
  1820. assert item["archive_id"] == archive.id
  1821. assert item["status"] == "pending"
  1822. @pytest.mark.asyncio
  1823. @pytest.mark.integration
  1824. async def test_add_to_queue_quantity_sequential_positions(
  1825. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  1826. ):
  1827. """Verify batch items get sequential positions."""
  1828. printer = await printer_factory()
  1829. archive = await archive_factory()
  1830. data = {
  1831. "printer_id": printer.id,
  1832. "archive_id": archive.id,
  1833. "quantity": 3,
  1834. }
  1835. response = await async_client.post("/api/v1/queue/", json=data)
  1836. assert response.status_code == 200
  1837. batch_id = response.json()["batch_id"]
  1838. list_response = await async_client.get("/api/v1/queue/")
  1839. items = list_response.json()
  1840. batch_items = sorted(
  1841. [i for i in items if i["batch_id"] == batch_id],
  1842. key=lambda i: i["position"],
  1843. )
  1844. positions = [i["position"] for i in batch_items]
  1845. assert positions == [positions[0], positions[0] + 1, positions[0] + 2]
  1846. @pytest.mark.asyncio
  1847. @pytest.mark.integration
  1848. async def test_add_to_queue_quantity_with_print_options(
  1849. self, async_client: AsyncClient, printer_factory, archive_factory, db_session
  1850. ):
  1851. """Verify print options are applied to all batch items."""
  1852. printer = await printer_factory()
  1853. archive = await archive_factory()
  1854. data = {
  1855. "printer_id": printer.id,
  1856. "archive_id": archive.id,
  1857. "quantity": 2,
  1858. "bed_levelling": False,
  1859. "timelapse": True,
  1860. }
  1861. response = await async_client.post("/api/v1/queue/", json=data)
  1862. assert response.status_code == 200
  1863. batch_id = response.json()["batch_id"]
  1864. list_response = await async_client.get("/api/v1/queue/")
  1865. batch_items = [i for i in list_response.json() if i["batch_id"] == batch_id]
  1866. assert len(batch_items) == 2
  1867. for item in batch_items:
  1868. assert item["bed_levelling"] is False
  1869. assert item["timelapse"] is True
  1870. @pytest.mark.asyncio
  1871. @pytest.mark.integration
  1872. async def test_get_batch(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
  1873. """Verify batch can be retrieved with progress stats."""
  1874. printer = await printer_factory()
  1875. archive = await archive_factory()
  1876. # Create a batch of 3
  1877. data = {
  1878. "printer_id": printer.id,
  1879. "archive_id": archive.id,
  1880. "quantity": 3,
  1881. }
  1882. response = await async_client.post("/api/v1/queue/", json=data)
  1883. batch_id = response.json()["batch_id"]
  1884. # Get batch
  1885. response = await async_client.get(f"/api/v1/queue/batches/{batch_id}")
  1886. assert response.status_code == 200
  1887. result = response.json()
  1888. assert result["id"] == batch_id
  1889. assert result["quantity"] == 3
  1890. assert result["status"] == "active"
  1891. assert result["pending_count"] == 3
  1892. assert result["printing_count"] == 0
  1893. assert result["completed_count"] == 0
  1894. @pytest.mark.asyncio
  1895. @pytest.mark.integration
  1896. async def test_list_batches(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
  1897. """Verify batches can be listed."""
  1898. printer = await printer_factory()
  1899. archive = await archive_factory()
  1900. # Create two batches
  1901. for qty in [2, 3]:
  1902. await async_client.post(
  1903. "/api/v1/queue/",
  1904. json={"printer_id": printer.id, "archive_id": archive.id, "quantity": qty},
  1905. )
  1906. response = await async_client.get("/api/v1/queue/batches")
  1907. assert response.status_code == 200
  1908. batches = response.json()
  1909. assert len(batches) >= 2
  1910. @pytest.mark.asyncio
  1911. @pytest.mark.integration
  1912. async def test_cancel_batch(self, async_client: AsyncClient, printer_factory, archive_factory, db_session):
  1913. """Verify cancelling a batch cancels all pending items."""
  1914. printer = await printer_factory()
  1915. archive = await archive_factory()
  1916. data = {
  1917. "printer_id": printer.id,
  1918. "archive_id": archive.id,
  1919. "quantity": 3,
  1920. }
  1921. response = await async_client.post("/api/v1/queue/", json=data)
  1922. batch_id = response.json()["batch_id"]
  1923. # Cancel the batch
  1924. response = await async_client.delete(f"/api/v1/queue/batches/{batch_id}")
  1925. assert response.status_code == 200
  1926. # Verify all items are cancelled
  1927. list_response = await async_client.get("/api/v1/queue/")
  1928. batch_items = [i for i in list_response.json() if i["batch_id"] == batch_id]
  1929. for item in batch_items:
  1930. assert item["status"] == "cancelled"
  1931. # Verify batch status
  1932. batch_response = await async_client.get(f"/api/v1/queue/batches/{batch_id}")
  1933. assert batch_response.json()["status"] == "cancelled"
  1934. @pytest.mark.asyncio
  1935. @pytest.mark.integration
  1936. async def test_get_batch_not_found(self, async_client: AsyncClient):
  1937. """Verify 404 for non-existent batch."""
  1938. response = await async_client.get("/api/v1/queue/batches/9999")
  1939. assert response.status_code == 404
  1940. # ========================================================================
  1941. # Queue redesign: create-empty + group-existing + ungroup
  1942. # ========================================================================
  1943. @pytest.mark.asyncio
  1944. @pytest.mark.integration
  1945. async def test_create_empty_batch_for_client_side_grouping(
  1946. self, async_client: AsyncClient, printer_factory, archive_factory
  1947. ):
  1948. """Verify POST /queue/batches without item_ids creates an empty batch
  1949. whose id can be passed on subsequent /queue/ POSTs (the multi-plate
  1950. auto-batch flow). Subsequent items must end up with the same batch_id."""
  1951. printer = await printer_factory()
  1952. archive = await archive_factory()
  1953. # 1. Pre-create batch
  1954. batch_resp = await async_client.post(
  1955. "/api/v1/queue/batches",
  1956. json={"name": "Plates · 2 plates", "archive_id": archive.id},
  1957. )
  1958. assert batch_resp.status_code == 200
  1959. batch = batch_resp.json()
  1960. assert batch["status"] == "active"
  1961. batch_id = batch["id"]
  1962. # 2. Add two items referencing that batch
  1963. for plate_id in (1, 2):
  1964. item_resp = await async_client.post(
  1965. "/api/v1/queue/",
  1966. json={
  1967. "printer_id": printer.id,
  1968. "archive_id": archive.id,
  1969. "plate_id": plate_id,
  1970. "batch_id": batch_id,
  1971. },
  1972. )
  1973. assert item_resp.status_code == 200
  1974. assert item_resp.json()["batch_id"] == batch_id
  1975. # 3. Verify batch now has 2 pending children
  1976. list_resp = await async_client.get("/api/v1/queue/")
  1977. siblings = [i for i in list_resp.json() if i["batch_id"] == batch_id]
  1978. assert len(siblings) == 2
  1979. assert {i["plate_id"] for i in siblings} == {1, 2}
  1980. @pytest.mark.asyncio
  1981. @pytest.mark.integration
  1982. async def test_group_existing_items_as_batch(
  1983. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory
  1984. ):
  1985. """Verify POST /queue/batches with item_ids assigns batch_id to
  1986. existing pending items (the 'Group as batch' UI action)."""
  1987. printer = await printer_factory()
  1988. archive = await archive_factory()
  1989. item_a = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
  1990. item_b = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
  1991. resp = await async_client.post(
  1992. "/api/v1/queue/batches",
  1993. json={"name": "Manual group", "item_ids": [item_a.id, item_b.id]},
  1994. )
  1995. assert resp.status_code == 200
  1996. batch_id = resp.json()["id"]
  1997. list_resp = await async_client.get("/api/v1/queue/")
  1998. grouped = [i for i in list_resp.json() if i["batch_id"] == batch_id]
  1999. assert {i["id"] for i in grouped} == {item_a.id, item_b.id}
  2000. @pytest.mark.asyncio
  2001. @pytest.mark.integration
  2002. async def test_group_skips_non_pending_items(
  2003. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory
  2004. ):
  2005. """Verify grouping doesn't pull in already-completed/cancelled items."""
  2006. printer = await printer_factory()
  2007. archive = await archive_factory()
  2008. pending = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
  2009. completed = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="completed")
  2010. resp = await async_client.post(
  2011. "/api/v1/queue/batches",
  2012. json={"name": "Mixed", "item_ids": [pending.id, completed.id]},
  2013. )
  2014. assert resp.status_code == 200
  2015. batch_id = resp.json()["id"]
  2016. list_resp = await async_client.get("/api/v1/queue/")
  2017. grouped = [i for i in list_resp.json() if i["batch_id"] == batch_id]
  2018. assert {i["id"] for i in grouped} == {pending.id}
  2019. @pytest.mark.asyncio
  2020. @pytest.mark.integration
  2021. async def test_create_batch_requires_name(self, async_client: AsyncClient):
  2022. """Verify empty / whitespace-only name is rejected with 400."""
  2023. resp = await async_client.post("/api/v1/queue/batches", json={"name": " "})
  2024. assert resp.status_code == 400
  2025. @pytest.mark.asyncio
  2026. @pytest.mark.integration
  2027. async def test_ungroup_batch_clears_batch_id_and_deletes_row(
  2028. self, async_client: AsyncClient, printer_factory, archive_factory
  2029. ):
  2030. """Verify POST /queue/batches/{id}/ungroup clears batch_id from all
  2031. members and deletes the batch row when nothing remains assigned."""
  2032. printer = await printer_factory()
  2033. archive = await archive_factory()
  2034. # Create batch with two items via the existing quantity flow
  2035. add_resp = await async_client.post(
  2036. "/api/v1/queue/",
  2037. json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 2},
  2038. )
  2039. batch_id = add_resp.json()["batch_id"]
  2040. # Ungroup
  2041. ungroup_resp = await async_client.post(f"/api/v1/queue/batches/{batch_id}/ungroup")
  2042. assert ungroup_resp.status_code == 200
  2043. assert ungroup_resp.json()["ungrouped_count"] == 2
  2044. # Verify items still exist but no longer batched
  2045. list_resp = await async_client.get("/api/v1/queue/")
  2046. ex_members = [i for i in list_resp.json() if i["batch_id"] == batch_id]
  2047. assert ex_members == []
  2048. # Batch row was deleted
  2049. get_resp = await async_client.get(f"/api/v1/queue/batches/{batch_id}")
  2050. assert get_resp.status_code == 404
  2051. @pytest.mark.asyncio
  2052. @pytest.mark.integration
  2053. async def test_add_to_queue_with_unknown_batch_id_404(
  2054. self, async_client: AsyncClient, printer_factory, archive_factory
  2055. ):
  2056. """Verify addToQueue with a non-existent batch_id is rejected."""
  2057. printer = await printer_factory()
  2058. archive = await archive_factory()
  2059. resp = await async_client.post(
  2060. "/api/v1/queue/",
  2061. json={
  2062. "printer_id": printer.id,
  2063. "archive_id": archive.id,
  2064. "batch_id": 99999,
  2065. },
  2066. )
  2067. assert resp.status_code == 404
  2068. # ========================================================================
  2069. # Soft-deleted archive handling (#1348 follow-up)
  2070. # ========================================================================
  2071. @pytest.mark.asyncio
  2072. @pytest.mark.integration
  2073. async def test_soft_delete_archive_deletes_all_related_queue_items(
  2074. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
  2075. ):
  2076. """Soft-deleting an archive removes every related queue item, regardless
  2077. of status (#1734). Pre-#1734 only ``pending`` rows were flipped to
  2078. ``cancelled`` and stayed in the DB, surprising users who expected the
  2079. queue lines to disappear with the archive — especially on multi-plate
  2080. Send All uploads (#1733), where ONE archive backed N queue items and
  2081. soft-deleting the archive left N "cancelled" rows behind. The change
  2082. keeps the printing guard (a row with ``status='printing'`` blocks the
  2083. delete one layer up at the API route), so we never delete the row of
  2084. an actively-running print here.
  2085. Print history lives in ``PrintLogEntry`` (FK ``ON DELETE SET NULL``) —
  2086. the audit trail survives independently of the queue rows.
  2087. """
  2088. from sqlalchemy import select
  2089. from backend.app.models.print_queue import PrintQueueItem
  2090. from backend.app.services.archive import ArchiveService
  2091. printer = await printer_factory()
  2092. archive = await archive_factory(thumbnail_path="archives/test/test/thumbnail.png")
  2093. pending = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
  2094. completed = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="completed")
  2095. service = ArchiveService(db_session)
  2096. assert await service.soft_delete_archive(archive.id) is True
  2097. # Every queue row that referenced this archive is gone — both the
  2098. # pending and the completed rows. Print history (PrintLogEntry) is
  2099. # the authoritative record and is preserved by the FK SET NULL.
  2100. remaining = (
  2101. (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id.in_([pending.id, completed.id]))))
  2102. .scalars()
  2103. .all()
  2104. )
  2105. assert remaining == [], (
  2106. "Soft-deleting the archive must delete every related queue row, "
  2107. f"got {[(r.id, r.status) for r in remaining]} still present"
  2108. )
  2109. @pytest.mark.asyncio
  2110. @pytest.mark.integration
  2111. async def test_queue_api_hides_archive_surface_when_soft_deleted(
  2112. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
  2113. ):
  2114. """Queue serializer must NOT populate archive_thumbnail / archive_name
  2115. when the archive is soft-deleted — otherwise the frontend renders a
  2116. broken <img> and 404-storms the thumbnail / plates / plate-thumbnail
  2117. endpoints. archive_deleted=True signals the soft-deleted state so
  2118. the UI can render a 'source deleted' badge."""
  2119. from datetime import datetime, timezone
  2120. printer = await printer_factory()
  2121. archive = await archive_factory(
  2122. print_name="Test Print",
  2123. thumbnail_path="archives/test/test/thumbnail.png",
  2124. deleted_at=datetime.now(timezone.utc), # Pre-soft-deleted
  2125. )
  2126. item = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="cancelled")
  2127. resp = await async_client.get("/api/v1/queue/")
  2128. assert resp.status_code == 200
  2129. body = resp.json()
  2130. row = next((r for r in body if r["id"] == item.id), None)
  2131. assert row is not None
  2132. assert row["archive_deleted"] is True
  2133. assert row["archive_thumbnail"] is None, "must not expose stale thumbnail path for soft-deleted archive"
  2134. assert row["archive_name"] is None
  2135. @pytest.mark.asyncio
  2136. @pytest.mark.integration
  2137. async def test_queue_api_still_exposes_archive_surface_when_live(
  2138. self, async_client: AsyncClient, printer_factory, archive_factory, queue_item_factory, db_session
  2139. ):
  2140. """Sanity guard: the soft-delete suppression must not affect live
  2141. archives. archive_name / archive_thumbnail still flow through and
  2142. archive_deleted stays False."""
  2143. printer = await printer_factory()
  2144. archive = await archive_factory(
  2145. print_name="Live Archive",
  2146. thumbnail_path="archives/test/live/thumbnail.png",
  2147. )
  2148. item = await queue_item_factory(printer_id=printer.id, archive_id=archive.id, status="pending")
  2149. resp = await async_client.get("/api/v1/queue/")
  2150. assert resp.status_code == 200
  2151. row = next((r for r in resp.json() if r["id"] == item.id), None)
  2152. assert row is not None
  2153. assert row["archive_deleted"] is False
  2154. assert row["archive_name"] == "Live Archive"
  2155. assert row["archive_thumbnail"] == "archives/test/live/thumbnail.png"