test_pipeline_runs_api.py 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268
  1. """Integration tests for Slicer Pipeline runs (#1425 PR B).
  2. Slicing itself is a network call to the slicer sidecar — these tests
  3. stub ``slice_and_persist`` so the orchestration logic is exercised without
  4. needing a live sidecar in CI.
  5. """
  6. from __future__ import annotations
  7. from unittest.mock import AsyncMock, patch
  8. import pytest
  9. from httpx import AsyncClient
  10. def _pipeline_payload(**overrides) -> dict:
  11. payload = {
  12. "name": "Production Batch",
  13. "description": None,
  14. "printer_preset": {"source": "local", "id": "1"},
  15. "process_preset": {"source": "local", "id": "2"},
  16. "filament_presets": [{"source": "local", "id": "3"}],
  17. "bed_type": None,
  18. }
  19. payload.update(overrides)
  20. return payload
  21. @pytest.fixture
  22. async def pipeline_factory(async_client: AsyncClient):
  23. """Create pipelines via the API + optionally set a target printer."""
  24. async def _make(target_printer_id: int | None = None, **overrides) -> dict:
  25. resp = await async_client.post("/api/v1/slicer-pipelines/", json=_pipeline_payload(**overrides))
  26. assert resp.status_code == 201, resp.text
  27. pipeline = resp.json()
  28. if target_printer_id is not None:
  29. put_resp = await async_client.put(
  30. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  31. json={"target_kind": "specific_printer", "target_printer_id": target_printer_id},
  32. )
  33. assert put_resp.status_code == 200, put_resp.text
  34. pipeline = put_resp.json()
  35. return pipeline
  36. return _make
  37. @pytest.fixture
  38. async def printer_factory(db_session):
  39. """Insert a Printer row for tests that need a target_printer_id."""
  40. from backend.app.models.printer import Printer
  41. counter = [0]
  42. async def _make(**overrides) -> Printer:
  43. counter[0] += 1
  44. defaults = {
  45. "name": f"X1C #{counter[0]}",
  46. "serial_number": f"SERIAL{counter[0]:04d}",
  47. "ip_address": "192.0.2.1",
  48. "access_code": "ABCD1234",
  49. "model": "Bambu Lab X1 Carbon",
  50. "is_active": True,
  51. }
  52. defaults.update(overrides)
  53. printer = Printer(**defaults)
  54. db_session.add(printer)
  55. await db_session.commit()
  56. await db_session.refresh(printer)
  57. return printer
  58. return _make
  59. @pytest.fixture
  60. async def library_file_factory(db_session):
  61. """Insert a LibraryFile row for tests that need a source_library_file_id."""
  62. from pathlib import Path
  63. from backend.app.core.config import settings as app_settings
  64. from backend.app.models.library import LibraryFile
  65. counter = [0]
  66. async def _make(**overrides) -> LibraryFile:
  67. counter[0] += 1
  68. # Materialise an empty file on disk so the orchestration's path-exists
  69. # guard passes when tests reach it.
  70. rel = f"test_pipeline_run_{counter[0]}.3mf"
  71. abs_path = Path(app_settings.base_dir) / rel
  72. abs_path.parent.mkdir(parents=True, exist_ok=True)
  73. abs_path.write_bytes(b"")
  74. defaults = {
  75. "filename": f"cube_{counter[0]}.3mf",
  76. "file_path": rel,
  77. "file_type": "3mf",
  78. "file_size": 0,
  79. "file_hash": f"hash_{counter[0]}",
  80. "source_type": "uploaded",
  81. }
  82. defaults.update(overrides)
  83. row = LibraryFile(**defaults)
  84. db_session.add(row)
  85. await db_session.commit()
  86. await db_session.refresh(row)
  87. return row
  88. return _make
  89. class TestSlicerPipelineTarget:
  90. """PUT /slicer-pipelines/{id} accepts the new target fields."""
  91. @pytest.mark.asyncio
  92. @pytest.mark.integration
  93. async def test_update_writes_target(self, async_client: AsyncClient, pipeline_factory, printer_factory):
  94. printer = await printer_factory()
  95. pipeline = await pipeline_factory()
  96. resp = await async_client.put(
  97. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  98. json={"target_kind": "specific_printer", "target_printer_id": printer.id},
  99. )
  100. assert resp.status_code == 200, resp.text
  101. updated = resp.json()
  102. assert updated["target_kind"] == "specific_printer"
  103. assert updated["target_printer_id"] == printer.id
  104. @pytest.mark.asyncio
  105. @pytest.mark.integration
  106. async def test_update_target_printer_id_zero_clears(
  107. self, async_client: AsyncClient, pipeline_factory, printer_factory
  108. ):
  109. """Empty-select dropdown sends target_printer_id=0 → backend treats
  110. as 'clear' rather than referencing printer #0 (which doesn't exist)."""
  111. printer = await printer_factory()
  112. pipeline = await pipeline_factory(target_printer_id=printer.id)
  113. resp = await async_client.put(
  114. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  115. json={"target_printer_id": 0},
  116. )
  117. assert resp.status_code == 200, resp.text
  118. assert resp.json()["target_printer_id"] is None
  119. class TestCheckEligibility:
  120. """POST /slicer-pipelines/{id}/check-eligibility surfaces structured issues."""
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_no_target_set(
  124. self,
  125. async_client: AsyncClient,
  126. pipeline_factory,
  127. library_file_factory,
  128. ):
  129. pipeline = await pipeline_factory() # no target set
  130. src = await library_file_factory()
  131. resp = await async_client.post(
  132. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  133. json={"source_library_file_id": src.id},
  134. )
  135. assert resp.status_code == 200
  136. body = resp.json()
  137. assert body["ok"] is False
  138. kinds = [i["kind"] for i in body["issues"]]
  139. # PR A defaults target_kind to 'printer_class' so a freshly-saved
  140. # pipeline with no target_model_class surfaces ``class_not_set``; the
  141. # PR B UI path that hadn't pinned a target_printer_id would surface
  142. # ``printer_not_set``. Both signal the same thing to the operator;
  143. # accept either.
  144. assert kinds == ["class_not_set"] or kinds == ["printer_not_set"]
  145. @pytest.mark.asyncio
  146. @pytest.mark.integration
  147. async def test_printer_disabled(
  148. self,
  149. async_client: AsyncClient,
  150. pipeline_factory,
  151. printer_factory,
  152. library_file_factory,
  153. ):
  154. printer = await printer_factory(is_active=False)
  155. pipeline = await pipeline_factory(target_printer_id=printer.id)
  156. src = await library_file_factory()
  157. with patch("backend.app.api.routes.pipeline_runs._load_printer_status", new=AsyncMock(return_value=None)):
  158. resp = await async_client.post(
  159. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  160. json={"source_library_file_id": src.id},
  161. )
  162. assert resp.status_code == 200
  163. body = resp.json()
  164. kinds = [i["kind"] for i in body["issues"]]
  165. assert "printer_disabled" in kinds
  166. # printer_offline also fires because get_status returns None — both
  167. # issues are expected and both block.
  168. assert "printer_offline" in kinds
  169. assert body["ok"] is False
  170. @pytest.mark.asyncio
  171. @pytest.mark.integration
  172. async def test_online_match_clears_issues(
  173. self,
  174. async_client: AsyncClient,
  175. pipeline_factory,
  176. printer_factory,
  177. library_file_factory,
  178. db_session,
  179. ):
  180. """Patch printer_manager so AMS slot 0 carries the same canonical
  181. type the pipeline's local-tier filament preset declares."""
  182. from backend.app.models.local_preset import LocalPreset
  183. preset = LocalPreset(
  184. name="My PLA",
  185. preset_type="filament",
  186. source="manual",
  187. setting="{}",
  188. filament_type="PLA",
  189. default_filament_colour="#FFFFFF",
  190. )
  191. db_session.add(preset)
  192. await db_session.commit()
  193. await db_session.refresh(preset)
  194. printer = await printer_factory()
  195. pipeline = await pipeline_factory(
  196. target_printer_id=printer.id,
  197. filament_presets=[{"source": "local", "id": str(preset.id)}],
  198. )
  199. src = await library_file_factory()
  200. # The printer reports the generic material in tray_type and the product
  201. # name in tray_sub_brands, so this is the shape a real AMS sends.
  202. live_status = {
  203. "connected": True,
  204. "raw_data": {"ams": [{"tray": [{"tray_type": "PLA", "tray_color": "FFFFFFFF"}]}]},
  205. }
  206. with patch(
  207. "backend.app.api.routes.pipeline_runs._load_printer_status",
  208. new=AsyncMock(return_value=live_status),
  209. ):
  210. resp = await async_client.post(
  211. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  212. json={"source_library_file_id": src.id},
  213. )
  214. assert resp.status_code == 200
  215. body = resp.json()
  216. assert body["ok"] is True
  217. assert body["issues"] == []
  218. assert body["target_printer_name"] == printer.name
  219. @pytest.mark.asyncio
  220. @pytest.mark.integration
  221. async def test_a_product_name_in_tray_type_is_reported_as_a_mismatch(
  222. self,
  223. async_client: AsyncClient,
  224. db_session,
  225. printer_factory,
  226. pipeline_factory,
  227. library_file_factory,
  228. ):
  229. """Eligibility answers with the dispatch matcher's type rules, not its own.
  230. It used to alias "PLA Basic" to "PLA" and pass this; the matcher never
  231. did, so the run cleared the pre-flight and then failed to map the slot.
  232. Flagging it here is the honest answer even though it is the stricter one.
  233. """
  234. from backend.app.models.local_preset import LocalPreset
  235. preset = LocalPreset(
  236. name="My PLA",
  237. preset_type="filament",
  238. source="manual",
  239. setting="{}",
  240. filament_type="PLA",
  241. default_filament_colour="#FFFFFF",
  242. )
  243. db_session.add(preset)
  244. await db_session.commit()
  245. await db_session.refresh(preset)
  246. printer = await printer_factory()
  247. pipeline = await pipeline_factory(
  248. target_printer_id=printer.id,
  249. filament_presets=[{"source": "local", "id": str(preset.id)}],
  250. )
  251. src = await library_file_factory()
  252. live_status = {
  253. "connected": True,
  254. "raw_data": {"ams": [{"tray": [{"tray_type": "PLA Basic", "tray_color": "FFFFFFFF"}]}]},
  255. }
  256. with patch(
  257. "backend.app.api.routes.pipeline_runs._load_printer_status",
  258. new=AsyncMock(return_value=live_status),
  259. ):
  260. resp = await async_client.post(
  261. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  262. json={"source_library_file_id": src.id},
  263. )
  264. assert resp.status_code == 200
  265. body = resp.json()
  266. assert [i["kind"] for i in body["issues"]] == ["filament_type_mismatch"]
  267. class TestRunPipeline:
  268. """POST /slicer-pipelines/{id}/run orchestrates slice + enqueue."""
  269. @pytest.mark.asyncio
  270. @pytest.mark.integration
  271. async def test_run_with_issues_and_no_force_returns_409(
  272. self,
  273. async_client: AsyncClient,
  274. pipeline_factory,
  275. library_file_factory,
  276. ):
  277. pipeline = await pipeline_factory() # no target set
  278. src = await library_file_factory()
  279. resp = await async_client.post(
  280. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  281. json={"source_library_file_id": src.id},
  282. )
  283. assert resp.status_code == 409
  284. # Eligibility report rides in detail.
  285. detail = resp.json()["detail"]
  286. assert detail["ok"] is False
  287. # printer_not_set or class_not_set — depends on the PR A default
  288. # target_kind. Both mean "no target chosen yet".
  289. kinds = [i["kind"] for i in detail["issues"]]
  290. assert "printer_not_set" in kinds or "class_not_set" in kinds
  291. @pytest.mark.asyncio
  292. @pytest.mark.integration
  293. async def test_run_force_with_no_target_still_400(
  294. self,
  295. async_client: AsyncClient,
  296. pipeline_factory,
  297. library_file_factory,
  298. ):
  299. """``force=True`` bypasses the 409 but the run endpoint still needs a
  300. target to enqueue against — the second guard returns 400."""
  301. pipeline = await pipeline_factory()
  302. src = await library_file_factory()
  303. resp = await async_client.post(
  304. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  305. json={"source_library_file_id": src.id, "force": True},
  306. )
  307. assert resp.status_code == 400
  308. @pytest.mark.asyncio
  309. @pytest.mark.integration
  310. async def test_run_creates_run_and_job(
  311. self,
  312. async_client: AsyncClient,
  313. pipeline_factory,
  314. printer_factory,
  315. library_file_factory,
  316. ):
  317. printer = await printer_factory()
  318. pipeline = await pipeline_factory(target_printer_id=printer.id)
  319. src = await library_file_factory()
  320. live_status = {"connected": True, "raw_data": {"ams": []}}
  321. # AMS empty → eligibility surfaces filament_unverified (non-blocking)
  322. # for the standard-tier filament refs the default factory uses; report
  323. # is ok=True so no force needed.
  324. from dataclasses import dataclass
  325. @dataclass
  326. class _FakeSliceJob:
  327. id: int = 9001
  328. with (
  329. patch(
  330. "backend.app.api.routes.pipeline_runs._load_printer_status",
  331. new=AsyncMock(return_value=live_status),
  332. ),
  333. patch(
  334. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  335. new=AsyncMock(return_value=_FakeSliceJob()),
  336. ),
  337. ):
  338. resp = await async_client.post(
  339. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  340. json={"source_library_file_id": src.id},
  341. )
  342. assert resp.status_code == 202, resp.text
  343. body = resp.json()
  344. assert body["pipeline_id"] == pipeline["id"]
  345. assert body["source_library_file_id"] == src.id
  346. assert body["copies"] == 1
  347. assert body["status"] == "queued"
  348. assert len(body["jobs"]) == 1
  349. assert body["jobs"][0]["copy_index"] == 0
  350. assert body["eligibility_overridden"] is False
  351. # slice_job_id rides on the response so the frontend can call
  352. # trackJob and render the progress toast.
  353. assert body["slice_job_id"] == 9001
  354. class TestRunListAndGet:
  355. """Run history surfaces."""
  356. @pytest.mark.asyncio
  357. @pytest.mark.integration
  358. async def test_list_runs_empty(
  359. self,
  360. async_client: AsyncClient,
  361. pipeline_factory,
  362. ):
  363. pipeline = await pipeline_factory()
  364. resp = await async_client.get(f"/api/v1/slicer-pipelines/{pipeline['id']}/runs")
  365. assert resp.status_code == 200
  366. assert resp.json() == {"runs": [], "total": 0}
  367. @pytest.mark.asyncio
  368. @pytest.mark.integration
  369. async def test_get_run_404(
  370. self,
  371. async_client: AsyncClient,
  372. ):
  373. resp = await async_client.get("/api/v1/pipeline-runs/99999")
  374. assert resp.status_code == 404
  375. class TestCancelRun:
  376. """Cancellation marks the run + linked queue entry."""
  377. @pytest.mark.asyncio
  378. @pytest.mark.integration
  379. async def test_cancel_unknown_run_404(self, async_client: AsyncClient):
  380. resp = await async_client.post("/api/v1/pipeline-runs/99999/cancel")
  381. assert resp.status_code == 404
  382. @pytest.mark.asyncio
  383. @pytest.mark.integration
  384. async def test_cancel_marks_queued_run(
  385. self,
  386. async_client: AsyncClient,
  387. pipeline_factory,
  388. printer_factory,
  389. library_file_factory,
  390. db_session,
  391. ):
  392. printer = await printer_factory()
  393. pipeline = await pipeline_factory(target_printer_id=printer.id)
  394. src = await library_file_factory()
  395. live_status = {"connected": True, "raw_data": {"ams": []}}
  396. from dataclasses import dataclass
  397. @dataclass
  398. class _FakeSliceJob:
  399. id: int = 9001
  400. with (
  401. patch(
  402. "backend.app.api.routes.pipeline_runs._load_printer_status",
  403. new=AsyncMock(return_value=live_status),
  404. ),
  405. patch(
  406. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  407. new=AsyncMock(return_value=_FakeSliceJob()),
  408. ),
  409. ):
  410. run_resp = await async_client.post(
  411. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  412. json={"source_library_file_id": src.id},
  413. )
  414. run_id = run_resp.json()["id"]
  415. cancel_resp = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/cancel")
  416. assert cancel_resp.status_code == 200
  417. assert cancel_resp.json()["status"] == "cancelled"
  418. @pytest.mark.asyncio
  419. @pytest.mark.integration
  420. async def test_run_accepts_archive_source(
  421. self,
  422. async_client: AsyncClient,
  423. pipeline_factory,
  424. printer_factory,
  425. db_session,
  426. ):
  427. """``source_archive_id`` is accepted in place of source_library_file_id."""
  428. from pathlib import Path
  429. from backend.app.core.config import settings as app_settings
  430. from backend.app.models.archive import PrintArchive
  431. printer = await printer_factory()
  432. pipeline = await pipeline_factory(target_printer_id=printer.id)
  433. rel = "test_pipeline_archive_source.3mf"
  434. (Path(app_settings.base_dir) / rel).write_bytes(b"")
  435. archive = PrintArchive(
  436. printer_id=printer.id,
  437. filename="Archive Source.3mf",
  438. file_path=rel,
  439. file_size=0,
  440. source_3mf_path=rel,
  441. )
  442. db_session.add(archive)
  443. await db_session.commit()
  444. await db_session.refresh(archive)
  445. from dataclasses import dataclass
  446. @dataclass
  447. class _FakeSliceJob:
  448. id: int = 7777
  449. live_status = {"connected": True, "raw_data": {"ams": []}}
  450. with (
  451. patch(
  452. "backend.app.api.routes.pipeline_runs._load_printer_status",
  453. new=AsyncMock(return_value=live_status),
  454. ),
  455. patch(
  456. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  457. new=AsyncMock(return_value=_FakeSliceJob()),
  458. ),
  459. ):
  460. resp = await async_client.post(
  461. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  462. json={"source_archive_id": archive.id},
  463. )
  464. assert resp.status_code == 202, resp.text
  465. body = resp.json()
  466. assert body["source_library_file_id"] is None
  467. assert body["source_archive_id"] == archive.id
  468. assert body["slice_job_id"] == 7777
  469. @pytest.mark.asyncio
  470. @pytest.mark.integration
  471. async def test_run_rejects_no_source(
  472. self,
  473. async_client: AsyncClient,
  474. pipeline_factory,
  475. printer_factory,
  476. ):
  477. printer = await printer_factory()
  478. pipeline = await pipeline_factory(target_printer_id=printer.id)
  479. resp = await async_client.post(f"/api/v1/slicer-pipelines/{pipeline['id']}/run", json={})
  480. assert resp.status_code == 422
  481. @pytest.mark.asyncio
  482. @pytest.mark.integration
  483. async def test_run_rejects_both_sources(
  484. self,
  485. async_client: AsyncClient,
  486. pipeline_factory,
  487. printer_factory,
  488. library_file_factory,
  489. ):
  490. printer = await printer_factory()
  491. pipeline = await pipeline_factory(target_printer_id=printer.id)
  492. src = await library_file_factory()
  493. resp = await async_client.post(
  494. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  495. json={"source_library_file_id": src.id, "source_archive_id": 99},
  496. )
  497. assert resp.status_code == 422
  498. class TestPipelineC:
  499. """PR C — multi-copy, class targeting, fanout strategies, retry-failed,
  500. dashboard list, max-copies cap."""
  501. @pytest.mark.asyncio
  502. @pytest.mark.integration
  503. async def test_copies_cap_enforced(
  504. self,
  505. async_client: AsyncClient,
  506. pipeline_factory,
  507. printer_factory,
  508. library_file_factory,
  509. ):
  510. printer = await printer_factory()
  511. pipeline = await pipeline_factory(target_printer_id=printer.id)
  512. src = await library_file_factory()
  513. # Default cap is 50; over-request returns 422 even with valid eligibility.
  514. resp = await async_client.post(
  515. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  516. json={"source_library_file_id": src.id, "copies": 9999},
  517. )
  518. assert resp.status_code == 422 # schema gate (le=1000)
  519. @pytest.mark.asyncio
  520. @pytest.mark.integration
  521. async def test_run_copies_3_creates_3_jobs(
  522. self,
  523. async_client: AsyncClient,
  524. pipeline_factory,
  525. printer_factory,
  526. library_file_factory,
  527. ):
  528. from dataclasses import dataclass
  529. @dataclass
  530. class _FakeSliceJob:
  531. id: int = 5555
  532. printer = await printer_factory()
  533. pipeline = await pipeline_factory(target_printer_id=printer.id)
  534. src = await library_file_factory()
  535. live_status = {"connected": True, "raw_data": {"ams": []}}
  536. with (
  537. patch(
  538. "backend.app.api.routes.pipeline_runs._load_printer_status",
  539. new=AsyncMock(return_value=live_status),
  540. ),
  541. patch(
  542. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  543. new=AsyncMock(return_value=_FakeSliceJob()),
  544. ),
  545. ):
  546. resp = await async_client.post(
  547. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  548. json={"source_library_file_id": src.id, "copies": 3},
  549. )
  550. assert resp.status_code == 202, resp.text
  551. body = resp.json()
  552. assert body["copies"] == 3
  553. assert len(body["jobs"]) == 3
  554. assert [j["copy_index"] for j in body["jobs"]] == [0, 1, 2]
  555. @pytest.mark.asyncio
  556. @pytest.mark.integration
  557. async def test_class_eligibility_per_printer_breakdown(
  558. self,
  559. async_client: AsyncClient,
  560. pipeline_factory,
  561. printer_factory,
  562. library_file_factory,
  563. ):
  564. """target_kind='printer_class' surfaces per-printer reports."""
  565. await printer_factory(model="X1C")
  566. await printer_factory(model="X1C")
  567. await printer_factory(model="P1S") # noise — different model
  568. pipeline = await pipeline_factory()
  569. # Wire class targeting via PUT.
  570. put_resp = await async_client.put(
  571. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  572. json={
  573. "target_kind": "printer_class",
  574. "target_printer_id": 0,
  575. "target_model_class": "X1C",
  576. "fanout_strategy": "max_parallel",
  577. },
  578. )
  579. assert put_resp.status_code == 200, put_resp.text
  580. src = await library_file_factory()
  581. resp = await async_client.post(
  582. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  583. json={"source_library_file_id": src.id},
  584. )
  585. assert resp.status_code == 200, resp.text
  586. body = resp.json()
  587. assert body["target_kind"] == "printer_class"
  588. assert body["target_model_class"] == "X1C"
  589. # Two X1Cs were created — both should appear in the per-printer breakdown.
  590. assert len(body["printer_reports"]) == 2
  591. assert all(r["printer_name"].startswith("X1C") for r in body["printer_reports"])
  592. # AMS empty + no live state → both are offline, so ok=False.
  593. assert body["ok"] is False
  594. @pytest.mark.asyncio
  595. @pytest.mark.integration
  596. async def test_class_eligibility_no_matching_printers(
  597. self,
  598. async_client: AsyncClient,
  599. pipeline_factory,
  600. printer_factory,
  601. library_file_factory,
  602. ):
  603. await printer_factory(model="P1S") # only a P1S in the install
  604. pipeline = await pipeline_factory()
  605. await async_client.put(
  606. f"/api/v1/slicer-pipelines/{pipeline['id']}",
  607. json={
  608. "target_kind": "printer_class",
  609. "target_printer_id": 0,
  610. "target_model_class": "X1C",
  611. },
  612. )
  613. src = await library_file_factory()
  614. resp = await async_client.post(
  615. f"/api/v1/slicer-pipelines/{pipeline['id']}/check-eligibility",
  616. json={"source_library_file_id": src.id},
  617. )
  618. body = resp.json()
  619. assert body["ok"] is False
  620. assert any(i["kind"] == "no_class_matches" for i in body["issues"])
  621. @pytest.mark.asyncio
  622. @pytest.mark.integration
  623. async def test_list_all_runs_dashboard_endpoint(
  624. self,
  625. async_client: AsyncClient,
  626. pipeline_factory,
  627. printer_factory,
  628. library_file_factory,
  629. db_session,
  630. ):
  631. from backend.app.models.pipeline_run import PipelineRun
  632. printer = await printer_factory()
  633. pipeline = await pipeline_factory(target_printer_id=printer.id)
  634. src = await library_file_factory()
  635. for i in range(3):
  636. run = PipelineRun(
  637. pipeline_id=pipeline["id"],
  638. source_library_file_id=src.id,
  639. copies=1,
  640. status="completed" if i % 2 == 0 else "failed",
  641. )
  642. db_session.add(run)
  643. await db_session.commit()
  644. resp = await async_client.get("/api/v1/pipeline-runs?limit=10")
  645. assert resp.status_code == 200
  646. body = resp.json()
  647. assert body["total"] == 3
  648. assert len(body["runs"]) == 3
  649. # Newest first.
  650. assert body["runs"][0]["id"] > body["runs"][-1]["id"]
  651. # Filter by status.
  652. resp = await async_client.get("/api/v1/pipeline-runs?status=failed")
  653. body = resp.json()
  654. assert all(r["status"] == "failed" for r in body["runs"])
  655. @pytest.mark.asyncio
  656. @pytest.mark.integration
  657. async def test_retry_failed_creates_child_run(
  658. self,
  659. async_client: AsyncClient,
  660. pipeline_factory,
  661. printer_factory,
  662. library_file_factory,
  663. db_session,
  664. ):
  665. from dataclasses import dataclass
  666. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  667. @dataclass
  668. class _FakeSliceJob:
  669. id: int = 6666
  670. printer = await printer_factory()
  671. pipeline = await pipeline_factory(target_printer_id=printer.id)
  672. src = await library_file_factory()
  673. # Build a parent run with 3 jobs: 1 completed, 2 failed → retry
  674. # should request copies=2.
  675. parent = PipelineRun(
  676. pipeline_id=pipeline["id"],
  677. source_library_file_id=src.id,
  678. copies=3,
  679. status="partial_failure",
  680. )
  681. db_session.add(parent)
  682. await db_session.flush()
  683. for idx, status in enumerate(["completed", "failed", "failed"]):
  684. db_session.add(PipelineJob(pipeline_run_id=parent.id, copy_index=idx, status=status))
  685. await db_session.commit()
  686. await db_session.refresh(parent)
  687. live_status = {"connected": True, "raw_data": {"ams": []}}
  688. with (
  689. patch(
  690. "backend.app.api.routes.pipeline_runs._load_printer_status",
  691. new=AsyncMock(return_value=live_status),
  692. ),
  693. patch(
  694. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  695. new=AsyncMock(return_value=_FakeSliceJob()),
  696. ),
  697. ):
  698. resp = await async_client.post(f"/api/v1/pipeline-runs/{parent.id}/retry-failed")
  699. assert resp.status_code == 202, resp.text
  700. body = resp.json()
  701. assert body["copies"] == 2 # only the 2 failed copies
  702. assert body["parent_run_id"] == parent.id
  703. class TestPolishFollowUp:
  704. """Polish-pass fixes: dashboard target filters, clear endpoint, and the
  705. deleted-queue-entry → cancelled rollup behaviour."""
  706. @pytest.mark.asyncio
  707. @pytest.mark.integration
  708. async def test_dashboard_filters_by_target_printer(
  709. self,
  710. async_client: AsyncClient,
  711. pipeline_factory,
  712. printer_factory,
  713. library_file_factory,
  714. db_session,
  715. ):
  716. from backend.app.models.pipeline_run import PipelineRun
  717. printer_a = await printer_factory()
  718. printer_b = await printer_factory()
  719. pipe_a = await pipeline_factory(target_printer_id=printer_a.id)
  720. pipe_b = await pipeline_factory(target_printer_id=printer_b.id)
  721. src = await library_file_factory()
  722. for pipe in (pipe_a, pipe_a, pipe_b):
  723. db_session.add(
  724. PipelineRun(
  725. pipeline_id=pipe["id"],
  726. source_library_file_id=src.id,
  727. copies=1,
  728. status="completed",
  729. )
  730. )
  731. await db_session.commit()
  732. resp = await async_client.get(f"/api/v1/pipeline-runs?target_printer_id={printer_a.id}")
  733. assert resp.status_code == 200
  734. body = resp.json()
  735. assert body["total"] == 2
  736. assert all(r["target_printer_id"] == printer_a.id for r in body["runs"])
  737. @pytest.mark.asyncio
  738. @pytest.mark.integration
  739. async def test_dashboard_filters_by_target_model_class(
  740. self,
  741. async_client: AsyncClient,
  742. pipeline_factory,
  743. printer_factory,
  744. library_file_factory,
  745. db_session,
  746. ):
  747. from backend.app.models.pipeline_run import PipelineRun
  748. await printer_factory(model="X1C")
  749. await printer_factory(model="P1S")
  750. # Two pipelines, one class-targeting X1C, one P1S.
  751. pipe_x = await pipeline_factory()
  752. await async_client.put(
  753. f"/api/v1/slicer-pipelines/{pipe_x['id']}",
  754. json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "X1C"},
  755. )
  756. pipe_p = await pipeline_factory()
  757. await async_client.put(
  758. f"/api/v1/slicer-pipelines/{pipe_p['id']}",
  759. json={"target_kind": "printer_class", "target_printer_id": 0, "target_model_class": "P1S"},
  760. )
  761. src = await library_file_factory()
  762. for pipe in (pipe_x, pipe_p, pipe_p):
  763. db_session.add(
  764. PipelineRun(
  765. pipeline_id=pipe["id"],
  766. source_library_file_id=src.id,
  767. copies=1,
  768. status="completed",
  769. )
  770. )
  771. await db_session.commit()
  772. resp = await async_client.get("/api/v1/pipeline-runs?target_model_class=P1S")
  773. assert resp.status_code == 200
  774. body = resp.json()
  775. assert body["total"] == 2
  776. assert all(r["target_model_class"] == "P1S" for r in body["runs"])
  777. @pytest.mark.asyncio
  778. @pytest.mark.integration
  779. async def test_clear_endpoint_deletes_terminal_runs_only(
  780. self,
  781. async_client: AsyncClient,
  782. pipeline_factory,
  783. printer_factory,
  784. library_file_factory,
  785. db_session,
  786. ):
  787. from backend.app.models.pipeline_run import PipelineRun
  788. printer = await printer_factory()
  789. pipe = await pipeline_factory(target_printer_id=printer.id)
  790. src = await library_file_factory()
  791. for status in ("completed", "failed", "cancelled", "partial_failure", "dispatching", "in_progress"):
  792. db_session.add(
  793. PipelineRun(
  794. pipeline_id=pipe["id"],
  795. source_library_file_id=src.id,
  796. copies=1,
  797. status=status,
  798. )
  799. )
  800. await db_session.commit()
  801. resp = await async_client.post("/api/v1/pipeline-runs/clear")
  802. assert resp.status_code == 200, resp.text
  803. assert resp.json()["deleted"] == 4 # 4 terminal statuses cleared
  804. # The in-flight rows survive.
  805. survivors = (await async_client.get("/api/v1/pipeline-runs")).json()
  806. assert survivors["total"] == 2
  807. assert {r["status"] for r in survivors["runs"]} == {"dispatching", "in_progress"}
  808. @staticmethod
  809. async def _dispatched_run(db_session, pipeline_factory, printer_factory, library_file_factory):
  810. """A run in 'dispatching' whose one job is queued as a real queue entry."""
  811. from datetime import datetime, timezone
  812. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  813. from backend.app.models.print_queue import PrintQueueItem
  814. printer = await printer_factory()
  815. pipe = await pipeline_factory(target_printer_id=printer.id)
  816. src = await library_file_factory()
  817. item = PrintQueueItem(printer_id=printer.id, status="pending", library_file_id=src.id)
  818. run = PipelineRun(pipeline_id=pipe["id"], source_library_file_id=src.id, copies=1, status="dispatching")
  819. db_session.add_all([item, run])
  820. await db_session.flush()
  821. db_session.add(
  822. PipelineJob(
  823. pipeline_run_id=run.id,
  824. copy_index=0,
  825. queue_entry_id=item.id,
  826. assigned_printer_id=printer.id,
  827. status="queued",
  828. dispatched_at=datetime.now(timezone.utc),
  829. )
  830. )
  831. await db_session.commit()
  832. return run.id, item.id, src.id
  833. @pytest.mark.xfail(
  834. strict=True,
  835. reason="A job keeps status 'queued' after dispatch; once its queue entry is deleted, a pending copy cannot be told from a printed one. Needs the job's final status stored when its entry ends.",
  836. )
  837. @pytest.mark.asyncio
  838. @pytest.mark.integration
  839. async def test_deleted_queue_entry_rolls_up_as_cancelled(
  840. self,
  841. async_client: AsyncClient,
  842. pipeline_factory,
  843. printer_factory,
  844. library_file_factory,
  845. db_session,
  846. ):
  847. """When the queue entry that a PipelineJob is linked to gets deleted
  848. from the print-queue page, the job's live status should roll up to
  849. ``cancelled`` so the run doesn't sit forever showing ``queued`` /
  850. ``dispatching``. The delete leaves the job without its queue entry."""
  851. run_id, item_id, _ = await self._dispatched_run(
  852. db_session, pipeline_factory, printer_factory, library_file_factory
  853. )
  854. assert (await async_client.delete(f"/api/v1/queue/{item_id}")).status_code == 200
  855. resp = await async_client.get(f"/api/v1/pipeline-runs/{run_id}")
  856. assert resp.status_code == 200, resp.text
  857. body = resp.json()
  858. assert body["jobs"][0]["status"] == "cancelled"
  859. assert body["status"] == "cancelled"
  860. assert body["copies_cancelled"] == 1 and body["copies_in_progress"] == 0
  861. @pytest.mark.asyncio
  862. @pytest.mark.integration
  863. async def test_a_job_never_dispatched_is_not_called_cancelled(
  864. self,
  865. async_client: AsyncClient,
  866. pipeline_factory,
  867. printer_factory,
  868. library_file_factory,
  869. db_session,
  870. ):
  871. """No queue entry yet because it was never dispatched: still waiting."""
  872. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  873. printer = await printer_factory()
  874. pipe = await pipeline_factory(target_printer_id=printer.id)
  875. src = await library_file_factory()
  876. run = PipelineRun(pipeline_id=pipe["id"], source_library_file_id=src.id, copies=1, status="slicing")
  877. db_session.add(run)
  878. await db_session.flush()
  879. db_session.add(PipelineJob(pipeline_run_id=run.id, copy_index=0, status="pending"))
  880. await db_session.commit()
  881. body = (await async_client.get(f"/api/v1/pipeline-runs/{run.id}")).json()
  882. assert body["jobs"][0]["status"] == "pending"
  883. @pytest.mark.xfail(
  884. strict=True,
  885. reason="A job keeps status 'queued' after dispatch; once its queue entry is deleted, a pending copy cannot be told from a printed one. Needs the job's final status stored when its entry ends.",
  886. )
  887. @pytest.mark.asyncio
  888. @pytest.mark.integration
  889. async def test_retry_counts_a_deleted_queue_entry_as_cancelled(
  890. self,
  891. async_client: AsyncClient,
  892. pipeline_factory,
  893. printer_factory,
  894. library_file_factory,
  895. db_session,
  896. ):
  897. """What the run view shows as cancelled, retry starts again."""
  898. from dataclasses import dataclass
  899. @dataclass
  900. class _FakeSliceJob:
  901. id: int = 6667
  902. run_id, item_id, _ = await self._dispatched_run(
  903. db_session, pipeline_factory, printer_factory, library_file_factory
  904. )
  905. assert (await async_client.delete(f"/api/v1/queue/{item_id}")).status_code == 200
  906. live_status = {"connected": True, "raw_data": {"ams": []}}
  907. with (
  908. patch(
  909. "backend.app.api.routes.pipeline_runs._load_printer_status",
  910. new=AsyncMock(return_value=live_status),
  911. ),
  912. patch(
  913. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  914. new=AsyncMock(return_value=_FakeSliceJob()),
  915. ),
  916. ):
  917. resp = await async_client.post(f"/api/v1/pipeline-runs/{run_id}/retry-failed")
  918. assert resp.status_code == 202, resp.text
  919. assert resp.json()["copies"] == 1
  920. class TestCancelTerminal:
  921. @pytest.mark.asyncio
  922. @pytest.mark.integration
  923. async def test_cancel_terminal_run_is_idempotent(
  924. self,
  925. async_client: AsyncClient,
  926. pipeline_factory,
  927. printer_factory,
  928. library_file_factory,
  929. db_session,
  930. ):
  931. from backend.app.models.pipeline_run import PipelineRun
  932. printer = await printer_factory()
  933. pipeline = await pipeline_factory(target_printer_id=printer.id)
  934. src = await library_file_factory()
  935. run = PipelineRun(
  936. pipeline_id=pipeline["id"],
  937. source_library_file_id=src.id,
  938. copies=1,
  939. status="completed",
  940. )
  941. db_session.add(run)
  942. await db_session.commit()
  943. await db_session.refresh(run)
  944. resp = await async_client.post(f"/api/v1/pipeline-runs/{run.id}/cancel")
  945. assert resp.status_code == 200
  946. assert resp.json()["status"] == "completed" # unchanged
  947. class TestRunViaApiKey:
  948. """An API key may run a pipeline (#1425 follow-up).
  949. Every pipeline endpoint used to answer 403 for API keys — the three
  950. permissions were parked as administrative in PR A, before the run dispatch
  951. existed to decide about. ``pipelines:run`` now needs the key's Manage Queue
  952. *and* Manage Library scopes together, because a run slices into the library
  953. and then queues prints.
  954. """
  955. async def _admin_and_key(self, async_client: AsyncClient, db_session, **flags):
  956. """Enable auth, then mint a key owned by the admin. The owner matters:
  957. a key never out-ranks its owner, and only an owned key can stand in for
  958. a user when a cloud preset has to be resolved."""
  959. from sqlalchemy import select
  960. from backend.app.core.auth import generate_api_key
  961. from backend.app.models.api_key import APIKey
  962. from backend.app.models.user import User
  963. await async_client.post(
  964. "/api/v1/auth/setup",
  965. json={"auth_enabled": True, "admin_username": "pipeadmin", "admin_password": "AdminPass1!"},
  966. )
  967. admin = (await db_session.execute(select(User).where(User.username == "pipeadmin"))).scalar_one()
  968. full_key, key_hash, key_prefix = generate_api_key()
  969. db_session.add(
  970. APIKey(
  971. name="pipeline-runner",
  972. key_hash=key_hash,
  973. key_prefix=key_prefix,
  974. user_id=admin.id,
  975. enabled=True,
  976. **{"can_read_status": False, "can_queue": False, "can_manage_library": False, **flags},
  977. )
  978. )
  979. await db_session.commit()
  980. return admin, full_key
  981. @pytest.mark.asyncio
  982. @pytest.mark.integration
  983. async def test_a_scoped_key_runs_the_pipeline(
  984. self,
  985. async_client: AsyncClient,
  986. pipeline_factory,
  987. printer_factory,
  988. library_file_factory,
  989. db_session,
  990. ):
  991. from dataclasses import dataclass
  992. @dataclass
  993. class _FakeSliceJob:
  994. id: int = 7777
  995. printer = await printer_factory()
  996. pipeline = await pipeline_factory(target_printer_id=printer.id)
  997. src = await library_file_factory()
  998. # Auth goes on only now: the factories above post as an anonymous
  999. # caller, which is how every other test in this file works.
  1000. _, key = await self._admin_and_key(
  1001. async_client, db_session, can_read_status=True, can_queue=True, can_manage_library=True
  1002. )
  1003. live_status = {"connected": True, "raw_data": {"ams": []}}
  1004. with (
  1005. patch(
  1006. "backend.app.api.routes.pipeline_runs._load_printer_status",
  1007. new=AsyncMock(return_value=live_status),
  1008. ),
  1009. patch(
  1010. "backend.app.services.slice_dispatch.slice_dispatch.enqueue",
  1011. new=AsyncMock(return_value=_FakeSliceJob()),
  1012. ),
  1013. ):
  1014. resp = await async_client.post(
  1015. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  1016. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  1017. headers={"X-API-Key": key},
  1018. )
  1019. assert resp.status_code == 202, resp.text
  1020. @pytest.mark.asyncio
  1021. @pytest.mark.integration
  1022. async def test_a_key_without_manage_library_is_refused(
  1023. self,
  1024. async_client: AsyncClient,
  1025. pipeline_factory,
  1026. printer_factory,
  1027. library_file_factory,
  1028. db_session,
  1029. ):
  1030. """Queueing prints is only half of what a run does. The refusal names
  1031. the flag that is missing rather than calling the whole thing
  1032. administrative."""
  1033. printer = await printer_factory()
  1034. pipeline = await pipeline_factory(target_printer_id=printer.id)
  1035. src = await library_file_factory()
  1036. _, key = await self._admin_and_key(async_client, db_session, can_read_status=True, can_queue=True)
  1037. resp = await async_client.post(
  1038. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  1039. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  1040. headers={"X-API-Key": key},
  1041. )
  1042. assert resp.status_code == 403
  1043. assert "can_manage_library" in resp.json()["detail"]
  1044. @pytest.mark.asyncio
  1045. @pytest.mark.integration
  1046. async def test_a_cloud_scoped_key_slices_as_its_owner(
  1047. self,
  1048. async_client: AsyncClient,
  1049. pipeline_factory,
  1050. printer_factory,
  1051. library_file_factory,
  1052. db_session,
  1053. ):
  1054. """A pipeline can be built on Bambu/Orca Cloud presets, and resolving
  1055. those reads a cloud token off a user record. The permission gate hands
  1056. an API-keyed request ``current_user=None``, so without falling back to
  1057. the key's owner such a pipeline would have nobody to resolve against
  1058. and would fail at slice time — the same fallback the direct slice route
  1059. makes."""
  1060. from dataclasses import dataclass
  1061. @dataclass
  1062. class _FakeSliceJob:
  1063. id: int = 7778
  1064. printer = await printer_factory()
  1065. pipeline = await pipeline_factory(target_printer_id=printer.id)
  1066. src = await library_file_factory()
  1067. admin, key = await self._admin_and_key(
  1068. async_client,
  1069. db_session,
  1070. can_read_status=True,
  1071. can_queue=True,
  1072. can_manage_library=True,
  1073. can_access_cloud=True,
  1074. )
  1075. enqueue = AsyncMock(return_value=_FakeSliceJob())
  1076. live_status = {"connected": True, "raw_data": {"ams": []}}
  1077. with (
  1078. patch(
  1079. "backend.app.api.routes.pipeline_runs._load_printer_status",
  1080. new=AsyncMock(return_value=live_status),
  1081. ),
  1082. patch("backend.app.services.slice_dispatch.slice_dispatch.enqueue", new=enqueue),
  1083. ):
  1084. resp = await async_client.post(
  1085. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  1086. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  1087. headers={"X-API-Key": key},
  1088. )
  1089. assert resp.status_code == 202, resp.text
  1090. assert enqueue.await_args.kwargs["owner_id"] == admin.id
  1091. @pytest.mark.asyncio
  1092. @pytest.mark.integration
  1093. async def test_a_key_without_cloud_scope_stays_anonymous(
  1094. self,
  1095. async_client: AsyncClient,
  1096. pipeline_factory,
  1097. printer_factory,
  1098. library_file_factory,
  1099. db_session,
  1100. ):
  1101. """The fallback is the cloud scope's own opt-in, not a general identity
  1102. for API keys: a key without it slices unattributed, exactly as before."""
  1103. from dataclasses import dataclass
  1104. @dataclass
  1105. class _FakeSliceJob:
  1106. id: int = 7779
  1107. printer = await printer_factory()
  1108. pipeline = await pipeline_factory(target_printer_id=printer.id)
  1109. src = await library_file_factory()
  1110. # Same owner and same three scopes as the test above — only the cloud
  1111. # opt-in differs.
  1112. _, full_key = await self._admin_and_key(
  1113. async_client, db_session, can_read_status=True, can_queue=True, can_manage_library=True
  1114. )
  1115. enqueue = AsyncMock(return_value=_FakeSliceJob())
  1116. live_status = {"connected": True, "raw_data": {"ams": []}}
  1117. with (
  1118. patch(
  1119. "backend.app.api.routes.pipeline_runs._load_printer_status",
  1120. new=AsyncMock(return_value=live_status),
  1121. ),
  1122. patch("backend.app.services.slice_dispatch.slice_dispatch.enqueue", new=enqueue),
  1123. ):
  1124. resp = await async_client.post(
  1125. f"/api/v1/slicer-pipelines/{pipeline['id']}/run",
  1126. json={"source_library_file_id": src.id, "copies": 1, "force": True},
  1127. headers={"X-API-Key": full_key},
  1128. )
  1129. assert resp.status_code == 202, resp.text
  1130. assert enqueue.await_args.kwargs["owner_id"] is None