| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657 |
- """Ask for the outcome of prints Bambuddy did not start (#1898 follow-up).
- The ask-for-outcome flag rides from a queue item onto the archive at dispatch.
- On a farm where most jobs are started at the printer's screen, in Bambu Studio
- or in the Handy app there is no queue item to ride from, so every archive
- ``on_print_start`` created had ``confirm_requested`` false and the feature
- looked broken. ``confirm_outcome_external_prints`` is the missing source.
- Both archive-creating branches of ``on_print_start`` are driven here against a
- real database: the no-3MF fallback (what a P1S/A1 farm actually hits) and the
- normal path that archives a downloaded 3MF.
- """
- from contextlib import ExitStack
- from datetime import datetime, timezone
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from sqlalchemy import select
- from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
- from backend.app.main import (
- _active_prints,
- _expected_print_creators,
- _expected_print_registered_at,
- _expected_prints,
- _print_ams_mappings,
- )
- from backend.app.models.archive import PrintArchive
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.models.settings import Settings
- DISPATCH = "/data/Metadata/plate_1.gcode"
- SUBTASK = "Bracket_plate_1"
- @pytest.fixture(autouse=True)
- def _clear_print_state():
- dicts = (
- _expected_prints,
- _expected_print_registered_at,
- _expected_print_creators,
- _print_ams_mappings,
- _active_prints,
- )
- for d in dicts:
- d.clear()
- yield
- for d in dicts:
- d.clear()
- class _StubArchiveService:
- """Stands in for ArchiveService on the downloaded-3MF branch.
- Writes the row the real service would write, without needing a parseable
- 3MF on disk. Everything this test asserts happens *after* the row exists.
- """
- def __init__(self, db):
- self.db = db
- async def archive_print(self, **kwargs):
- archive = PrintArchive(
- printer_id=kwargs.get("printer_id"),
- filename=f"{SUBTASK}.gcode.3mf",
- file_path=f"archives/test/{SUBTASK}.gcode.3mf",
- file_size=2048,
- print_name=SUBTASK,
- status="printing",
- started_at=datetime.now(timezone.utc),
- )
- self.db.add(archive)
- await self.db.commit()
- await self.db.refresh(archive)
- return archive
- async def _drive_print_start(test_engine, printer, *, download_ok: bool, extra_patches=()) -> dict:
- """Run ``on_print_start`` against the test database.
- ``download_ok`` picks the branch: False leaves the 3MF unreachable and the
- no-3MF fallback archive is written inline; True takes the ArchiveService
- branch.
- """
- session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
- state = MagicMock(
- current_project_url=f"ftp://{SUBTASK}.gcode.3mf",
- sdcard=True,
- sdcard_reported=True,
- )
- patches = [
- patch("backend.app.main.async_session", session_maker),
- patch("backend.app.core.database.async_session", session_maker),
- patch("backend.app.main.download_file_async", new=AsyncMock(return_value=download_ok)),
- patch("backend.app.main.download_file_try_paths_async", new=AsyncMock(return_value=None)),
- patch("backend.app.main.get_cached_3mf", return_value=None),
- patch("backend.app.main.cache_3mf_download"),
- patch("backend.app.main.peek_plate_index_in_3mf", return_value=None),
- patch("backend.app.main.ArchiveService", _StubArchiveService),
- # Imported inside the function, so patching it anywhere else lets the
- # directory walk open real sockets.
- patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=[])),
- patch("backend.app.main.ftps_handshake_blocked", return_value=False),
- patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30))),
- patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
- patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
- patch("backend.app.main._maybe_start_layer_timelapse"),
- patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock),
- # Real, it would spawn a task that outlives the test by a minute.
- patch("backend.app.main._schedule_fallback_3mf_retry"),
- patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
- # Imported inside on_print_start; it would try to persist the mocked
- # printer state's AMS trays and fail on the MagicMock values.
- patch("backend.app.services.usage_tracker.on_print_start", new_callable=AsyncMock),
- ]
- mocks: dict = {}
- with ExitStack() as stack:
- for p in [*patches, *extra_patches]:
- mocks[p.attribute] = stack.enter_context(p)
- notif = stack.enter_context(patch("backend.app.main.notification_service"))
- plug = stack.enter_context(patch("backend.app.main.smart_plug_manager"))
- ws = stack.enter_context(patch("backend.app.main.ws_manager"))
- relay = stack.enter_context(patch("backend.app.main.mqtt_relay"))
- pm = stack.enter_context(patch("backend.app.main.printer_manager"))
- notif.on_print_start = AsyncMock()
- plug.on_print_start = AsyncMock()
- ws.send_print_start = AsyncMock()
- ws.send_archive_created = AsyncMock()
- ws.send_archive_updated = AsyncMock()
- relay.on_print_start = AsyncMock()
- relay.on_archive_created = AsyncMock()
- pm.get_status = MagicMock(return_value=state)
- pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST1898"))
- from backend.app.main import on_print_start
- await on_print_start(printer.id, {"filename": DISPATCH, "subtask_name": SUBTASK})
- return mocks
- async def _set_setting(db_session, key: str, value: str) -> None:
- db_session.add(Settings(key=key, value=value))
- await db_session.commit()
- async def _created_archive(db_session, printer_id: int) -> PrintArchive:
- db_session.expire_all()
- archive = await db_session.scalar(
- select(PrintArchive).where(PrintArchive.printer_id == printer_id).order_by(PrintArchive.id.desc()).limit(1)
- )
- assert archive is not None, "on_print_start created no archive"
- return archive
- class TestExternalPrintGetsTheOutcomePrompt:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_no_3mf_fallback_asks_when_the_setting_is_on(self, test_engine, db_session, printer_factory):
- """The P1S/A1 case: the 3MF cannot be fetched, the archive is written
- inline — and that is the row the completion path reads the flag off."""
- printer = await printer_factory()
- await _set_setting(db_session, "confirm_outcome_external_prints", "true")
- await _drive_print_start(test_engine, printer, download_ok=False)
- archive = await _created_archive(db_session, printer.id)
- assert archive.extra_data.get("no_3mf_available") is True
- assert archive.confirm_requested is True
- # What the completion path gates the prompt on.
- assert archive.user_verdict is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_archive_reaches_the_rest_of_the_1898_machinery(self, test_engine, db_session, printer_factory):
- """An externally started print is now a pending confirmation like any
- other: the prompt the completion path emits is gated on exactly these
- two fields, and the plate-release default resolves the same row."""
- from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
- printer = await printer_factory()
- printer_id = printer.id
- await _set_setting(db_session, "confirm_outcome_external_prints", "true")
- await _drive_print_start(test_engine, printer, download_ok=False)
- archive = await _created_archive(db_session, printer_id)
- archive.status = "completed"
- await db_session.commit()
- assert await resolve_pending_confirmation_as_good(db_session, printer_id) == archive.id
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_downloaded_3mf_archive_asks_when_the_setting_is_on(self, test_engine, db_session, printer_factory):
- """The other creation branch: the 3MF arrived and ArchiveService wrote
- the row. The flag is set on the row afterwards rather than passed into
- archive_print, which also serves the queue dispatcher."""
- printer = await printer_factory()
- await _set_setting(db_session, "confirm_outcome_external_prints", "true")
- await _drive_print_start(test_engine, printer, download_ok=True)
- archive = await _created_archive(db_session, printer.id)
- assert archive.file_path.endswith(".3mf")
- assert archive.confirm_requested is True
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_default_leaves_external_prints_alone(self, test_engine, db_session, printer_factory):
- """Default off: nothing about today's behaviour changes for an install
- that never touches the new setting — no row for it at all."""
- printer = await printer_factory()
- await _drive_print_start(test_engine, printer, download_ok=False)
- archive = await _created_archive(db_session, printer.id)
- assert archive.confirm_requested is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_setting_off_does_not_ask(self, test_engine, db_session, printer_factory):
- printer = await printer_factory()
- await _set_setting(db_session, "confirm_outcome_external_prints", "false")
- await _drive_print_start(test_engine, printer, download_ok=False)
- assert (await _created_archive(db_session, printer.id)).confirm_requested is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_odd_stored_value_counts_as_off(self, test_engine, db_session, printer_factory):
- """Settings live in a VARCHAR column and every other reader treats
- anything that is not "true" as off."""
- printer = await printer_factory()
- await _set_setting(db_session, "confirm_outcome_external_prints", "None")
- await _drive_print_start(test_engine, printer, download_ok=False)
- assert (await _created_archive(db_session, printer.id)).confirm_requested is False
- class TestAFailureHereCostsOnlyThePrompt:
- """The prompt is optional; the rest of print start is not.
- After a failed statement, a rollback of the whole transaction expires every
- object the session holds, and on an async session the next read of one
- raises instead of reloading. The print start below the check reads both
- the printer and the archive, so the check runs in a savepoint, and the flag
- write reloads what it used after its own rollback.
- """
- @staticmethod
- def _assert_print_start_finished(mocks, archive_id: int) -> None:
- assert archive_id in _active_prints.values()
- mocks["_record_energy_start"].assert_awaited()
- assert mocks["_record_energy_start"].await_args.args[0].id == archive_id
- mocks["_capture_timelapse_baseline_at_start"].assert_awaited()
- assert mocks["_capture_timelapse_baseline_at_start"].await_args.kwargs["archive_id"] == archive_id
- @pytest.mark.asyncio
- @pytest.mark.integration
- @pytest.mark.parametrize("download_ok", [True, False])
- async def test_a_failing_query_in_the_check(self, test_engine, db_session, printer_factory, download_ok):
- from sqlalchemy import text
- from backend.app.api.routes import settings as settings_routes
- real_get_setting = settings_routes.get_setting
- async def _broken_for_this_key(db, key):
- if key == "confirm_outcome_external_prints":
- # A real failed statement, not just an exception: this is what
- # leaves the transaction needing a rollback.
- await db.execute(text("SELECT no_such_column FROM settings"))
- return await real_get_setting(db, key)
- printer = await printer_factory()
- await _set_setting(db_session, "confirm_outcome_external_prints", "true")
- mocks = await _drive_print_start(
- test_engine,
- printer,
- download_ok=download_ok,
- extra_patches=[patch.object(settings_routes, "get_setting", _broken_for_this_key)],
- )
- archive = await _created_archive(db_session, printer.id)
- assert archive.confirm_requested is False
- self._assert_print_start_finished(mocks, archive.id)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_failing_flag_write(self, test_engine, db_session, printer_factory):
- async def _yes_but_poison_the_commit(db, printer_id, observed_name=None):
- # Two rows for one unique key: the commit that carries the flag fails.
- db.add(Settings(key="x_1898_duplicate", value="a"))
- db.add(Settings(key="x_1898_duplicate", value="b"))
- return True
- printer = await printer_factory()
- mocks = await _drive_print_start(
- test_engine,
- printer,
- download_ok=True,
- extra_patches=[patch("backend.app.main._ask_outcome_for_external_print", _yes_but_poison_the_commit)],
- )
- archive = await _created_archive(db_session, printer.id)
- # archive_print committed the row; only the flag is lost.
- assert archive.confirm_requested is False
- self._assert_print_start_finished(mocks, archive.id)
- class TestAQueuedPrintStillDecidesForItself:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_dispatched_job_is_not_overridden_by_the_setting(
- self, test_engine, db_session, printer_factory, archive_factory
- ):
- """A queue item that deliberately has the ask-for-outcome flag off must
- stay off. A restart mid-print empties the expected-print registry, so
- the queue row — which the scheduler commits to "printing" before the
- MQTT send — is the durable record that Bambuddy started this."""
- printer = await printer_factory()
- # Dispatched but not yet reported as started: the archive the scheduler
- # attached to the row only turns "printing" once on_print_start runs, so
- # the name-match resume above cannot find it and the queue row is the
- # only record that Bambuddy sent this print.
- source = await archive_factory(printer.id, filename=f"{SUBTASK}.gcode.3mf", status="pending", with_run=False)
- db_session.add(
- PrintQueueItem(
- printer_id=printer.id,
- archive_id=source.id,
- status="printing",
- confirm_outcome=False,
- )
- )
- await db_session.commit()
- await _set_setting(db_session, "confirm_outcome_external_prints", "true")
- await _drive_print_start(test_engine, printer, download_ok=False)
- archive = await _created_archive(db_session, printer.id)
- assert archive.confirm_requested is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_queue_items_own_yes_survives_print_start(
- self, test_engine, db_session, printer_factory, archive_factory
- ):
- """The dispatcher copies ``confirm_outcome`` onto the archive before
- the print starts; the expected-print branch of ``on_print_start`` must
- leave that alone even while the external-print setting is off."""
- from backend.app.main import register_expected_print
- printer = await printer_factory()
- archive = await archive_factory(
- printer.id,
- filename=f"{SUBTASK}.gcode.3mf",
- status="pending",
- confirm_requested=True,
- with_run=False,
- )
- db_session.add(
- PrintQueueItem(
- printer_id=printer.id,
- archive_id=archive.id,
- status="printing",
- confirm_outcome=True,
- )
- )
- await db_session.commit()
- archive_id, printer_id = archive.id, printer.id
- register_expected_print(printer_id, f"{SUBTASK}.gcode.3mf", archive_id)
- await _drive_print_start(test_engine, printer, download_ok=False)
- db_session.expire_all()
- refreshed = await db_session.get(PrintArchive, archive_id)
- assert refreshed.status == "printing"
- assert refreshed.confirm_requested is True
- # No second row for the same print.
- rows = (await db_session.scalars(select(PrintArchive).where(PrintArchive.printer_id == printer_id))).all()
- assert len(rows) == 1
- class TestSettingsRoundTrip:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_defaults_to_off_and_round_trips(self, async_client):
- response = await async_client.get("/api/v1/settings/")
- assert response.status_code == 200
- assert response.json()["confirm_outcome_external_prints"] is False
- response = await async_client.put("/api/v1/settings/", json={"confirm_outcome_external_prints": True})
- assert response.status_code == 200
- assert response.json()["confirm_outcome_external_prints"] is True
- assert (await async_client.get("/api/v1/settings/")).json()["confirm_outcome_external_prints"] is True
- response = await async_client.put("/api/v1/settings/", json={"confirm_outcome_external_prints": False})
- assert response.status_code == 200
- assert (await async_client.get("/api/v1/settings/")).json()["confirm_outcome_external_prints"] is False
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_updating_it_leaves_the_per_job_default_alone(self, async_client):
- """Two different questions: one seeds the per-job toggle in the print
- dialog, the other covers prints that never see that dialog."""
- await async_client.put("/api/v1/settings/", json={"default_confirm_outcome": True})
- body = (await async_client.put("/api/v1/settings/", json={"confirm_outcome_external_prints": True})).json()
- assert body["default_confirm_outcome"] is True
- assert body["confirm_outcome_external_prints"] is True
- class TestAStrandedQueueRowDoesNotMuteTheSetting:
- """A ``printing`` row is not proof that Bambuddy started what is printing now.
- ``_completion_belongs_to_queue_item`` deliberately leaves a row open when a
- completion's subtask name disagrees with the file it was dispatched with,
- and the scheduler's stranded sweep only takes it back once the printer has
- sat connected and terminal for minutes. Treating any such row as "we
- dispatched this" switched the setting off for every screen-started print in
- between -- the exact symptom the setting exists to cure.
- """
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_row_left_open_for_another_file_still_asks(
- self, test_engine, db_session, printer_factory, archive_factory
- ):
- printer = await printer_factory()
- stranded_for = await archive_factory(
- printer.id, filename="SomeOtherJob.gcode.3mf", status="printing", with_run=False
- )
- db_session.add(
- PrintQueueItem(
- printer_id=printer.id,
- archive_id=stranded_for.id,
- status="printing",
- confirm_outcome=False,
- )
- )
- await db_session.commit()
- await _set_setting(db_session, "confirm_outcome_external_prints", "true")
- stranded_filename = stranded_for.filename
- await _drive_print_start(test_engine, printer, download_ok=False)
- archive = await _created_archive(db_session, printer.id)
- assert archive.filename != stranded_filename
- assert archive.confirm_requested is True
- class TestAReprintAsksAgain:
- """The reprint reuses the archive row, and the completion prompt is gated on
- ``user_verdict is None`` -- so without a reset the second run inherits the
- first run's answer and is never asked about."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_previous_runs_verdict_does_not_carry_over(
- self, test_engine, db_session, printer_factory, archive_factory
- ):
- from backend.app.main import register_expected_print
- printer = await printer_factory()
- archive = await archive_factory(
- printer.id,
- filename=f"{SUBTASK}.gcode.3mf",
- status="completed",
- confirm_requested=True,
- user_verdict="good",
- confirm_token="token-from-the-first-run",
- with_run=False,
- )
- archive_id, printer_id = archive.id, printer.id
- register_expected_print(printer_id, f"{SUBTASK}.gcode.3mf", archive_id)
- await _drive_print_start(test_engine, printer, download_ok=False)
- db_session.expire_all()
- refreshed = await db_session.get(PrintArchive, archive_id)
- assert refreshed.status == "printing"
- assert refreshed.confirm_requested is True
- assert refreshed.user_verdict is None
- assert refreshed.confirm_token is None
- # And the completion really does ask again, which is the point.
- refreshed.status = "completed"
- await db_session.commit()
- sent, ws, notif = await _dispatch(db_session, printer_id, archive_id)
- assert sent is True
- ws.send_print_confirm_request.assert_awaited_once()
- notif.on_print_confirm_request.assert_awaited_once()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_archive_that_was_never_asked_about_keeps_its_verdict(
- self, test_engine, db_session, printer_factory, archive_factory
- ):
- """The reset is scoped to rows that ask. An archive answered once and
- later reprinted with the flag off must keep the answer it has."""
- from backend.app.main import register_expected_print
- printer = await printer_factory()
- archive = await archive_factory(
- printer.id,
- filename=f"{SUBTASK}.gcode.3mf",
- status="completed",
- confirm_requested=False,
- user_verdict="reject",
- with_run=False,
- )
- archive_id = archive.id
- register_expected_print(printer.id, f"{SUBTASK}.gcode.3mf", archive_id)
- await _drive_print_start(test_engine, printer, download_ok=False)
- db_session.expire_all()
- assert (await db_session.get(PrintArchive, archive_id)).user_verdict == "reject"
- async def _dispatch(db_session, printer_id: int, archive_id: int, **kwargs):
- """Run the completion path's outcome dispatch with the two emitters mocked."""
- from backend.app.main import dispatch_outcome_confirmation
- with (
- patch("backend.app.main.ws_manager") as ws,
- patch("backend.app.main.notification_service") as notif,
- ):
- ws.send_print_confirm_request = AsyncMock()
- notif.on_print_confirm_request = AsyncMock()
- sent = await dispatch_outcome_confirmation(
- db_session,
- printer_id,
- "Bench P1S",
- {"subtask_name": SUBTASK},
- archive_id,
- **kwargs,
- )
- return sent, ws, notif
- class TestTheCompletionEmitsThePrompt:
- """The half of the feature the user actually sees.
- Everything else here asserts a column value; this drives the block
- ``on_print_complete``'s notification task runs -- which is wrapped in a bare
- ``except Exception`` that only logs, so a regression in it is invisible
- unless something pins it.
- """
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_external_print_gets_a_prompt_with_one_tap_links(self, test_engine, db_session, printer_factory):
- printer = await printer_factory()
- printer_id = printer.id
- await _set_setting(db_session, "confirm_outcome_external_prints", "true")
- await _set_setting(db_session, "external_url", "https://farm.example.com/")
- await _drive_print_start(test_engine, printer, download_ok=False)
- archive = await _created_archive(db_session, printer_id)
- archive_id = archive.id
- assert archive.confirm_requested is True
- archive.status = "completed"
- await db_session.commit()
- sent, ws, notif = await _dispatch(db_session, printer_id, archive_id)
- assert sent is True
- ws.send_print_confirm_request.assert_awaited_once()
- assert ws.send_print_confirm_request.await_args.args[1]["archive_id"] == archive_id
- notif.on_print_confirm_request.assert_awaited_once()
- kwargs = notif.on_print_confirm_request.await_args.kwargs
- token = (await db_session.get(PrintArchive, archive_id)).confirm_token
- assert token, "the one-tap links need a minted capability token"
- assert kwargs["good_url"] == f"https://farm.example.com/api/v1/archives/confirm/{token}/good"
- assert kwargs["reject_url"] == f"https://farm.example.com/api/v1/archives/confirm/{token}/reject"
- assert kwargs["confirm_url"] == f"https://farm.example.com/archives?confirm={archive_id}"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_the_links_stay_absolute_without_an_external_url(self, test_engine, db_session, printer_factory):
- """Telegram's inline keyboard and ntfy's action buttons are both dropped
- for a relative URL, so an install that never set external_url used to
- get a message carrying two unusable paths and no buttons at all."""
- printer = await printer_factory()
- printer_id = printer.id
- await _set_setting(db_session, "confirm_outcome_external_prints", "true")
- await _drive_print_start(test_engine, printer, download_ok=False)
- archive = await _created_archive(db_session, printer_id)
- archive_id = archive.id
- archive.status = "completed"
- await db_session.commit()
- _, _, notif = await _dispatch(db_session, printer_id, archive_id)
- kwargs = notif.on_print_confirm_request.await_args.kwargs
- assert kwargs["good_url"].startswith("http")
- assert kwargs["reject_url"].startswith("http")
- assert kwargs["confirm_url"].startswith("http")
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_failed_prompt_leaves_the_session_usable_for_the_email(self, db_session):
- """The completion task sends the per-user print email on the same
- session right after the prompt, so a prompt that dies mid-flush must
- not take the email with it."""
- from backend.app.main import _dispatch_outcome_confirmation_safely
- async def _dies_mid_flush(db, *args, **kwargs):
- db.add(Settings(key="x_1898_duplicate", value="a"))
- db.add(Settings(key="x_1898_duplicate", value="b"))
- await db.flush()
- with patch("backend.app.main.dispatch_outcome_confirmation", _dies_mid_flush):
- await _dispatch_outcome_confirmation_safely(db_session, 1, "X1C", {}, 1, {})
- # What _dispatch_user_print_email does next: read from the session.
- # Without the rollback this raises PendingRollbackError.
- await db_session.execute(select(Settings).limit(1))
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_already_answered_archive_is_not_asked_again(self, db_session, printer_factory, archive_factory):
- printer = await printer_factory()
- archive = await archive_factory(
- printer.id, status="completed", confirm_requested=True, user_verdict="good", with_run=False
- )
- sent, ws, notif = await _dispatch(db_session, printer.id, archive.id)
- assert sent is False
- ws.send_print_confirm_request.assert_not_awaited()
- notif.on_print_confirm_request.assert_not_awaited()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_an_archive_that_never_opted_in_is_not_asked(self, db_session, printer_factory, archive_factory):
- printer = await printer_factory()
- archive = await archive_factory(printer.id, status="completed", confirm_requested=False, with_run=False)
- sent, _, notif = await _dispatch(db_session, printer.id, archive.id)
- assert sent is False
- notif.on_print_confirm_request.assert_not_awaited()
|