| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681 |
- """Every path that creates queue jobs behaves like the print dialog (C4).
- Only ``POST /queue/`` announced a new job ("job added" notification and the
- MQTT relay event); File Manager's "Add to queue", the webhook, a virtual
- printer, a pipeline run and an order's "dispatch remaining" said nothing. The
- default print options (timelapse, bed levelling, ...) reached only the print
- dialog and the virtual printer. The copies an order dispatches lost their cost
- centre, so they were billed to none and never budget-checked. And a
- ``scheduled_time`` sent with a UTC offset lost the offset, so the job started
- hours late.
- """
- from datetime import datetime
- from pathlib import Path
- from unittest.mock import AsyncMock, patch
- from uuid import uuid4
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
- from backend.app.core.auth import generate_api_key, get_password_hash
- from backend.app.core.config import settings as app_settings
- from backend.app.models.api_key import APIKey
- from backend.app.models.archive import PrintArchive
- from backend.app.models.finance import CostCenter, CostCenterMember
- from backend.app.models.group import Group
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.models.printer import Printer
- from backend.app.models.settings import Settings
- from backend.app.models.user import User
- from backend.app.services.mqtt_relay import mqtt_relay
- from backend.app.services.notification_service import notification_service
- pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
- async def _set(db_session, key: str, value: str) -> None:
- row = await db_session.scalar(select(Settings).where(Settings.key == key))
- if row is None:
- db_session.add(Settings(key=key, value=value))
- else:
- row.value = value
- await db_session.commit()
- async def _items(test_engine, ids=None) -> list[PrintQueueItem]:
- maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
- async with maker() as fresh:
- query = select(PrintQueueItem).order_by(PrintQueueItem.id)
- if ids is not None:
- query = query.where(PrintQueueItem.id.in_(ids))
- return list((await fresh.execute(query)).scalars().all())
- @pytest.fixture
- def announced():
- """The notification and relay calls, recorded."""
- notify = AsyncMock()
- relay = AsyncMock()
- with (
- patch.object(notification_service, "on_queue_job_added", notify),
- patch.object(mqtt_relay, "on_queue_job_added", relay),
- ):
- yield notify, relay
- @pytest.fixture
- async def library_files(db_session):
- from backend.app.models.library import LibraryFile
- made = []
- async def make(stem: str):
- name = f"{stem}_{uuid4().hex[:8]}.gcode.3mf"
- rel_path = f"archive/library/files/{name}"
- abs_path = Path(app_settings.base_dir) / rel_path
- abs_path.parent.mkdir(parents=True, exist_ok=True)
- abs_path.write_bytes(b"probe")
- made.append(abs_path)
- lib_file = LibraryFile(filename=name, file_path=rel_path, file_size=5, file_type="3mf")
- db_session.add(lib_file)
- await db_session.commit()
- await db_session.refresh(lib_file)
- return lib_file
- yield make
- for path in made:
- path.unlink(missing_ok=True)
- async def _printer(db_session, name="C4 Printer", model="X1C") -> Printer:
- printer = Printer(
- name=name,
- ip_address=f"192.168.44.{len(name)}",
- serial_number=f"C4{uuid4().hex[:12].upper()}",
- access_code="12345678",
- model=model,
- )
- db_session.add(printer)
- await db_session.commit()
- await db_session.refresh(printer)
- return printer
- async def _archive(db_session, filename="widget.gcode.3mf", **extra) -> PrintArchive:
- archive = PrintArchive(
- filename=filename,
- print_name="widget",
- file_path=f"/tmp/c4_{uuid4().hex}.3mf", # nosec B108
- file_size=1,
- content_hash=uuid4().hex,
- status="completed",
- **extra,
- )
- db_session.add(archive)
- await db_session.commit()
- await db_session.refresh(archive)
- return archive
- async def _webhook_key(db_session) -> dict[str, str]:
- full_key, key_hash, key_prefix = generate_api_key()
- db_session.add(
- APIKey(name="c4", key_hash=key_hash, key_prefix=key_prefix, user_id=None, can_queue=True, can_read_status=True)
- )
- await db_session.commit()
- return {"X-API-Key": full_key}
- class TestEveryPathAnnouncesItsJobs:
- async def test_a_file_manager_bulk_add_sends_one_summary(
- self, async_client: AsyncClient, db_session, library_files, announced
- ):
- notify, relay = announced
- printer = await _printer(db_session)
- files = [await library_files("bracket"), await library_files("hinge")]
- response = await async_client.post(
- "/api/v1/library/files/add-to-queue",
- json={"file_ids": [f.id for f in files], "printer_id": printer.id},
- )
- assert response.status_code == 200, response.text
- notify.assert_awaited_once()
- kwargs = notify.await_args.kwargs
- assert kwargs["job_name"].startswith("2 jobs (bracket_")
- assert "hinge_" in kwargs["job_name"]
- assert kwargs["target"] == printer.name
- assert kwargs["printer_id"] == printer.id
- # The relay is a machine event: one per job.
- assert sorted(c.kwargs["job_id"] for c in relay.await_args_list) == sorted(
- a["queue_item_id"] for a in response.json()["added"]
- )
- async def test_the_webhook_announces_its_job(self, async_client: AsyncClient, db_session, announced):
- notify, relay = announced
- printer = await _printer(db_session)
- archive = await _archive(db_session)
- response = await async_client.post(
- "/api/v1/webhook/queue/add",
- json={"printer_id": printer.id, "archive_id": archive.id},
- headers=await _webhook_key(db_session),
- )
- assert response.status_code == 200, response.text
- notify.assert_awaited_once()
- assert notify.await_args.kwargs["job_name"] == "widget"
- assert relay.await_args.kwargs["job_id"] == response.json()["id"]
- async def test_an_orders_dispatch_announces_the_copies(self, async_client: AsyncClient, db_session, announced):
- notify, _ = announced
- printer = await _printer(db_session)
- archive = await _archive(db_session)
- order = (
- await async_client.post(
- "/api/v1/queue/batches",
- json={"name": "o", "archive_id": archive.id, "plates": [{"plate_id": None, "quantity_target": 3}]},
- )
- ).json()
- first = await async_client.post(
- "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "batch_id": order["id"]}
- )
- assert first.status_code == 200, first.text
- notify.reset_mock()
- response = await async_client.post(f"/api/v1/queue/batches/{order['id']}/dispatch", json={})
- assert response.status_code == 200, response.text
- notify.assert_awaited_once()
- assert notify.await_args.kwargs["job_name"] == "widget ×2"
- async def test_a_failing_notification_does_not_fail_the_add(
- self, async_client: AsyncClient, db_session, library_files
- ):
- lib_file = await library_files("plain")
- with patch.object(notification_service, "on_queue_job_added", AsyncMock(side_effect=RuntimeError("smtp"))):
- response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
- assert response.status_code == 200
- assert len(response.json()["added"]) == 1
- async def test_the_print_dialog_still_announces_once(self, async_client: AsyncClient, db_session, announced):
- notify, relay = announced
- printer = await _printer(db_session)
- archive = await _archive(db_session)
- response = await async_client.post(
- "/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id, "quantity": 3}
- )
- assert response.status_code == 200, response.text
- notify.assert_awaited_once()
- assert notify.await_args.kwargs["job_name"] == "widget ×3"
- assert relay.await_count == 3
- class TestTheSummary:
- async def test_jobs_for_different_printers_are_announced_per_printer(self, db_session, test_engine, announced):
- """A provider scoped to one printer must not hear of another's jobs."""
- from backend.app.services.queue_announcements import announce_queue_jobs_added
- notify, _ = announced
- a, b = await _printer(db_session, "Alpha"), await _printer(db_session, "Bravo")
- archive = await _archive(db_session)
- items = [
- PrintQueueItem(printer_id=a.id, archive_id=archive.id, position=1),
- PrintQueueItem(printer_id=b.id, archive_id=archive.id, position=2),
- PrintQueueItem(printer_id=None, target_model="P1S", archive_id=archive.id, position=3),
- ]
- db_session.add_all(items)
- await db_session.commit()
- await announce_queue_jobs_added(db_session, [i.id for i in items])
- sent = sorted((c.kwargs["printer_id"] or 0, c.kwargs["target"]) for c in notify.await_args_list)
- assert sent == sorted([(a.id, "Alpha"), (b.id, "Bravo"), (0, "Any P1S")])
- async def test_many_different_files_are_named_briefly(self, db_session, announced):
- from backend.app.services.queue_announcements import announce_queue_jobs_added
- notify, _ = announced
- items = []
- for n in range(5):
- archive = await _archive(db_session, filename=f"part{n}.3mf")
- items.append(PrintQueueItem(printer_id=None, archive_id=archive.id, position=n))
- db_session.add_all(items)
- await db_session.commit()
- await announce_queue_jobs_added(db_session, [i.id for i in items])
- assert notify.await_args.kwargs["job_name"] == "5 jobs (part0, part1, part2, …)"
- assert notify.await_args.kwargs["target"] == "Unassigned"
- async def test_nothing_added_says_nothing(self, db_session, announced):
- from backend.app.services.queue_announcements import announce_queue_jobs_added
- notify, relay = announced
- await announce_queue_jobs_added(db_session, [])
- notify.assert_not_awaited()
- relay.assert_not_awaited()
- class TestTheDefaultPrintOptions:
- async def test_unset_they_are_the_settings_defaults(self, db_session):
- from backend.app.services.print_defaults import default_print_options
- assert await default_print_options(db_session) == {
- "bed_levelling": "auto",
- "flow_cali": "auto",
- "nozzle_offset_cali": "auto",
- "vibration_cali": True,
- "layer_inspect": False,
- "timelapse": False,
- }
- async def test_set_they_are_read_including_legacy_values(self, db_session):
- from backend.app.services.print_defaults import default_print_options
- for key, value in {
- "default_bed_levelling": "false", # legacy boolean row
- "default_flow_cali": "on",
- "default_nozzle_offset_cali": "off",
- "default_vibration_cali": "false",
- "default_layer_inspect": "true",
- "default_timelapse": "true",
- }.items():
- await _set(db_session, key, value)
- assert await default_print_options(db_session) == {
- "bed_levelling": "off",
- "flow_cali": "on",
- "nozzle_offset_cali": "off",
- "vibration_cali": False,
- "layer_inspect": True,
- "timelapse": True,
- }
- async def test_a_file_manager_add_uses_them(
- self, async_client: AsyncClient, db_session, test_engine, library_files
- ):
- await _set(db_session, "default_timelapse", "true")
- await _set(db_session, "default_bed_levelling", "off")
- lib_file = await library_files("defaults")
- response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
- assert response.status_code == 200, response.text
- (item,) = await _items(test_engine, [response.json()["added"][0]["queue_item_id"]])
- assert (item.timelapse, item.bed_levelling) == (True, "off")
- async def test_the_webhook_uses_them(self, async_client: AsyncClient, db_session, test_engine):
- await _set(db_session, "default_timelapse", "true")
- await _set(db_session, "default_layer_inspect", "true")
- printer = await _printer(db_session)
- archive = await _archive(db_session)
- response = await async_client.post(
- "/api/v1/webhook/queue/add",
- json={"printer_id": printer.id, "archive_id": archive.id},
- headers=await _webhook_key(db_session),
- )
- assert response.status_code == 200, response.text
- (item,) = await _items(test_engine, [response.json()["id"]])
- assert (item.timelapse, item.layer_inspect) == (True, True)
- class TestAPipelineRun:
- async def _run(self, db_session, test_engine, tmp_path):
- from backend.app.api.routes.pipeline_runs import _make_orchestration_callable
- from backend.app.core.printer_scope import ALL_PRINTERS
- from backend.app.models.library import LibraryFile
- from backend.app.models.pipeline_run import PipelineJob, PipelineRun
- from backend.app.models.slicer_pipeline import SlicerPipeline
- from backend.app.schemas.slicer import SliceResponse
- printer = await _printer(db_session, "Pipe")
- source = LibraryFile(filename="pipe.3mf", file_path="library/pipe.3mf", file_type="3mf", file_size=1)
- pipeline = SlicerPipeline(
- name="p",
- printer_preset_source="local",
- printer_preset_id="1",
- process_preset_source="local",
- process_preset_id="2",
- filament_presets_json='[{"source": "local", "id": "3"}]',
- target_kind="specific_printer",
- target_printer_id=printer.id,
- )
- db_session.add_all([source, pipeline])
- await db_session.commit()
- run = PipelineRun(pipeline_id=pipeline.id, source_library_file_id=source.id, copies=2, created_by=None)
- db_session.add(run)
- await db_session.commit()
- db_session.add_all([PipelineJob(pipeline_run_id=run.id, copy_index=i) for i in range(2)])
- await db_session.commit()
- model = tmp_path / "pipe.3mf"
- model.write_bytes(b"model")
- async def sliced(session, **kwargs):
- out = LibraryFile(filename="pipe.gcode.3mf", file_path="library/pipe.gcode.3mf", file_type="3mf")
- out.file_size = 1
- session.add(out)
- await session.flush()
- return SliceResponse(
- library_file_id=out.id, name="pipe", print_time_seconds=1, filament_used_g=1.0, filament_used_mm=1.0
- )
- orchestrate = _make_orchestration_callable(
- run_id=run.id,
- pipeline_id=pipeline.id,
- src_kind="library_file",
- src_id=source.id,
- src_filename="pipe.3mf",
- src_path=Path(model),
- creator_user_id=None,
- copies=2,
- printer_scope=ALL_PRINTERS,
- )
- with (
- patch(
- "backend.app.api.routes.pipeline_runs.async_session",
- async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False),
- ),
- patch("backend.app.api.routes.library.slice_and_persist", new=sliced),
- patch("backend.app.api.routes.pipeline_runs._publish_run_event", new=AsyncMock()),
- ):
- await orchestrate(1)
- return await _items(test_engine)
- async def test_announces_its_copies_and_uses_the_defaults(self, db_session, test_engine, tmp_path, announced):
- notify, _ = announced
- await _set(db_session, "default_timelapse", "true")
- items = await self._run(db_session, test_engine, tmp_path)
- assert len(items) == 2
- assert all(item.timelapse for item in items)
- notify.assert_awaited_once()
- assert notify.await_args.kwargs["job_name"] == "pipe ×2"
- class TestAVirtualPrinter:
- async def test_announces_what_it_queued(self, tmp_path):
- from backend.tests.unit.services.test_vp_queue_intake_d2 import _enqueue, _session, _vp
- added: list = []
- factory, _ = _session(added)
- inst = _vp(tmp_path, factory, target_printer_id=7)
- announce = AsyncMock()
- await _enqueue(inst, tmp_path, {"timelapse": True}, announce=announce)
- announce.assert_awaited_once()
- assert announce.await_args.args[1] == [item.id for item in added]
- async def test_where_the_slicer_says_nothing_it_uses_the_defaults(self, tmp_path):
- """Read from the same helper as every other path now."""
- from backend.tests.unit.services.test_vp_queue_intake_d2 import _enqueue, _session, _vp
- added: list = []
- factory, _ = _session(added)
- inst = _vp(tmp_path, factory, target_printer_id=7)
- await _enqueue(
- inst,
- tmp_path,
- {"flow_cali": True},
- {"default_timelapse": "true", "default_layer_inspect": "true", "default_vibration_cali": "false"},
- )
- assert (added[0].timelapse, added[0].layer_inspect, added[0].vibration_cali) == (True, True, False)
- class TestAnOrdersCopiesKeepTheirCostCentre:
- @pytest.fixture
- async def billed(self, db_session):
- await _set(db_session, "auth_enabled", "true")
- await _set(db_session, "advanced_auth_enabled", "false")
- group = Group(
- name="c4-dispatchers",
- description="t",
- permissions=[
- "queue:create",
- "queue:read_all",
- "queue:update_all",
- "archives:read_all",
- "archives:reprint_all",
- ],
- is_system=False,
- )
- db_session.add(group)
- await db_session.flush()
- admin = User(username="c4admin", password_hash=get_password_hash("Pass1word!"), role="admin", is_active=True)
- clerk = User(username="c4clerk", password_hash=get_password_hash("Pass1word!"), is_active=True, groups=[group])
- center = CostCenter(name="c4-center", is_active=True, is_private=False)
- db_session.add_all([admin, clerk, center])
- await db_session.commit()
- printer = await _printer(db_session)
- archive = await _archive(db_session, cost=1.25, filament_used_grams=50.0)
- await _set(db_session, "billing_enabled", "true")
- return {"admin": admin, "clerk": clerk, "center": center, "printer": printer, "archive": archive}
- async def _login(self, client, username):
- response = await client.post("/api/v1/auth/login", json={"username": username, "password": "Pass1word!"})
- assert response.status_code == 200, response.text
- return {"Authorization": f"Bearer {response.json()['access_token']}"}
- async def _order(self, client, headers, world):
- order = await client.post(
- "/api/v1/queue/batches",
- json={"name": "o", "archive_id": world["archive"].id, "plates": [{"plate_id": None, "quantity_target": 3}]},
- headers=headers,
- )
- assert order.status_code == 200, order.text
- first = await client.post(
- "/api/v1/queue/",
- json={
- "printer_id": world["printer"].id,
- "archive_id": world["archive"].id,
- "batch_id": order.json()["id"],
- "cost_center_id": world["center"].id,
- },
- headers=headers,
- )
- assert first.status_code == 200, first.text
- return order.json()["id"]
- async def test_the_copies_are_billed_to_it(self, async_client: AsyncClient, test_engine, billed):
- admin = await self._login(async_client, "c4admin")
- batch_id = await self._order(async_client, admin, billed)
- response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={}, headers=admin)
- assert response.status_code == 200, response.text
- items = [i for i in await _items(test_engine) if i.batch_id == batch_id]
- assert len(items) == 3
- assert {i.cost_center_id for i in items} == {billed["center"].id}
- async def test_a_dispatcher_who_may_not_use_it_is_refused(
- self, async_client: AsyncClient, db_session, test_engine, billed
- ):
- admin = await self._login(async_client, "c4admin")
- batch_id = await self._order(async_client, admin, billed)
- clerk = await self._login(async_client, "c4clerk")
- response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={}, headers=clerk)
- assert response.status_code == 403, response.text
- assert len([i for i in await _items(test_engine) if i.batch_id == batch_id]) == 1
- db_session.add(CostCenterMember(cost_center_id=billed["center"].id, user_id=billed["clerk"].id, can_print=True))
- await db_session.commit()
- response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={}, headers=clerk)
- assert response.status_code == 200, response.text
- async def test_the_budget_counts_every_copy(self, async_client: AsyncClient, db_session, test_engine, billed):
- center = await db_session.get(CostCenter, billed["center"].id)
- admin = await self._login(async_client, "c4admin")
- batch_id = await self._order(async_client, admin, billed)
- (template,) = [i for i in await _items(test_engine) if i.batch_id == batch_id]
- # Room for this one and one more, not for the two copies still owed.
- center.total_budget = 2 * template.estimated_cost + 0.01
- await db_session.commit()
- response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={}, headers=admin)
- assert response.status_code == 400, response.text
- assert "budget" in response.json()["detail"]
- async def test_copies_without_one_are_refused_while_billing_is_on(
- self, async_client: AsyncClient, db_session, test_engine, billed
- ):
- """Queued before billing was switched on: refused here, as POST /queue/
- would, rather than failing later at the start."""
- admin = await self._login(async_client, "c4admin")
- batch_id = await self._order(async_client, admin, billed)
- (template,) = [i for i in await _items(test_engine) if i.batch_id == batch_id]
- row = await db_session.get(PrintQueueItem, template.id)
- row.cost_center_id = None
- await db_session.commit()
- response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={}, headers=admin)
- assert response.status_code == 400, response.text
- assert "Cost center is required" in response.json()["detail"]
- async def test_a_budget_that_holds_every_copy_dispatches(
- self, async_client: AsyncClient, db_session, test_engine, billed
- ):
- """The copies are not counted against themselves."""
- center = await db_session.get(CostCenter, billed["center"].id)
- admin = await self._login(async_client, "c4admin")
- batch_id = await self._order(async_client, admin, billed)
- (template,) = [i for i in await _items(test_engine) if i.batch_id == batch_id]
- center.total_budget = 3 * template.estimated_cost + 0.01
- await db_session.commit()
- response = await async_client.post(f"/api/v1/queue/batches/{batch_id}/dispatch", json={}, headers=admin)
- assert response.status_code == 200, response.text
- copies = [i for i in await _items(test_engine) if i.batch_id == batch_id and i.id != template.id]
- assert [c.estimated_cost for c in copies] == [template.estimated_cost] * 2
- class TestAScheduledTimeWithAnOffset:
- async def test_the_queue_stores_it_in_utc(self, async_client: AsyncClient, db_session, test_engine):
- printer = await _printer(db_session)
- archive = await _archive(db_session)
- response = await async_client.post(
- "/api/v1/queue/",
- json={"printer_id": printer.id, "archive_id": archive.id, "scheduled_time": "2026-10-15T10:00:00+02:00"},
- )
- assert response.status_code == 200, response.text
- (item,) = await _items(test_engine, [response.json()["id"]])
- assert item.scheduled_time == datetime(2026, 10, 15, 8, 0)
- assert response.json()["scheduled_time"] == "2026-10-15T08:00:00Z"
- async def test_an_edit_stores_it_in_utc(self, async_client: AsyncClient, db_session, test_engine):
- printer = await _printer(db_session)
- archive = await _archive(db_session)
- created = await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id})
- response = await async_client.patch(
- f"/api/v1/queue/{created.json()['id']}", json={"scheduled_time": "2026-10-15T10:00:00-05:00"}
- )
- assert response.status_code == 200, response.text
- (item,) = await _items(test_engine, [created.json()["id"]])
- assert item.scheduled_time == datetime(2026, 10, 15, 15, 0)
- async def test_a_bulk_edit_stores_it_in_utc(self, async_client: AsyncClient, db_session, test_engine):
- printer = await _printer(db_session)
- archive = await _archive(db_session)
- created = await async_client.post("/api/v1/queue/", json={"printer_id": printer.id, "archive_id": archive.id})
- response = await async_client.patch(
- "/api/v1/queue/bulk",
- json={"item_ids": [created.json()["id"]], "scheduled_time": "2026-10-15T10:00:00+02:00"},
- )
- assert response.status_code == 200, response.text
- (item,) = await _items(test_engine, [created.json()["id"]])
- assert item.scheduled_time == datetime(2026, 10, 15, 8, 0)
- async def test_the_webhook_stores_it_in_utc(self, async_client: AsyncClient, db_session, test_engine):
- printer = await _printer(db_session)
- archive = await _archive(db_session)
- response = await async_client.post(
- "/api/v1/webhook/queue/add",
- json={"printer_id": printer.id, "archive_id": archive.id, "scheduled_time": "2026-10-15T10:00:00+02:00"},
- headers=await _webhook_key(db_session),
- )
- assert response.status_code == 200, response.text
- (item,) = await _items(test_engine, [response.json()["id"]])
- assert item.scheduled_time == datetime(2026, 10, 15, 8, 0)
- async def test_the_postgres_bind_hook_converts_rather_than_drops_the_offset():
- """The backstop for any other aware value: naive columns hold UTC."""
- from datetime import timedelta, timezone
- from backend.app.core import database
- with patch.object(database, "is_sqlite", return_value=False):
- engine = database._create_engine("postgresql+asyncpg://u:p@127.0.0.1:1/never")
- try:
- (hook,) = [
- fn for fn in engine.sync_engine.dispatch.before_cursor_execute if "strip" in getattr(fn, "__name__", "")
- ]
- aware = datetime(2026, 10, 15, 10, 0, tzinfo=timezone(timedelta(hours=2)))
- _, params = hook(None, None, "", {"t": aware, "rows": [(aware,)]}, None, False)
- assert params == {"t": datetime(2026, 10, 15, 8, 0), "rows": [(datetime(2026, 10, 15, 8, 0),)]}
- finally:
- await engine.dispose()
- async def test_a_time_at_the_edge_of_the_calendar_is_refused_not_a_crash(async_client: AsyncClient, db_session):
- printer = await _printer(db_session)
- archive = await _archive(db_session)
- queued = await async_client.post(
- "/api/v1/queue/",
- json={"printer_id": printer.id, "archive_id": archive.id, "scheduled_time": "0001-01-01T00:00:00+05:00"},
- )
- webhook = await async_client.post(
- "/api/v1/webhook/queue/add",
- json={"printer_id": printer.id, "archive_id": archive.id, "scheduled_time": "0001-01-01T00:00:00+05:00"},
- headers=await _webhook_key(db_session),
- )
- assert (queued.status_code, webhook.status_code) == (422, 400)
- async def test_a_failed_notification_log_leaves_the_session_usable(db_session):
- from backend.app.services.queue_announcements import announce_queue_jobs_added
- archive = await _archive(db_session)
- item = PrintQueueItem(printer_id=None, archive_id=archive.id, position=1)
- db_session.add(item)
- await db_session.commit()
- async def log_then_fail(**kwargs):
- db_session.add(Settings(key="dup", value="1"))
- db_session.add(Settings(key="dup", value="2"))
- await kwargs["db"].commit()
- with patch.object(notification_service, "on_queue_job_added", side_effect=log_then_fail):
- await announce_queue_jobs_added(db_session, [item.id])
- db_session.add(Settings(key="after", value="1"))
- await db_session.commit()
|