test_scheduler_budget_reservation.py 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. """Budget-reservation lifecycle through the unified queue scheduler."""
  2. from contextlib import ExitStack
  3. from pathlib import Path
  4. from types import SimpleNamespace
  5. from unittest.mock import AsyncMock, MagicMock, patch
  6. import pytest
  7. from fastapi import HTTPException
  8. from sqlalchemy import func, select
  9. from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
  10. import backend.app.models # noqa: F401 - populate Base.metadata
  11. import backend.app.services.print_scheduler as scheduler_module
  12. from backend.app.core.database import Base
  13. from backend.app.models.archive import PrintArchive
  14. from backend.app.models.finance import BudgetReservation, CostCenter
  15. from backend.app.models.print_queue import PrintQueueItem
  16. from backend.app.models.printer import Printer
  17. from backend.app.models.settings import Settings
  18. from backend.app.models.user import User
  19. from backend.app.services.finance_budget import validate_print_budget
  20. from backend.app.services.print_scheduler import PrintScheduler
  21. pytestmark = pytest.mark.integration
  22. @pytest.fixture
  23. async def billing_dispatch_case(tmp_path):
  24. engine = create_async_engine("sqlite+aiosqlite:///:memory:")
  25. async with engine.begin() as conn:
  26. await conn.run_sync(Base.metadata.create_all)
  27. session_maker = async_sessionmaker(engine, expire_on_commit=False)
  28. base_dir = tmp_path / "billing-dispatch"
  29. archive_rel = Path("archives") / "job.3mf"
  30. archive_abs = base_dir / archive_rel
  31. archive_abs.parent.mkdir(parents=True)
  32. archive_abs.write_bytes(b"archive payload")
  33. async with session_maker() as db:
  34. db.add(Settings(key="billing_enabled", value="true"))
  35. user = User(username="scheduler-budget-admin", role="admin", is_active=True)
  36. cost_center = CostCenter(name="Scheduler Budget", is_active=True, monthly_budget=10.0)
  37. printer = Printer(
  38. name="Budget Printer",
  39. serial_number="BUDGET-SERIAL",
  40. ip_address="127.0.0.1",
  41. access_code="access-code",
  42. model="X1C",
  43. )
  44. db.add_all([user, cost_center, printer])
  45. await db.flush()
  46. archive = PrintArchive(
  47. printer_id=printer.id,
  48. filename="job.3mf",
  49. file_path=str(archive_rel),
  50. file_size=archive_abs.stat().st_size,
  51. status="completed",
  52. cost=4.0,
  53. created_by_id=user.id,
  54. cost_center_id=cost_center.id,
  55. )
  56. db.add(archive)
  57. await db.flush()
  58. item = PrintQueueItem(
  59. printer_id=printer.id,
  60. archive_id=archive.id,
  61. cost_center_id=cost_center.id,
  62. estimated_cost=4.0,
  63. created_by_id=user.id,
  64. status="pending",
  65. )
  66. db.add(item)
  67. await db.commit()
  68. ids = SimpleNamespace(
  69. user_id=user.id,
  70. cost_center_id=cost_center.id,
  71. printer_id=printer.id,
  72. archive_id=archive.id,
  73. item_id=item.id,
  74. )
  75. try:
  76. yield SimpleNamespace(session_maker=session_maker, base_dir=base_dir, ids=ids)
  77. finally:
  78. await engine.dispose()
  79. async def _dispatch(ctx, *, uploaded: bool = True, cancel_during_upload: bool = False):
  80. scheduler = PrintScheduler()
  81. start_print = MagicMock(return_value=True)
  82. async def upload(*_args, **_kwargs):
  83. if cancel_during_upload:
  84. async with ctx.session_maker() as other_db:
  85. item = await other_db.get(PrintQueueItem, ctx.ids.item_id)
  86. item.status = "cancelled"
  87. await other_db.commit()
  88. return uploaded
  89. patches = [
  90. patch.object(scheduler_module, "async_session", ctx.session_maker),
  91. patch.object(scheduler_module.settings, "base_dir", ctx.base_dir),
  92. patch("backend.app.services.print_scheduler.printer_manager.is_connected", MagicMock(return_value=True)),
  93. patch("backend.app.services.print_scheduler.printer_manager.get_status", MagicMock(return_value=None)),
  94. patch("backend.app.services.print_scheduler.printer_manager.start_print", start_print),
  95. patch("backend.app.services.print_scheduler.printer_manager.set_awaiting_plate_clear", MagicMock()),
  96. patch(
  97. "backend.app.services.print_scheduler.get_ftp_retry_settings",
  98. AsyncMock(return_value=(False, 0, 0, 1.0)),
  99. ),
  100. patch("backend.app.services.print_scheduler.delete_file_async", AsyncMock(return_value=True)),
  101. patch("backend.app.services.print_scheduler.upload_file_async", upload),
  102. patch("backend.app.services.print_scheduler.cache_3mf_download", MagicMock()),
  103. patch("backend.app.services.print_scheduler.spawn_background_task", MagicMock()),
  104. patch("backend.app.services.notification_service.notification_service.on_queue_job_started", AsyncMock()),
  105. patch("backend.app.services.notification_service.notification_service.on_queue_job_failed", AsyncMock()),
  106. patch("backend.app.services.mqtt_relay.mqtt_relay.on_queue_job_started", AsyncMock()),
  107. patch.object(scheduler, "_propagate_owner_to_printer_manager", AsyncMock()),
  108. patch.object(scheduler, "_power_off_if_needed", AsyncMock()),
  109. patch.object(scheduler, "_preheat_and_soak", AsyncMock()),
  110. ]
  111. with ExitStack() as stack:
  112. for patcher in patches:
  113. stack.enter_context(patcher)
  114. await scheduler._dispatch_one(ctx.ids.item_id)
  115. return start_print
  116. async def _reservation(ctx):
  117. async with ctx.session_maker() as db:
  118. return await db.scalar(
  119. select(BudgetReservation).where(
  120. BudgetReservation.source_type == "print_queue",
  121. BudgetReservation.source_id == ctx.ids.item_id,
  122. )
  123. )
  124. @pytest.mark.asyncio
  125. async def test_successful_scheduler_dispatch_keeps_one_active_reservation(billing_dispatch_case):
  126. start_print = await _dispatch(billing_dispatch_case)
  127. reservation = await _reservation(billing_dispatch_case)
  128. assert reservation is not None
  129. assert reservation.status == "active"
  130. assert reservation.amount == 4.0
  131. assert reservation.print_archive_id == billing_dispatch_case.ids.archive_id
  132. start_print.assert_called_once()
  133. # The printing queue row and its persisted reservation represent the same
  134. # €4 hold. A second €6 job must fit exactly; €6.01 must not.
  135. async with billing_dispatch_case.session_maker() as db:
  136. user = await db.get(User, billing_dispatch_case.ids.user_id)
  137. second = PrintQueueItem(
  138. printer_id=billing_dispatch_case.ids.printer_id,
  139. archive_id=billing_dispatch_case.ids.archive_id,
  140. cost_center_id=billing_dispatch_case.ids.cost_center_id,
  141. estimated_cost=6.0,
  142. created_by_id=user.id,
  143. status="pending",
  144. )
  145. db.add(second)
  146. await db.commit()
  147. await validate_print_budget(
  148. db,
  149. cost_center_id=second.cost_center_id,
  150. estimated_cost=6.0,
  151. current_user=user,
  152. exclude_queue_item_id=second.id,
  153. )
  154. with pytest.raises(HTTPException, match="exceeds available"):
  155. await validate_print_budget(
  156. db,
  157. cost_center_id=second.cost_center_id,
  158. estimated_cost=6.01,
  159. current_user=user,
  160. exclude_queue_item_id=second.id,
  161. )
  162. @pytest.mark.asyncio
  163. async def test_upload_failure_releases_scheduler_reservation(billing_dispatch_case):
  164. start_print = await _dispatch(billing_dispatch_case, uploaded=False)
  165. reservation = await _reservation(billing_dispatch_case)
  166. assert reservation is not None
  167. assert reservation.status == "released"
  168. assert reservation.released_at is not None
  169. start_print.assert_not_called()
  170. @pytest.mark.asyncio
  171. async def test_retried_dispatch_reuses_active_reservation(billing_dispatch_case):
  172. await _dispatch(billing_dispatch_case)
  173. # Simulate startup recovery after the process stopped with a persisted
  174. # reservation and the queue row was made dispatchable again.
  175. async with billing_dispatch_case.session_maker() as db:
  176. item = await db.get(PrintQueueItem, billing_dispatch_case.ids.item_id)
  177. item.status = "pending"
  178. item.started_at = None
  179. item.dispatching_at = None
  180. await db.commit()
  181. await _dispatch(billing_dispatch_case)
  182. async with billing_dispatch_case.session_maker() as db:
  183. reservations = (
  184. (
  185. await db.execute(
  186. select(BudgetReservation).where(
  187. BudgetReservation.source_type == "print_queue",
  188. BudgetReservation.source_id == billing_dispatch_case.ids.item_id,
  189. )
  190. )
  191. )
  192. .scalars()
  193. .all()
  194. )
  195. assert len(reservations) == 1
  196. assert reservations[0].status == "active"
  197. @pytest.mark.asyncio
  198. async def test_cancel_during_upload_releases_scheduler_reservation(billing_dispatch_case):
  199. start_print = await _dispatch(billing_dispatch_case, cancel_during_upload=True)
  200. reservation = await _reservation(billing_dispatch_case)
  201. assert reservation is not None
  202. assert reservation.status == "released"
  203. assert reservation.released_at is not None
  204. start_print.assert_not_called()
  205. async with billing_dispatch_case.session_maker() as db:
  206. item = await db.get(PrintQueueItem, billing_dispatch_case.ids.item_id)
  207. active_count = await db.scalar(
  208. select(func.count()).select_from(BudgetReservation).where(BudgetReservation.status == "active")
  209. )
  210. assert item.status == "cancelled"
  211. assert active_count == 0