test_queue_creation_paths_c4.py 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681
  1. """Every path that creates queue jobs behaves like the print dialog (C4).
  2. Only ``POST /queue/`` announced a new job ("job added" notification and the
  3. MQTT relay event); File Manager's "Add to queue", the webhook, a virtual
  4. printer, a pipeline run and an order's "dispatch remaining" said nothing. The
  5. default print options (timelapse, bed levelling, ...) reached only the print
  6. dialog and the virtual printer. The copies an order dispatches lost their cost
  7. centre, so they were billed to none and never budget-checked. And a
  8. ``scheduled_time`` sent with a UTC offset lost the offset, so the job started
  9. hours late.
  10. """
  11. from datetime import datetime
  12. from pathlib import Path
  13. from unittest.mock import AsyncMock, patch
  14. from uuid import uuid4
  15. import pytest
  16. from httpx import AsyncClient
  17. from sqlalchemy import select
  18. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  19. from backend.app.core.auth import generate_api_key, get_password_hash
  20. from backend.app.core.config import settings as app_settings
  21. from backend.app.models.api_key import APIKey
  22. from backend.app.models.archive import PrintArchive
  23. from backend.app.models.finance import CostCenter, CostCenterMember
  24. from backend.app.models.group import Group
  25. from backend.app.models.print_queue import PrintQueueItem
  26. from backend.app.models.printer import Printer
  27. from backend.app.models.settings import Settings
  28. from backend.app.models.user import User
  29. from backend.app.services.mqtt_relay import mqtt_relay
  30. from backend.app.services.notification_service import notification_service
  31. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  32. async def _set(db_session, key: str, value: str) -> None:
  33. row = await db_session.scalar(select(Settings).where(Settings.key == key))
  34. if row is None:
  35. db_session.add(Settings(key=key, value=value))
  36. else:
  37. row.value = value
  38. await db_session.commit()
  39. async def _items(test_engine, ids=None) -> list[PrintQueueItem]:
  40. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  41. async with maker() as fresh:
  42. query = select(PrintQueueItem).order_by(PrintQueueItem.id)
  43. if ids is not None:
  44. query = query.where(PrintQueueItem.id.in_(ids))
  45. return list((await fresh.execute(query)).scalars().all())
  46. @pytest.fixture
  47. def announced():
  48. """The notification and relay calls, recorded."""
  49. notify = AsyncMock()
  50. relay = AsyncMock()
  51. with (
  52. patch.object(notification_service, "on_queue_job_added", notify),
  53. patch.object(mqtt_relay, "on_queue_job_added", relay),
  54. ):
  55. yield notify, relay
  56. @pytest.fixture
  57. async def library_files(db_session):
  58. from backend.app.models.library import LibraryFile
  59. made = []
  60. async def make(stem: str):
  61. name = f"{stem}_{uuid4().hex[:8]}.gcode.3mf"
  62. rel_path = f"archive/library/files/{name}"
  63. abs_path = Path(app_settings.base_dir) / rel_path
  64. abs_path.parent.mkdir(parents=True, exist_ok=True)
  65. abs_path.write_bytes(b"probe")
  66. made.append(abs_path)
  67. lib_file = LibraryFile(filename=name, file_path=rel_path, file_size=5, file_type="3mf")
  68. db_session.add(lib_file)
  69. await db_session.commit()
  70. await db_session.refresh(lib_file)
  71. return lib_file
  72. yield make
  73. for path in made:
  74. path.unlink(missing_ok=True)
  75. async def _printer(db_session, name="C4 Printer", model="X1C") -> Printer:
  76. printer = Printer(
  77. name=name,
  78. ip_address=f"192.168.44.{len(name)}",
  79. serial_number=f"C4{uuid4().hex[:12].upper()}",
  80. access_code="12345678",
  81. model=model,
  82. )
  83. db_session.add(printer)
  84. await db_session.commit()
  85. await db_session.refresh(printer)
  86. return printer
  87. async def _archive(db_session, filename="widget.gcode.3mf", **extra) -> PrintArchive:
  88. archive = PrintArchive(
  89. filename=filename,
  90. print_name="widget",
  91. file_path=f"/tmp/c4_{uuid4().hex}.3mf", # nosec B108
  92. file_size=1,
  93. content_hash=uuid4().hex,
  94. status="completed",
  95. **extra,
  96. )
  97. db_session.add(archive)
  98. await db_session.commit()
  99. await db_session.refresh(archive)
  100. return archive
  101. async def _webhook_key(db_session) -> dict[str, str]:
  102. full_key, key_hash, key_prefix = generate_api_key()
  103. db_session.add(
  104. APIKey(name="c4", key_hash=key_hash, key_prefix=key_prefix, user_id=None, can_queue=True, can_read_status=True)
  105. )
  106. await db_session.commit()
  107. return {"X-API-Key": full_key}
  108. class TestEveryPathAnnouncesItsJobs:
  109. async def test_a_file_manager_bulk_add_sends_one_summary(
  110. self, async_client: AsyncClient, db_session, library_files, announced
  111. ):
  112. notify, relay = announced
  113. printer = await _printer(db_session)
  114. files = [await library_files("bracket"), await library_files("hinge")]
  115. response = await async_client.post(
  116. "/api/v1/library/files/add-to-queue",
  117. json={"file_ids": [f.id for f in files], "printer_id": printer.id},
  118. )
  119. assert response.status_code == 200, response.text
  120. notify.assert_awaited_once()
  121. kwargs = notify.await_args.kwargs
  122. assert kwargs["job_name"].startswith("2 jobs (bracket_")
  123. assert "hinge_" in kwargs["job_name"]
  124. assert kwargs["target"] == printer.name
  125. assert kwargs["printer_id"] == printer.id
  126. # The relay is a machine event: one per job.
  127. assert sorted(c.kwargs["job_id"] for c in relay.await_args_list) == sorted(
  128. a["queue_item_id"] for a in response.json()["added"]
  129. )
  130. async def test_the_webhook_announces_its_job(self, async_client: AsyncClient, db_session, announced):
  131. notify, relay = announced
  132. printer = await _printer(db_session)
  133. archive = await _archive(db_session)
  134. response = await async_client.post(
  135. "/api/v1/webhook/queue/add",
  136. json={"printer_id": printer.id, "archive_id": archive.id},
  137. headers=await _webhook_key(db_session),
  138. )
  139. assert response.status_code == 200, response.text
  140. notify.assert_awaited_once()
  141. assert notify.await_args.kwargs["job_name"] == "widget"
  142. assert relay.await_args.kwargs["job_id"] == response.json()["id"]
  143. async def test_an_orders_dispatch_announces_the_copies(self, async_client: AsyncClient, db_session, announced):
  144. notify, _ = announced
  145. printer = await _printer(db_session)
  146. archive = await _archive(db_session)
  147. order = (
  148. await async_client.post(
  149. "/api/v1/queue/batches",
  150. json={"name": "o", "archive_id": archive.id, "plates": [{"plate_id": None, "quantity_target": 3}]},
  151. )
  152. ).json()
  153. first = await async_client.post(
  154. "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "batch_id": order["id"]}
  155. )
  156. assert first.status_code == 200, first.text
  157. notify.reset_mock()
  158. response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
  159. assert response.status_code == 200, response.text
  160. notify.assert_awaited_once()
  161. assert notify.await_args.kwargs["job_name"] == "widget ×2"
  162. async def test_a_failing_notification_does_not_fail_the_add(
  163. self, async_client: AsyncClient, db_session, library_files
  164. ):
  165. lib_file = await library_files("plain")
  166. with patch.object(notification_service, "on_queue_job_added", AsyncMock(side_effect=RuntimeError("smtp"))):
  167. response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
  168. assert response.status_code == 200
  169. assert len(response.json()["added"]) == 1
  170. async def test_the_print_dialog_still_announces_once(self, async_client: AsyncClient, db_session, announced):
  171. notify, relay = announced
  172. printer = await _printer(db_session)
  173. archive = await _archive(db_session)
  174. response = await async_client.post(
  175. "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 3}
  176. )
  177. assert response.status_code == 200, response.text
  178. notify.assert_awaited_once()
  179. assert notify.await_args.kwargs["job_name"] == "widget ×3"
  180. assert relay.await_count == 3
  181. class TestTheSummary:
  182. async def test_jobs_for_different_printers_are_announced_per_printer(self, db_session, test_engine, announced):
  183. """A provider scoped to one printer must not hear of another's jobs."""
  184. from backend.app.services.queue_announcements import announce_queue_jobs_added
  185. notify, _ = announced
  186. a, b = await _printer(db_session, "Alpha"), await _printer(db_session, "Bravo")
  187. archive = await _archive(db_session)
  188. items = [
  189. PrintQueueItem(printer_id=a.id, archive_id=archive.id, position=1),
  190. PrintQueueItem(printer_id=b.id, archive_id=archive.id, position=2),
  191. PrintQueueItem(printer_id=None, target_model="P1S", archive_id=archive.id, position=3),
  192. ]
  193. db_session.add_all(items)
  194. await db_session.commit()
  195. await announce_queue_jobs_added(db_session, [i.id for i in items])
  196. sent = sorted((c.kwargs["printer_id"] or 0, c.kwargs["target"]) for c in notify.await_args_list)
  197. assert sent == sorted([(a.id, "Alpha"), (b.id, "Bravo"), (0, "Any P1S")])
  198. async def test_many_different_files_are_named_briefly(self, db_session, announced):
  199. from backend.app.services.queue_announcements import announce_queue_jobs_added
  200. notify, _ = announced
  201. items = []
  202. for n in range(5):
  203. archive = await _archive(db_session, filename=f"part{n}.3mf")
  204. items.append(PrintQueueItem(printer_id=None, archive_id=archive.id, position=n))
  205. db_session.add_all(items)
  206. await db_session.commit()
  207. await announce_queue_jobs_added(db_session, [i.id for i in items])
  208. assert notify.await_args.kwargs["job_name"] == "5 jobs (part0, part1, part2, …)"
  209. assert notify.await_args.kwargs["target"] == "Unassigned"
  210. async def test_nothing_added_says_nothing(self, db_session, announced):
  211. from backend.app.services.queue_announcements import announce_queue_jobs_added
  212. notify, relay = announced
  213. await announce_queue_jobs_added(db_session, [])
  214. notify.assert_not_awaited()
  215. relay.assert_not_awaited()
  216. class TestTheDefaultPrintOptions:
  217. async def test_unset_they_are_the_settings_defaults(self, db_session):
  218. from backend.app.services.print_defaults import default_print_options
  219. assert await default_print_options(db_session) == {
  220. "bed_levelling": "auto",
  221. "flow_cali": "auto",
  222. "nozzle_offset_cali": "auto",
  223. "vibration_cali": True,
  224. "layer_inspect": False,
  225. "timelapse": False,
  226. }
  227. async def test_set_they_are_read_including_legacy_values(self, db_session):
  228. from backend.app.services.print_defaults import default_print_options
  229. for key, value in {
  230. "default_bed_levelling": "false", # legacy boolean row
  231. "default_flow_cali": "on",
  232. "default_nozzle_offset_cali": "off",
  233. "default_vibration_cali": "false",
  234. "default_layer_inspect": "true",
  235. "default_timelapse": "true",
  236. }.items():
  237. await _set(db_session, key, value)
  238. assert await default_print_options(db_session) == {
  239. "bed_levelling": "off",
  240. "flow_cali": "on",
  241. "nozzle_offset_cali": "off",
  242. "vibration_cali": False,
  243. "layer_inspect": True,
  244. "timelapse": True,
  245. }
  246. async def test_a_file_manager_add_uses_them(
  247. self, async_client: AsyncClient, db_session, test_engine, library_files
  248. ):
  249. await _set(db_session, "default_timelapse", "true")
  250. await _set(db_session, "default_bed_levelling", "off")
  251. lib_file = await library_files("defaults")
  252. response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
  253. assert response.status_code == 200, response.text
  254. (item,) = await _items(test_engine, [response.json()["added"][0]["queue_item_id"]])
  255. assert (item.timelapse, item.bed_levelling) == (True, "off")
  256. async def test_the_webhook_uses_them(self, async_client: AsyncClient, db_session, test_engine):
  257. await _set(db_session, "default_timelapse", "true")
  258. await _set(db_session, "default_layer_inspect", "true")
  259. printer = await _printer(db_session)
  260. archive = await _archive(db_session)
  261. response = await async_client.post(
  262. "/api/v1/webhook/queue/add",
  263. json={"printer_id": printer.id, "archive_id": archive.id},
  264. headers=await _webhook_key(db_session),
  265. )
  266. assert response.status_code == 200, response.text
  267. (item,) = await _items(test_engine, [response.json()["id"]])
  268. assert (item.timelapse, item.layer_inspect) == (True, True)
  269. class TestAPipelineRun:
  270. async def _run(self, db_session, test_engine, tmp_path):
  271. from backend.app.api.routes.pipeline_runs import _make_orchestration_callable
  272. from backend.app.core.printer_scope import ALL_PRINTERS
  273. from backend.app.models.library import LibraryFile
  274. from backend.app.models.pipeline_run import PipelineJob, PipelineRun
  275. from backend.app.models.slicer_pipeline import SlicerPipeline
  276. from backend.app.schemas.slicer import SliceResponse
  277. printer = await _printer(db_session, "Pipe")
  278. source = LibraryFile(filename="pipe.3mf", file_path="library/pipe.3mf", file_type="3mf", file_size=1)
  279. pipeline = SlicerPipeline(
  280. name="p",
  281. printer_preset_source="local",
  282. printer_preset_id="1",
  283. process_preset_source="local",
  284. process_preset_id="2",
  285. filament_presets_json='[{"source": "local", "id": "3"}]',
  286. target_kind="specific_printer",
  287. target_printer_id=printer.id,
  288. )
  289. db_session.add_all([source, pipeline])
  290. await db_session.commit()
  291. run = PipelineRun(pipeline_id=pipeline.id, source_library_file_id=source.id, copies=2, created_by=None)
  292. db_session.add(run)
  293. await db_session.commit()
  294. db_session.add_all([PipelineJob(pipeline_run_id=run.id, copy_index=i) for i in range(2)])
  295. await db_session.commit()
  296. model = tmp_path / "pipe.3mf"
  297. model.write_bytes(b"model")
  298. async def sliced(session, **kwargs):
  299. out = LibraryFile(filename="pipe.gcode.3mf", file_path="library/pipe.gcode.3mf", file_type="3mf")
  300. out.file_size = 1
  301. session.add(out)
  302. await session.flush()
  303. return SliceResponse(
  304. library_file_id=out.id, name="pipe", print_time_seconds=1, filament_used_g=1.0, filament_used_mm=1.0
  305. )
  306. orchestrate = _make_orchestration_callable(
  307. run_id=run.id,
  308. pipeline_id=pipeline.id,
  309. src_kind="library_file",
  310. src_id=source.id,
  311. src_filename="pipe.3mf",
  312. src_path=Path(model),
  313. creator_user_id=None,
  314. copies=2,
  315. printer_scope=ALL_PRINTERS,
  316. )
  317. with (
  318. patch(
  319. "backend.app.api.routes.pipeline_runs.async_session",
  320. async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False),
  321. ),
  322. patch("backend.app.api.routes.library.slice_and_persist", new=sliced),
  323. patch("backend.app.api.routes.pipeline_runs._publish_run_event", new=AsyncMock()),
  324. ):
  325. await orchestrate(1)
  326. return await _items(test_engine)
  327. async def test_announces_its_copies_and_uses_the_defaults(self, db_session, test_engine, tmp_path, announced):
  328. notify, _ = announced
  329. await _set(db_session, "default_timelapse", "true")
  330. items = await self._run(db_session, test_engine, tmp_path)
  331. assert len(items) == 2
  332. assert all(item.timelapse for item in items)
  333. notify.assert_awaited_once()
  334. assert notify.await_args.kwargs["job_name"] == "pipe ×2"
  335. class TestAVirtualPrinter:
  336. async def test_announces_what_it_queued(self, tmp_path):
  337. from backend.tests.unit.services.test_vp_queue_intake_d2 import _enqueue, _session, _vp
  338. added: list = []
  339. factory, _ = _session(added)
  340. inst = _vp(tmp_path, factory, target_printer_id=7)
  341. announce = AsyncMock()
  342. await _enqueue(inst, tmp_path, {"timelapse": True}, announce=announce)
  343. announce.assert_awaited_once()
  344. assert announce.await_args.args[1] == [item.id for item in added]
  345. async def test_where_the_slicer_says_nothing_it_uses_the_defaults(self, tmp_path):
  346. """Read from the same helper as every other path now."""
  347. from backend.tests.unit.services.test_vp_queue_intake_d2 import _enqueue, _session, _vp
  348. added: list = []
  349. factory, _ = _session(added)
  350. inst = _vp(tmp_path, factory, target_printer_id=7)
  351. await _enqueue(
  352. inst,
  353. tmp_path,
  354. {"flow_cali": True},
  355. {"default_timelapse": "true", "default_layer_inspect": "true", "default_vibration_cali": "false"},
  356. )
  357. assert (added[0].timelapse, added[0].layer_inspect, added[0].vibration_cali) == (True, True, False)
  358. class TestAnOrdersCopiesKeepTheirCostCentre:
  359. @pytest.fixture
  360. async def billed(self, db_session):
  361. await _set(db_session, "auth_enabled", "true")
  362. await _set(db_session, "advanced_auth_enabled", "false")
  363. group = Group(
  364. name="c4-dispatchers",
  365. description="t",
  366. permissions=[
  367. "queue:create",
  368. "queue:read_all",
  369. "queue:update_all",
  370. "archives:read_all",
  371. "archives:reprint_all",
  372. ],
  373. is_system=False,
  374. )
  375. db_session.add(group)
  376. await db_session.flush()
  377. admin = User(username="c4admin", password_hash=get_password_hash("Pass1word!"), role="admin", is_active=True)
  378. clerk = User(username="c4clerk", password_hash=get_password_hash("Pass1word!"), is_active=True, groups=[group])
  379. center = CostCenter(name="c4-center", is_active=True, is_private=False)
  380. db_session.add_all([admin, clerk, center])
  381. await db_session.commit()
  382. printer = await _printer(db_session)
  383. archive = await _archive(db_session, cost=1.25, filament_used_grams=50.0)
  384. await _set(db_session, "billing_enabled", "true")
  385. return {"admin": admin, "clerk": clerk, "center": center, "printer": printer, "archive": archive}
  386. async def _login(self, client, username):
  387. response = await client.post("/api/v1/auth/login", json={"username": username, "password": "Pass1word!"})
  388. assert response.status_code == 200, response.text
  389. return {"Authorization": f"Bearer {response.json()['access_token']}"}
  390. async def _order(self, client, headers, world):
  391. order = await client.post(
  392. "/api/v1/queue/batches",
  393. json={"name": "o", "archive_id": world["archive"].id, "plates": [{"plate_id": None, "quantity_target": 3}]},
  394. headers=headers,
  395. )
  396. assert order.status_code == 200, order.text
  397. first = await client.post(
  398. "/api/v1/queue/",
  399. json={
  400. "printer_id": world["printer"].id,
  401. "archive_id": world["archive"].id,
  402. "batch_id": order.json()["id"],
  403. "cost_center_id": world["center"].id,
  404. },
  405. headers=headers,
  406. )
  407. assert first.status_code == 200, first.text
  408. return order.json()["id"]
  409. async def test_the_copies_are_billed_to_it(self, async_client: AsyncClient, test_engine, billed):
  410. admin = await self._login(async_client, "c4admin")
  411. batch_id = await self._order(async_client, admin, billed)
  412. response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={}, headers=admin)
  413. assert response.status_code == 200, response.text
  414. items = [i for i in await _items(test_engine) if i.batch_id == batch_id]
  415. assert len(items) == 3
  416. assert {i.cost_center_id for i in items} == {billed["center"].id}
  417. async def test_a_dispatcher_who_may_not_use_it_is_refused(
  418. self, async_client: AsyncClient, db_session, test_engine, billed
  419. ):
  420. admin = await self._login(async_client, "c4admin")
  421. batch_id = await self._order(async_client, admin, billed)
  422. clerk = await self._login(async_client, "c4clerk")
  423. response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={}, headers=clerk)
  424. assert response.status_code == 403, response.text
  425. assert len([i for i in await _items(test_engine) if i.batch_id == batch_id]) == 1
  426. db_session.add(CostCenterMember(cost_center_id=billed["center"].id, user_id=billed["clerk"].id, can_print=True))
  427. await db_session.commit()
  428. response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={}, headers=clerk)
  429. assert response.status_code == 200, response.text
  430. async def test_the_budget_counts_every_copy(self, async_client: AsyncClient, db_session, test_engine, billed):
  431. center = await db_session.get(CostCenter, billed["center"].id)
  432. admin = await self._login(async_client, "c4admin")
  433. batch_id = await self._order(async_client, admin, billed)
  434. (template,) = [i for i in await _items(test_engine) if i.batch_id == batch_id]
  435. # Room for this one and one more, not for the two copies still owed.
  436. center.total_budget = 2 * template.estimated_cost + 0.01
  437. await db_session.commit()
  438. response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={}, headers=admin)
  439. assert response.status_code == 400, response.text
  440. assert "budget" in response.json()["detail"]
  441. async def test_copies_without_one_are_refused_while_billing_is_on(
  442. self, async_client: AsyncClient, db_session, test_engine, billed
  443. ):
  444. """Queued before billing was switched on: refused here, as POST /queue/
  445. would, rather than failing later at the start."""
  446. admin = await self._login(async_client, "c4admin")
  447. batch_id = await self._order(async_client, admin, billed)
  448. (template,) = [i for i in await _items(test_engine) if i.batch_id == batch_id]
  449. row = await db_session.get(PrintQueueItem, template.id)
  450. row.cost_center_id = None
  451. await db_session.commit()
  452. response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={}, headers=admin)
  453. assert response.status_code == 400, response.text
  454. assert "Cost center is required" in response.json()["detail"]
  455. async def test_a_budget_that_holds_every_copy_dispatches(
  456. self, async_client: AsyncClient, db_session, test_engine, billed
  457. ):
  458. """The copies are not counted against themselves."""
  459. center = await db_session.get(CostCenter, billed["center"].id)
  460. admin = await self._login(async_client, "c4admin")
  461. batch_id = await self._order(async_client, admin, billed)
  462. (template,) = [i for i in await _items(test_engine) if i.batch_id == batch_id]
  463. center.total_budget = 3 * template.estimated_cost + 0.01
  464. await db_session.commit()
  465. response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={}, headers=admin)
  466. assert response.status_code == 200, response.text
  467. copies = [i for i in await _items(test_engine) if i.batch_id == batch_id and i.id != template.id]
  468. assert [c.estimated_cost for c in copies] == [template.estimated_cost] * 2
  469. class TestAScheduledTimeWithAnOffset:
  470. async def test_the_queue_stores_it_in_utc(self, async_client: AsyncClient, db_session, test_engine):
  471. printer = await _printer(db_session)
  472. archive = await _archive(db_session)
  473. response = await async_client.post(
  474. "/api/v1/queue/",
  475. json={"printer_id": printer.id, "archive_id": archive.id, "scheduled_time": "2026-10-15T10:00:00+02:00"},
  476. )
  477. assert response.status_code == 200, response.text
  478. (item,) = await _items(test_engine, [response.json()["id"]])
  479. assert item.scheduled_time == datetime(2026, 10, 15, 8, 0)
  480. assert response.json()["scheduled_time"] == "2026-10-15T08:00:00Z"
  481. async def test_an_edit_stores_it_in_utc(self, async_client: AsyncClient, db_session, test_engine):
  482. printer = await _printer(db_session)
  483. archive = await _archive(db_session)
  484. created = await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id})
  485. response = await async_client.patch(
  486. f"/api/v1/queue/{created.json()['id']}", json={"scheduled_time": "2026-10-15T10:00:00-05:00"}
  487. )
  488. assert response.status_code == 200, response.text
  489. (item,) = await _items(test_engine, [created.json()["id"]])
  490. assert item.scheduled_time == datetime(2026, 10, 15, 15, 0)
  491. async def test_a_bulk_edit_stores_it_in_utc(self, async_client: AsyncClient, db_session, test_engine):
  492. printer = await _printer(db_session)
  493. archive = await _archive(db_session)
  494. created = await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id})
  495. response = await async_client.patch(
  496. "/api/v1/queue/bulk",
  497. json={"item_ids": [created.json()["id"]], "scheduled_time": "2026-10-15T10:00:00+02:00"},
  498. )
  499. assert response.status_code == 200, response.text
  500. (item,) = await _items(test_engine, [created.json()["id"]])
  501. assert item.scheduled_time == datetime(2026, 10, 15, 8, 0)
  502. async def test_the_webhook_stores_it_in_utc(self, async_client: AsyncClient, db_session, test_engine):
  503. printer = await _printer(db_session)
  504. archive = await _archive(db_session)
  505. response = await async_client.post(
  506. "/api/v1/webhook/queue/add",
  507. json={"printer_id": printer.id, "archive_id": archive.id, "scheduled_time": "2026-10-15T10:00:00+02:00"},
  508. headers=await _webhook_key(db_session),
  509. )
  510. assert response.status_code == 200, response.text
  511. (item,) = await _items(test_engine, [response.json()["id"]])
  512. assert item.scheduled_time == datetime(2026, 10, 15, 8, 0)
  513. async def test_the_postgres_bind_hook_converts_rather_than_drops_the_offset():
  514. """The backstop for any other aware value: naive columns hold UTC."""
  515. from datetime import timedelta, timezone
  516. from backend.app.core import database
  517. with patch.object(database, "is_sqlite", return_value=False):
  518. engine = database._create_engine("postgresql+asyncpg://u:p@127.0.0.1:1/never")
  519. try:
  520. (hook,) = [
  521. fn for fn in engine.sync_engine.dispatch.before_cursor_execute if "strip" in getattr(fn, "__name__", "")
  522. ]
  523. aware = datetime(2026, 10, 15, 10, 0, tzinfo=timezone(timedelta(hours=2)))
  524. _, params = hook(None, None, "", {"t": aware, "rows": [(aware,)]}, None, False)
  525. assert params == {"t": datetime(2026, 10, 15, 8, 0), "rows": [(datetime(2026, 10, 15, 8, 0),)]}
  526. finally:
  527. await engine.dispose()
  528. async def test_a_time_at_the_edge_of_the_calendar_is_refused_not_a_crash(async_client: AsyncClient, db_session):
  529. printer = await _printer(db_session)
  530. archive = await _archive(db_session)
  531. queued = await async_client.post(
  532. "/api/v1/queue/",
  533. json={"printer_id": printer.id, "archive_id": archive.id, "scheduled_time": "0001-01-01T00:00:00+05:00"},
  534. )
  535. webhook = await async_client.post(
  536. "/api/v1/webhook/queue/add",
  537. json={"printer_id": printer.id, "archive_id": archive.id, "scheduled_time": "0001-01-01T00:00:00+05:00"},
  538. headers=await _webhook_key(db_session),
  539. )
  540. assert (queued.status_code, webhook.status_code) == (422, 400)
  541. async def test_a_failed_notification_log_leaves_the_session_usable(db_session):
  542. from backend.app.services.queue_announcements import announce_queue_jobs_added
  543. archive = await _archive(db_session)
  544. item = PrintQueueItem(printer_id=None, archive_id=archive.id, position=1)
  545. db_session.add(item)
  546. await db_session.commit()
  547. async def log_then_fail(**kwargs):
  548. db_session.add(Settings(key="dup", value="1"))
  549. db_session.add(Settings(key="dup", value="2"))
  550. await kwargs["db"].commit()
  551. with patch.object(notification_service, "on_queue_job_added", side_effect=log_then_fail):
  552. await announce_queue_jobs_added(db_session, [item.id])
  553. db_session.add(Settings(key="after", value="1"))
  554. await db_session.commit()