test_background_dispatch_api.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. """Integration tests for background dispatch API behavior."""
  2. from unittest.mock import AsyncMock, patch
  3. import pytest
  4. from fastapi import HTTPException
  5. from httpx import AsyncClient
  6. from sqlalchemy import select
  7. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  8. from backend.app.models.finance import BudgetReservation, CostCenter
  9. from backend.app.models.settings import Settings
  10. from backend.app.services.background_dispatch import BackgroundDispatchService, DispatchEnqueueRejected
  11. async def enable_billing(db_session):
  12. setting = await db_session.scalar(select(Settings).where(Settings.key == "billing_enabled"))
  13. if setting is None:
  14. db_session.add(Settings(key="billing_enabled", value="true"))
  15. else:
  16. setting.value = "true"
  17. await db_session.commit()
  18. class TestBackgroundDispatchArchivesAPI:
  19. """Tests for archive reprint dispatch endpoint."""
  20. @pytest.mark.asyncio
  21. @pytest.mark.integration
  22. async def test_reprint_returns_dispatched_payload(
  23. self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
  24. ):
  25. """Reprint endpoint returns background dispatch metadata."""
  26. printer = await printer_factory()
  27. archive = await archive_factory(
  28. printer.id,
  29. filename="widget.gcode.3mf",
  30. file_path="archives/test/widget.gcode.3mf",
  31. )
  32. archive_file = tmp_path / archive.file_path
  33. archive_file.parent.mkdir(parents=True, exist_ok=True)
  34. archive_file.write_bytes(b"3mf-data")
  35. with (
  36. patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
  37. patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
  38. patch(
  39. "backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
  40. new=AsyncMock(return_value={"dispatch_job_id": 15, "dispatch_position": 1}),
  41. ) as mock_dispatch,
  42. ):
  43. response = await async_client.post(
  44. f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
  45. json={"plate_id": 2},
  46. )
  47. assert response.status_code == 200
  48. data = response.json()
  49. assert data["status"] == "dispatched"
  50. assert data["dispatch_job_id"] == 15
  51. assert data["dispatch_position"] == 1
  52. assert data["filename"] == "widget.gcode.3mf"
  53. mock_dispatch.assert_awaited_once()
  54. kwargs = mock_dispatch.await_args.kwargs
  55. assert kwargs["archive_name"].endswith("• Plate 2")
  56. assert kwargs["options"]["plate_id"] == 2
  57. @pytest.mark.asyncio
  58. @pytest.mark.integration
  59. async def test_reprint_forwards_cost_center_id(
  60. self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
  61. ):
  62. """Reprint endpoint forwards cost_center_id into the dispatch options."""
  63. await enable_billing(db_session)
  64. printer = await printer_factory()
  65. archive = await archive_factory(
  66. printer.id,
  67. filename="widget-cost.gcode.3mf",
  68. file_path="archives/test/widget-cost.gcode.3mf",
  69. )
  70. archive_file = tmp_path / archive.file_path
  71. archive_file.parent.mkdir(parents=True, exist_ok=True)
  72. archive_file.write_bytes(b"3mf-data")
  73. cost_center = CostCenter(name="Reprint Cost Center", is_active=True, is_private=False, monthly_budget=10.0)
  74. db_session.add(cost_center)
  75. await db_session.commit()
  76. await db_session.refresh(cost_center)
  77. with (
  78. patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
  79. patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
  80. patch(
  81. "backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
  82. new=AsyncMock(return_value={"dispatch_job_id": 16, "dispatch_position": 1}),
  83. ) as mock_dispatch,
  84. ):
  85. response = await async_client.post(
  86. f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
  87. json={"plate_id": 2, "cost_center_id": cost_center.id, "estimated_cost": 1.25},
  88. )
  89. assert response.status_code == 200
  90. mock_dispatch.assert_awaited_once()
  91. assert mock_dispatch.await_args.kwargs["options"]["cost_center_id"] == cost_center.id
  92. assert mock_dispatch.await_args.kwargs["options"]["estimated_cost"] == 1.25
  93. @pytest.mark.asyncio
  94. @pytest.mark.integration
  95. async def test_reprint_requires_cost_center_when_billing_enabled(
  96. self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
  97. ):
  98. await enable_billing(db_session)
  99. printer = await printer_factory()
  100. archive = await archive_factory(
  101. printer.id,
  102. filename="widget-missing-cost-center.gcode.3mf",
  103. file_path="archives/test/widget-missing-cost-center.gcode.3mf",
  104. )
  105. archive_file = tmp_path / archive.file_path
  106. archive_file.parent.mkdir(parents=True, exist_ok=True)
  107. archive_file.write_bytes(b"3mf-data")
  108. with (
  109. patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
  110. patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
  111. patch(
  112. "backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
  113. new=AsyncMock(return_value={"dispatch_job_id": 17, "dispatch_position": 1}),
  114. ) as mock_dispatch,
  115. ):
  116. response = await async_client.post(
  117. f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
  118. json={"plate_id": 2, "estimated_cost": 1.25},
  119. )
  120. assert response.status_code == 400
  121. assert "Cost center is required" in response.json()["detail"]
  122. mock_dispatch.assert_not_awaited()
  123. @pytest.mark.asyncio
  124. @pytest.mark.integration
  125. async def test_reprint_returns_409_when_enqueue_rejected(
  126. self, async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path
  127. ):
  128. """Reprint endpoint maps enqueue rejection to HTTP 409."""
  129. printer = await printer_factory()
  130. archive = await archive_factory(
  131. printer.id,
  132. filename="widget2.gcode.3mf",
  133. file_path="archives/test/widget2.gcode.3mf",
  134. )
  135. archive_file = tmp_path / archive.file_path
  136. archive_file.parent.mkdir(parents=True, exist_ok=True)
  137. archive_file.write_bytes(b"3mf-data")
  138. with (
  139. patch("backend.app.api.routes.archives.settings.base_dir", tmp_path),
  140. patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
  141. patch(
  142. "backend.app.services.background_dispatch.background_dispatch.dispatch_reprint_archive",
  143. new=AsyncMock(side_effect=DispatchEnqueueRejected("already has a background dispatch")),
  144. ),
  145. ):
  146. response = await async_client.post(
  147. f"/api/v1/archives/{archive.id}/reprint?printer_id={printer.id}",
  148. json={"plate_id": 1},
  149. )
  150. assert response.status_code == 409
  151. assert "already has a background dispatch" in response.json()["detail"]
  152. class TestBackgroundDispatchLibraryAPI:
  153. """Tests for library print dispatch endpoint."""
  154. @pytest.fixture
  155. async def library_file_factory(self, db_session):
  156. """Factory to create library files."""
  157. async def _create_file(**kwargs):
  158. from backend.app.models.library import LibraryFile
  159. defaults = {
  160. "filename": "library_part.gcode.3mf",
  161. "file_path": "library/files/library_part.gcode.3mf",
  162. "file_type": "gcode",
  163. "file_size": 1024,
  164. }
  165. defaults.update(kwargs)
  166. lib_file = LibraryFile(**defaults)
  167. db_session.add(lib_file)
  168. await db_session.commit()
  169. await db_session.refresh(lib_file)
  170. return lib_file
  171. return _create_file
  172. @pytest.mark.asyncio
  173. @pytest.mark.integration
  174. async def test_library_print_returns_dispatched_payload(
  175. self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
  176. ):
  177. """Library print endpoint returns dispatch job metadata."""
  178. printer = await printer_factory()
  179. lib_file = await library_file_factory()
  180. disk_path = tmp_path / lib_file.file_path
  181. disk_path.parent.mkdir(parents=True, exist_ok=True)
  182. disk_path.write_bytes(b"library data")
  183. with (
  184. patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
  185. patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
  186. patch(
  187. "backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
  188. new=AsyncMock(return_value={"dispatch_job_id": 21, "dispatch_position": 2}),
  189. ) as mock_dispatch,
  190. ):
  191. response = await async_client.post(
  192. f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
  193. json={"plate_id": 4},
  194. )
  195. assert response.status_code == 200
  196. data = response.json()
  197. assert data["status"] == "dispatched"
  198. assert data["dispatch_job_id"] == 21
  199. assert data["dispatch_position"] == 2
  200. assert data["archive_id"] is None
  201. mock_dispatch.assert_awaited_once()
  202. kwargs = mock_dispatch.await_args.kwargs
  203. assert kwargs["filename"].endswith("• Plate 4")
  204. assert kwargs["options"]["plate_id"] == 4
  205. @pytest.mark.asyncio
  206. @pytest.mark.integration
  207. async def test_library_print_forwards_cost_center_id(
  208. self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
  209. ):
  210. """Library print endpoint forwards cost_center_id into the dispatch options."""
  211. await enable_billing(db_session)
  212. printer = await printer_factory()
  213. lib_file = await library_file_factory(filename="library-cost.gcode.3mf")
  214. disk_path = tmp_path / lib_file.file_path
  215. disk_path.parent.mkdir(parents=True, exist_ok=True)
  216. disk_path.write_bytes(b"library data")
  217. cost_center = CostCenter(name="Library Cost Center", is_active=True, is_private=False, monthly_budget=10.0)
  218. db_session.add(cost_center)
  219. await db_session.commit()
  220. await db_session.refresh(cost_center)
  221. with (
  222. patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
  223. patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
  224. patch(
  225. "backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
  226. new=AsyncMock(return_value={"dispatch_job_id": 22, "dispatch_position": 2}),
  227. ) as mock_dispatch,
  228. ):
  229. response = await async_client.post(
  230. f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
  231. json={"plate_id": 4, "cost_center_id": cost_center.id, "estimated_cost": 1.5},
  232. )
  233. assert response.status_code == 200
  234. mock_dispatch.assert_awaited_once()
  235. assert mock_dispatch.await_args.kwargs["options"]["cost_center_id"] == cost_center.id
  236. assert mock_dispatch.await_args.kwargs["options"]["estimated_cost"] == 1.5
  237. @pytest.mark.asyncio
  238. @pytest.mark.integration
  239. async def test_library_print_returns_409_when_enqueue_rejected(
  240. self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
  241. ):
  242. """Library print endpoint maps enqueue rejection to HTTP 409."""
  243. printer = await printer_factory()
  244. lib_file = await library_file_factory(filename="another_part.gcode")
  245. disk_path = tmp_path / lib_file.file_path
  246. disk_path.parent.mkdir(parents=True, exist_ok=True)
  247. disk_path.write_bytes(b"library data")
  248. with (
  249. patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
  250. patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
  251. patch(
  252. "backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
  253. new=AsyncMock(side_effect=DispatchEnqueueRejected("queue conflict")),
  254. ),
  255. ):
  256. response = await async_client.post(
  257. f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
  258. json={"plate_id": 1},
  259. )
  260. assert response.status_code == 409
  261. assert "queue conflict" in response.json()["detail"]
  262. @pytest.mark.asyncio
  263. @pytest.mark.integration
  264. async def test_library_print_cleanup_flag_defaults_false(
  265. self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
  266. ):
  267. """Absent cleanup_library_after_dispatch in the request body ⇒ False reaches the dispatcher.
  268. Guards the File Manager / Project Detail paths from accidental deletion."""
  269. printer = await printer_factory()
  270. lib_file = await library_file_factory(filename="filemgr_part.gcode.3mf")
  271. disk_path = tmp_path / lib_file.file_path
  272. disk_path.parent.mkdir(parents=True, exist_ok=True)
  273. disk_path.write_bytes(b"library data")
  274. with (
  275. patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
  276. patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
  277. patch(
  278. "backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
  279. new=AsyncMock(return_value={"dispatch_job_id": 30, "dispatch_position": 1}),
  280. ) as mock_dispatch,
  281. ):
  282. response = await async_client.post(
  283. f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
  284. json={},
  285. )
  286. assert response.status_code == 200
  287. mock_dispatch.assert_awaited_once()
  288. assert mock_dispatch.await_args.kwargs["cleanup_library_after_dispatch"] is False
  289. # cleanup flag must never leak into the print-option dict forwarded to MQTT
  290. assert "cleanup_library_after_dispatch" not in mock_dispatch.await_args.kwargs["options"]
  291. @pytest.mark.asyncio
  292. @pytest.mark.integration
  293. async def test_library_print_forwards_cleanup_flag_true(
  294. self, async_client: AsyncClient, library_file_factory, printer_factory, db_session, tmp_path
  295. ):
  296. """Direct-Print flow sends cleanup_library_after_dispatch=True, which must reach the dispatcher."""
  297. printer = await printer_factory()
  298. lib_file = await library_file_factory(filename="transient_part.gcode.3mf")
  299. disk_path = tmp_path / lib_file.file_path
  300. disk_path.parent.mkdir(parents=True, exist_ok=True)
  301. disk_path.write_bytes(b"library data")
  302. with (
  303. patch("backend.app.api.routes.library.app_settings.base_dir", tmp_path),
  304. patch("backend.app.services.printer_manager.printer_manager.is_connected", return_value=True),
  305. patch(
  306. "backend.app.services.background_dispatch.background_dispatch.dispatch_print_library_file",
  307. new=AsyncMock(return_value={"dispatch_job_id": 31, "dispatch_position": 1}),
  308. ) as mock_dispatch,
  309. ):
  310. response = await async_client.post(
  311. f"/api/v1/library/files/{lib_file.id}/print?printer_id={printer.id}",
  312. json={"cleanup_library_after_dispatch": True},
  313. )
  314. assert response.status_code == 200
  315. mock_dispatch.assert_awaited_once()
  316. assert mock_dispatch.await_args.kwargs["cleanup_library_after_dispatch"] is True
  317. class TestBackgroundDispatchCancelAPI:
  318. """Tests for /background-dispatch cancel endpoint."""
  319. @pytest.mark.asyncio
  320. @pytest.mark.integration
  321. async def test_cancel_job_returns_cancelled(self, async_client: AsyncClient):
  322. """Cancel endpoint returns cancelled for queued job."""
  323. with patch(
  324. "backend.app.services.background_dispatch.background_dispatch.cancel_job",
  325. new=AsyncMock(
  326. return_value={
  327. "cancelled": True,
  328. "pending": False,
  329. "job_id": 9,
  330. "source_name": "cube.gcode.3mf",
  331. "printer_id": 1,
  332. "printer_name": "Printer A",
  333. }
  334. ),
  335. ):
  336. response = await async_client.delete("/api/v1/background-dispatch/9")
  337. assert response.status_code == 200
  338. data = response.json()
  339. assert data["status"] == "cancelled"
  340. assert data["job_id"] == 9
  341. @pytest.mark.asyncio
  342. @pytest.mark.integration
  343. async def test_cancel_job_returns_cancelling_for_active_job(self, async_client: AsyncClient):
  344. """Cancel endpoint returns cancelling while active upload is being interrupted."""
  345. with patch(
  346. "backend.app.services.background_dispatch.background_dispatch.cancel_job",
  347. new=AsyncMock(
  348. return_value={
  349. "cancelled": True,
  350. "pending": True,
  351. "job_id": 10,
  352. "source_name": "cube.gcode.3mf",
  353. "printer_id": 1,
  354. "printer_name": "Printer A",
  355. }
  356. ),
  357. ):
  358. response = await async_client.delete("/api/v1/background-dispatch/10")
  359. assert response.status_code == 200
  360. assert response.json()["status"] == "cancelling"
  361. @pytest.mark.asyncio
  362. @pytest.mark.integration
  363. async def test_cancel_job_returns_404_when_not_found(self, async_client: AsyncClient):
  364. """Cancel endpoint returns 404 for unknown job id."""
  365. with patch(
  366. "backend.app.services.background_dispatch.background_dispatch.cancel_job",
  367. new=AsyncMock(return_value={"cancelled": False, "reason": "not_found"}),
  368. ):
  369. response = await async_client.delete("/api/v1/background-dispatch/999")
  370. assert response.status_code == 404
  371. assert response.json()["detail"] == "Dispatch job not found"
  372. class TestBackgroundDispatchBudgetReservations:
  373. """Tests for persisted budget reservations on background dispatch enqueue."""
  374. @pytest.mark.asyncio
  375. @pytest.mark.integration
  376. async def test_background_dispatch_persists_budget_reservation_and_blocks_oversubscribe(
  377. self, printer_factory, db_session, test_engine
  378. ):
  379. await enable_billing(db_session)
  380. printer_one = await printer_factory()
  381. printer_two = await printer_factory()
  382. cost_center = CostCenter(name="Dispatch Reservation CC", is_active=True, is_private=False, monthly_budget=2.0)
  383. db_session.add(cost_center)
  384. await db_session.commit()
  385. await db_session.refresh(cost_center)
  386. service = BackgroundDispatchService()
  387. test_async_session = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  388. with (
  389. patch("backend.app.services.background_dispatch.async_session", test_async_session),
  390. patch("backend.app.services.printer_manager.printer_manager.get_status", return_value=None),
  391. ):
  392. first = await service.dispatch_reprint_archive(
  393. archive_id=100,
  394. archive_name="first.gcode.3mf",
  395. printer_id=printer_one.id,
  396. printer_name=printer_one.name,
  397. options={"cost_center_id": cost_center.id, "estimated_cost": 1.5},
  398. requested_by_user_id=None,
  399. requested_by_username=None,
  400. )
  401. with pytest.raises(HTTPException) as exc:
  402. await service.dispatch_reprint_archive(
  403. archive_id=101,
  404. archive_name="second.gcode.3mf",
  405. printer_id=printer_two.id,
  406. printer_name=printer_two.name,
  407. options={"cost_center_id": cost_center.id, "estimated_cost": 1.0},
  408. requested_by_user_id=None,
  409. requested_by_username=None,
  410. )
  411. assert exc.value.status_code == 400
  412. assert "exceeds available cost center budget" in exc.value.detail
  413. reservation = await db_session.scalar(
  414. select(BudgetReservation).where(
  415. BudgetReservation.source_type == "background_dispatch",
  416. BudgetReservation.source_id == first["dispatch_job_id"],
  417. )
  418. )
  419. assert reservation is not None
  420. assert reservation.status == "active"
  421. assert reservation.cost_center_id == cost_center.id
  422. assert reservation.amount == 1.5
  423. assert reservation.print_archive_id == 100