test_external_print_confirmation_1898.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  1. """Ask for the outcome of prints Bambuddy did not start (#1898 follow-up).
  2. The ask-for-outcome flag rides from a queue item onto the archive at dispatch.
  3. On a farm where most jobs are started at the printer's screen, in Bambu Studio
  4. or in the Handy app there is no queue item to ride from, so every archive
  5. ``on_print_start`` created had ``confirm_requested`` false and the feature
  6. looked broken. ``confirm_outcome_external_prints`` is the missing source.
  7. Both archive-creating branches of ``on_print_start`` are driven here against a
  8. real database: the no-3MF fallback (what a P1S/A1 farm actually hits) and the
  9. normal path that archives a downloaded 3MF.
  10. """
  11. from contextlib import ExitStack
  12. from datetime import datetime, timezone
  13. from unittest.mock import AsyncMock, MagicMock, patch
  14. import pytest
  15. from sqlalchemy import select
  16. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  17. from backend.app.main import (
  18. _active_prints,
  19. _expected_print_creators,
  20. _expected_print_registered_at,
  21. _expected_prints,
  22. _print_ams_mappings,
  23. )
  24. from backend.app.models.archive import PrintArchive
  25. from backend.app.models.print_queue import PrintQueueItem
  26. from backend.app.models.settings import Settings
  27. DISPATCH = "/data/Metadata/plate_1.gcode"
  28. SUBTASK = "Bracket_plate_1"
  29. @pytest.fixture(autouse=True)
  30. def _clear_print_state():
  31. dicts = (
  32. _expected_prints,
  33. _expected_print_registered_at,
  34. _expected_print_creators,
  35. _print_ams_mappings,
  36. _active_prints,
  37. )
  38. for d in dicts:
  39. d.clear()
  40. yield
  41. for d in dicts:
  42. d.clear()
  43. class _StubArchiveService:
  44. """Stands in for ArchiveService on the downloaded-3MF branch.
  45. Writes the row the real service would write, without needing a parseable
  46. 3MF on disk. Everything this test asserts happens *after* the row exists.
  47. """
  48. def __init__(self, db):
  49. self.db = db
  50. async def archive_print(self, **kwargs):
  51. archive = PrintArchive(
  52. printer_id=kwargs.get("printer_id"),
  53. filename=f"{SUBTASK}.gcode.3mf",
  54. file_path=f"archives/test/{SUBTASK}.gcode.3mf",
  55. file_size=2048,
  56. print_name=SUBTASK,
  57. status="printing",
  58. started_at=datetime.now(timezone.utc),
  59. )
  60. self.db.add(archive)
  61. await self.db.commit()
  62. await self.db.refresh(archive)
  63. return archive
  64. async def _drive_print_start(
  65. test_engine, printer, *, download_ok: bool, extra_patches=(), state_fields=None, raw_data=None
  66. ) -> dict:
  67. """Run ``on_print_start`` against the test database.
  68. ``download_ok`` picks the branch: False leaves the 3MF unreachable and the
  69. no-3MF fallback archive is written inline; True takes the ArchiveService
  70. branch.
  71. """
  72. session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  73. state = MagicMock(
  74. current_project_url=f"ftp://{SUBTASK}.gcode.3mf",
  75. sdcard=True,
  76. sdcard_reported=True,
  77. subtask_id="", # a local print; a bare mock attribute would be stored as its repr
  78. )
  79. for name, value in (state_fields or {}).items():
  80. setattr(state, name, value)
  81. patches = [
  82. patch("backend.app.main.async_session", session_maker),
  83. patch("backend.app.core.database.async_session", session_maker),
  84. patch("backend.app.main.download_file_async", new=AsyncMock(return_value=download_ok)),
  85. patch("backend.app.main.download_file_try_paths_async", new=AsyncMock(return_value=None)),
  86. patch("backend.app.main.get_cached_3mf", return_value=None),
  87. patch("backend.app.main.cache_3mf_download"),
  88. patch("backend.app.main.peek_plate_index_in_3mf", return_value=None),
  89. patch("backend.app.main.ArchiveService", _StubArchiveService),
  90. # Imported inside the function, so patching it anywhere else lets the
  91. # directory walk open real sockets.
  92. patch("backend.app.services.bambu_ftp.list_files_async", new=AsyncMock(return_value=[])),
  93. patch("backend.app.main.ftps_handshake_blocked", return_value=False),
  94. patch("backend.app.main.get_ftp_retry_settings", new=AsyncMock(return_value=(False, 3, 2.0, 30))),
  95. patch("backend.app.main._record_energy_start", new_callable=AsyncMock),
  96. patch("backend.app.main._send_print_start_notification", new_callable=AsyncMock),
  97. patch("backend.app.main._maybe_start_layer_timelapse"),
  98. patch("backend.app.main._capture_timelapse_baseline_at_start", new_callable=AsyncMock),
  99. # Real, it would spawn a task that outlives the test by a minute.
  100. patch("backend.app.main._schedule_fallback_3mf_retry"),
  101. patch("backend.app.main._store_spoolman_print_data", new_callable=AsyncMock),
  102. # Imported inside on_print_start; it would try to persist the mocked
  103. # printer state's AMS trays and fail on the MagicMock values.
  104. patch("backend.app.services.usage_tracker.on_print_start", new_callable=AsyncMock),
  105. ]
  106. mocks: dict = {}
  107. with ExitStack() as stack:
  108. for p in [*patches, *extra_patches]:
  109. mocks[p.attribute] = stack.enter_context(p)
  110. notif = stack.enter_context(patch("backend.app.main.notification_service"))
  111. plug = stack.enter_context(patch("backend.app.main.smart_plug_manager"))
  112. ws = stack.enter_context(patch("backend.app.main.ws_manager"))
  113. relay = stack.enter_context(patch("backend.app.main.mqtt_relay"))
  114. pm = stack.enter_context(patch("backend.app.main.printer_manager"))
  115. notif.on_print_start = AsyncMock()
  116. plug.on_print_start = AsyncMock()
  117. ws.send_print_start = AsyncMock()
  118. ws.send_archive_created = AsyncMock()
  119. ws.send_archive_updated = AsyncMock()
  120. relay.on_print_start = AsyncMock()
  121. relay.on_archive_created = AsyncMock()
  122. pm.get_status = MagicMock(return_value=state)
  123. pm.get_printer = MagicMock(return_value=MagicMock(serial_number="TEST1898"))
  124. pm.get_client = MagicMock(return_value=None) # else its mock dispatch id is stored
  125. from backend.app.main import on_print_start
  126. data = {"filename": DISPATCH, "subtask_name": SUBTASK}
  127. if raw_data is not None:
  128. data["raw_data"] = raw_data
  129. await on_print_start(printer.id, data)
  130. return mocks
  131. async def _set_setting(db_session, key: str, value: str) -> None:
  132. db_session.add(Settings(key=key, value=value))
  133. await db_session.commit()
  134. async def _created_archive(db_session, printer_id: int) -> PrintArchive:
  135. db_session.expire_all()
  136. archive = await db_session.scalar(
  137. select(PrintArchive).where(PrintArchive.printer_id == printer_id).order_by(PrintArchive.id.desc()).limit(1)
  138. )
  139. assert archive is not None, "on_print_start created no archive"
  140. return archive
  141. class TestExternalPrintGetsTheOutcomePrompt:
  142. @pytest.mark.asyncio
  143. @pytest.mark.integration
  144. async def test_no_3mf_fallback_asks_when_the_setting_is_on(self, test_engine, db_session, printer_factory):
  145. """The P1S/A1 case: the 3MF cannot be fetched, the archive is written
  146. inline — and that is the row the completion path reads the flag off."""
  147. printer = await printer_factory()
  148. await _set_setting(db_session, "confirm_outcome_external_prints", "true")
  149. await _drive_print_start(test_engine, printer, download_ok=False)
  150. archive = await _created_archive(db_session, printer.id)
  151. assert archive.extra_data.get("no_3mf_available") is True
  152. assert archive.confirm_requested is True
  153. # What the completion path gates the prompt on.
  154. assert archive.user_verdict is None
  155. @pytest.mark.asyncio
  156. @pytest.mark.integration
  157. async def test_the_archive_reaches_the_rest_of_the_1898_machinery(self, test_engine, db_session, printer_factory):
  158. """An externally started print is now a pending confirmation like any
  159. other: the prompt the completion path emits is gated on exactly these
  160. two fields, and the plate-release default resolves the same row."""
  161. from backend.app.services.print_confirmation import resolve_pending_confirmation_as_good
  162. printer = await printer_factory()
  163. printer_id = printer.id
  164. await _set_setting(db_session, "confirm_outcome_external_prints", "true")
  165. await _drive_print_start(test_engine, printer, download_ok=False)
  166. archive = await _created_archive(db_session, printer_id)
  167. archive.status = "completed"
  168. await db_session.commit()
  169. assert await resolve_pending_confirmation_as_good(db_session, printer_id) == archive.id
  170. @pytest.mark.asyncio
  171. @pytest.mark.integration
  172. async def test_downloaded_3mf_archive_asks_when_the_setting_is_on(self, test_engine, db_session, printer_factory):
  173. """The other creation branch: the 3MF arrived and ArchiveService wrote
  174. the row. The flag is set on the row afterwards rather than passed into
  175. archive_print, which also serves the queue dispatcher."""
  176. printer = await printer_factory()
  177. await _set_setting(db_session, "confirm_outcome_external_prints", "true")
  178. await _drive_print_start(test_engine, printer, download_ok=True)
  179. archive = await _created_archive(db_session, printer.id)
  180. assert archive.file_path.endswith(".3mf")
  181. assert archive.confirm_requested is True
  182. @pytest.mark.asyncio
  183. @pytest.mark.integration
  184. async def test_default_leaves_external_prints_alone(self, test_engine, db_session, printer_factory):
  185. """Default off: nothing about today's behaviour changes for an install
  186. that never touches the new setting — no row for it at all."""
  187. printer = await printer_factory()
  188. await _drive_print_start(test_engine, printer, download_ok=False)
  189. archive = await _created_archive(db_session, printer.id)
  190. assert archive.confirm_requested is False
  191. @pytest.mark.asyncio
  192. @pytest.mark.integration
  193. async def test_setting_off_does_not_ask(self, test_engine, db_session, printer_factory):
  194. printer = await printer_factory()
  195. await _set_setting(db_session, "confirm_outcome_external_prints", "false")
  196. await _drive_print_start(test_engine, printer, download_ok=False)
  197. assert (await _created_archive(db_session, printer.id)).confirm_requested is False
  198. @pytest.mark.asyncio
  199. @pytest.mark.integration
  200. async def test_an_odd_stored_value_counts_as_off(self, test_engine, db_session, printer_factory):
  201. """Settings live in a VARCHAR column and every other reader treats
  202. anything that is not "true" as off."""
  203. printer = await printer_factory()
  204. await _set_setting(db_session, "confirm_outcome_external_prints", "None")
  205. await _drive_print_start(test_engine, printer, download_ok=False)
  206. assert (await _created_archive(db_session, printer.id)).confirm_requested is False
  207. class TestAFailureHereCostsOnlyThePrompt:
  208. """The prompt is optional; the rest of print start is not.
  209. After a failed statement, a rollback of the whole transaction expires every
  210. object the session holds, and on an async session the next read of one
  211. raises instead of reloading. The print start below the check reads both
  212. the printer and the archive, so the check runs in a savepoint, and the flag
  213. write reloads what it used after its own rollback.
  214. """
  215. @staticmethod
  216. def _assert_print_start_finished(mocks, archive_id: int) -> None:
  217. assert archive_id in _active_prints.values()
  218. mocks["_record_energy_start"].assert_awaited()
  219. assert mocks["_record_energy_start"].await_args.args[0].id == archive_id
  220. mocks["_capture_timelapse_baseline_at_start"].assert_awaited()
  221. assert mocks["_capture_timelapse_baseline_at_start"].await_args.kwargs["archive_id"] == archive_id
  222. @pytest.mark.asyncio
  223. @pytest.mark.integration
  224. @pytest.mark.parametrize("download_ok", [True, False])
  225. async def test_a_failing_query_in_the_check(self, test_engine, db_session, printer_factory, download_ok):
  226. from sqlalchemy import text
  227. from backend.app.api.routes import settings as settings_routes
  228. real_get_setting = settings_routes.get_setting
  229. async def _broken_for_this_key(db, key):
  230. if key == "confirm_outcome_external_prints":
  231. # A real failed statement, not just an exception: this is what
  232. # leaves the transaction needing a rollback.
  233. await db.execute(text("SELECT no_such_column FROM settings"))
  234. return await real_get_setting(db, key)
  235. printer = await printer_factory()
  236. await _set_setting(db_session, "confirm_outcome_external_prints", "true")
  237. mocks = await _drive_print_start(
  238. test_engine,
  239. printer,
  240. download_ok=download_ok,
  241. extra_patches=[patch.object(settings_routes, "get_setting", _broken_for_this_key)],
  242. )
  243. archive = await _created_archive(db_session, printer.id)
  244. assert archive.confirm_requested is False
  245. self._assert_print_start_finished(mocks, archive.id)
  246. @pytest.mark.asyncio
  247. @pytest.mark.integration
  248. async def test_a_failing_flag_write(self, test_engine, db_session, printer_factory):
  249. async def _yes_but_poison_the_commit(db, printer_id, observed_name=None):
  250. # Two rows for one unique key: the commit that carries the flag fails.
  251. db.add(Settings(key="x_1898_duplicate", value="a"))
  252. db.add(Settings(key="x_1898_duplicate", value="b"))
  253. return True
  254. printer = await printer_factory()
  255. mocks = await _drive_print_start(
  256. test_engine,
  257. printer,
  258. download_ok=True,
  259. extra_patches=[patch("backend.app.main._ask_outcome_for_external_print", _yes_but_poison_the_commit)],
  260. )
  261. archive = await _created_archive(db_session, printer.id)
  262. # archive_print committed the row; only the flag is lost.
  263. assert archive.confirm_requested is False
  264. self._assert_print_start_finished(mocks, archive.id)
  265. class TestAQueuedPrintStillDecidesForItself:
  266. @pytest.mark.asyncio
  267. @pytest.mark.integration
  268. async def test_a_dispatched_job_is_not_overridden_by_the_setting(
  269. self, test_engine, db_session, printer_factory, archive_factory
  270. ):
  271. """A queue item that deliberately has the ask-for-outcome flag off must
  272. stay off. A restart mid-print empties the expected-print registry, so
  273. the queue row — which the scheduler commits to "printing" before the
  274. MQTT send — is the durable record that Bambuddy started this."""
  275. printer = await printer_factory()
  276. # Dispatched but not yet reported as started: the archive the scheduler
  277. # attached to the row only turns "printing" once on_print_start runs, so
  278. # the name-match resume above cannot find it and the queue row is the
  279. # only record that Bambuddy sent this print.
  280. source = await archive_factory(printer.id, filename=f"{SUBTASK}.gcode.3mf", status="pending", with_run=False)
  281. db_session.add(
  282. PrintQueueItem(
  283. printer_id=printer.id,
  284. archive_id=source.id,
  285. status="printing",
  286. confirm_outcome=False,
  287. )
  288. )
  289. await db_session.commit()
  290. await _set_setting(db_session, "confirm_outcome_external_prints", "true")
  291. await _drive_print_start(test_engine, printer, download_ok=False)
  292. archive = await _created_archive(db_session, printer.id)
  293. assert archive.confirm_requested is False
  294. @pytest.mark.asyncio
  295. @pytest.mark.integration
  296. async def test_a_queue_items_own_yes_survives_print_start(
  297. self, test_engine, db_session, printer_factory, archive_factory
  298. ):
  299. """The dispatcher copies ``confirm_outcome`` onto the archive before
  300. the print starts; the expected-print branch of ``on_print_start`` must
  301. leave that alone even while the external-print setting is off."""
  302. from backend.app.main import register_expected_print
  303. printer = await printer_factory()
  304. archive = await archive_factory(
  305. printer.id,
  306. filename=f"{SUBTASK}.gcode.3mf",
  307. status="pending",
  308. confirm_requested=True,
  309. with_run=False,
  310. )
  311. db_session.add(
  312. PrintQueueItem(
  313. printer_id=printer.id,
  314. archive_id=archive.id,
  315. status="printing",
  316. confirm_outcome=True,
  317. )
  318. )
  319. await db_session.commit()
  320. archive_id, printer_id = archive.id, printer.id
  321. register_expected_print(printer_id, f"{SUBTASK}.gcode.3mf", archive_id)
  322. await _drive_print_start(test_engine, printer, download_ok=False)
  323. db_session.expire_all()
  324. refreshed = await db_session.get(PrintArchive, archive_id)
  325. assert refreshed.status == "printing"
  326. assert refreshed.confirm_requested is True
  327. # No second row for the same print.
  328. rows = (await db_session.scalars(select(PrintArchive).where(PrintArchive.printer_id == printer_id))).all()
  329. assert len(rows) == 1
  330. class TestSettingsRoundTrip:
  331. @pytest.mark.asyncio
  332. @pytest.mark.integration
  333. async def test_defaults_to_off_and_round_trips(self, async_client):
  334. response = await async_client.get("/api/v1/settings/")
  335. assert response.status_code == 200
  336. assert response.json()["confirm_outcome_external_prints"] is False
  337. response = await async_client.put("/api/v1/settings/", json={"confirm_outcome_external_prints": True})
  338. assert response.status_code == 200
  339. assert response.json()["confirm_outcome_external_prints"] is True
  340. assert (await async_client.get("/api/v1/settings/")).json()["confirm_outcome_external_prints"] is True
  341. response = await async_client.put("/api/v1/settings/", json={"confirm_outcome_external_prints": False})
  342. assert response.status_code == 200
  343. assert (await async_client.get("/api/v1/settings/")).json()["confirm_outcome_external_prints"] is False
  344. @pytest.mark.asyncio
  345. @pytest.mark.integration
  346. async def test_updating_it_leaves_the_per_job_default_alone(self, async_client):
  347. """Two different questions: one seeds the per-job toggle in the print
  348. dialog, the other covers prints that never see that dialog."""
  349. await async_client.put("/api/v1/settings/", json={"default_confirm_outcome": True})
  350. body = (await async_client.put("/api/v1/settings/", json={"confirm_outcome_external_prints": True})).json()
  351. assert body["default_confirm_outcome"] is True
  352. assert body["confirm_outcome_external_prints"] is True
  353. class TestAStrandedQueueRowDoesNotMuteTheSetting:
  354. """A ``printing`` row is not proof that Bambuddy started what is printing now.
  355. ``_completion_belongs_to_queue_item`` deliberately leaves a row open when a
  356. completion's subtask name disagrees with the file it was dispatched with,
  357. and the scheduler's stranded sweep only takes it back once the printer has
  358. sat connected and terminal for minutes. Treating any such row as "we
  359. dispatched this" switched the setting off for every screen-started print in
  360. between -- the exact symptom the setting exists to cure.
  361. """
  362. @pytest.mark.asyncio
  363. @pytest.mark.integration
  364. async def test_a_row_left_open_for_another_file_still_asks(
  365. self, test_engine, db_session, printer_factory, archive_factory
  366. ):
  367. printer = await printer_factory()
  368. stranded_for = await archive_factory(
  369. printer.id, filename="SomeOtherJob.gcode.3mf", status="printing", with_run=False
  370. )
  371. db_session.add(
  372. PrintQueueItem(
  373. printer_id=printer.id,
  374. archive_id=stranded_for.id,
  375. status="printing",
  376. confirm_outcome=False,
  377. )
  378. )
  379. await db_session.commit()
  380. await _set_setting(db_session, "confirm_outcome_external_prints", "true")
  381. stranded_filename = stranded_for.filename
  382. await _drive_print_start(test_engine, printer, download_ok=False)
  383. archive = await _created_archive(db_session, printer.id)
  384. assert archive.filename != stranded_filename
  385. assert archive.confirm_requested is True
  386. class TestAReprintAsksAgain:
  387. """The reprint reuses the archive row, and the completion prompt is gated on
  388. ``user_verdict is None`` -- so without a reset the second run inherits the
  389. first run's answer and is never asked about."""
  390. @pytest.mark.asyncio
  391. @pytest.mark.integration
  392. async def test_the_previous_runs_verdict_does_not_carry_over(
  393. self, test_engine, db_session, printer_factory, archive_factory
  394. ):
  395. from backend.app.main import register_expected_print
  396. printer = await printer_factory()
  397. archive = await archive_factory(
  398. printer.id,
  399. filename=f"{SUBTASK}.gcode.3mf",
  400. status="completed",
  401. confirm_requested=True,
  402. user_verdict="good",
  403. confirm_token="token-from-the-first-run",
  404. with_run=False,
  405. )
  406. archive_id, printer_id = archive.id, printer.id
  407. register_expected_print(printer_id, f"{SUBTASK}.gcode.3mf", archive_id)
  408. await _drive_print_start(test_engine, printer, download_ok=False)
  409. db_session.expire_all()
  410. refreshed = await db_session.get(PrintArchive, archive_id)
  411. assert refreshed.status == "printing"
  412. assert refreshed.confirm_requested is True
  413. assert refreshed.user_verdict is None
  414. assert refreshed.confirm_token is None
  415. # And the completion really does ask again, which is the point.
  416. refreshed.status = "completed"
  417. await db_session.commit()
  418. sent, ws, notif = await _dispatch(db_session, printer_id, archive_id)
  419. assert sent is True
  420. ws.send_print_confirm_request.assert_awaited_once()
  421. notif.on_print_confirm_request.assert_awaited_once()
  422. @pytest.mark.asyncio
  423. @pytest.mark.integration
  424. async def test_an_archive_that_was_never_asked_about_keeps_its_verdict(
  425. self, test_engine, db_session, printer_factory, archive_factory
  426. ):
  427. """The reset is scoped to rows that ask. An archive answered once and
  428. later reprinted with the flag off must keep the answer it has."""
  429. from backend.app.main import register_expected_print
  430. printer = await printer_factory()
  431. archive = await archive_factory(
  432. printer.id,
  433. filename=f"{SUBTASK}.gcode.3mf",
  434. status="completed",
  435. confirm_requested=False,
  436. user_verdict="reject",
  437. with_run=False,
  438. )
  439. archive_id = archive.id
  440. register_expected_print(printer.id, f"{SUBTASK}.gcode.3mf", archive_id)
  441. await _drive_print_start(test_engine, printer, download_ok=False)
  442. db_session.expire_all()
  443. assert (await db_session.get(PrintArchive, archive_id)).user_verdict == "reject"
  444. async def _dispatch(db_session, printer_id: int, archive_id: int, **kwargs):
  445. """Run the completion path's outcome dispatch with the two emitters mocked."""
  446. from backend.app.main import dispatch_outcome_confirmation
  447. with (
  448. patch("backend.app.main.ws_manager") as ws,
  449. patch("backend.app.main.notification_service") as notif,
  450. ):
  451. ws.send_print_confirm_request = AsyncMock()
  452. notif.on_print_confirm_request = AsyncMock()
  453. sent = await dispatch_outcome_confirmation(
  454. db_session,
  455. printer_id,
  456. "Bench P1S",
  457. {"subtask_name": SUBTASK},
  458. archive_id,
  459. **kwargs,
  460. )
  461. return sent, ws, notif
  462. class TestTheCompletionEmitsThePrompt:
  463. """The half of the feature the user actually sees.
  464. Everything else here asserts a column value; this drives the block
  465. ``on_print_complete``'s notification task runs -- which is wrapped in a bare
  466. ``except Exception`` that only logs, so a regression in it is invisible
  467. unless something pins it.
  468. """
  469. @pytest.mark.asyncio
  470. @pytest.mark.integration
  471. async def test_an_external_print_gets_a_prompt_with_one_tap_links(self, test_engine, db_session, printer_factory):
  472. printer = await printer_factory()
  473. printer_id = printer.id
  474. await _set_setting(db_session, "confirm_outcome_external_prints", "true")
  475. await _set_setting(db_session, "external_url", "https://farm.example.com/")
  476. await _drive_print_start(test_engine, printer, download_ok=False)
  477. archive = await _created_archive(db_session, printer_id)
  478. archive_id = archive.id
  479. assert archive.confirm_requested is True
  480. archive.status = "completed"
  481. await db_session.commit()
  482. sent, ws, notif = await _dispatch(db_session, printer_id, archive_id)
  483. assert sent is True
  484. ws.send_print_confirm_request.assert_awaited_once()
  485. assert ws.send_print_confirm_request.await_args.args[1]["archive_id"] == archive_id
  486. # Nobody owns an external print: everyone on the printer is asked
  487. assert ws.send_print_confirm_request.await_args.kwargs["user_ids"] is None
  488. notif.on_print_confirm_request.assert_awaited_once()
  489. kwargs = notif.on_print_confirm_request.await_args.kwargs
  490. token = (await db_session.get(PrintArchive, archive_id)).confirm_token
  491. assert token, "the one-tap links need a minted capability token"
  492. assert kwargs["good_url"] == f"https://farm.example.com/api/v1/archives/confirm/{token}/good"
  493. assert kwargs["reject_url"] == f"https://farm.example.com/api/v1/archives/confirm/{token}/reject"
  494. assert kwargs["confirm_url"] == f"https://farm.example.com/archives?confirm={archive_id}"
  495. @pytest.mark.asyncio
  496. @pytest.mark.integration
  497. async def test_the_links_stay_absolute_without_an_external_url(self, test_engine, db_session, printer_factory):
  498. """Telegram's inline keyboard and ntfy's action buttons are both dropped
  499. for a relative URL, so an install that never set external_url used to
  500. get a message carrying two unusable paths and no buttons at all."""
  501. printer = await printer_factory()
  502. printer_id = printer.id
  503. await _set_setting(db_session, "confirm_outcome_external_prints", "true")
  504. await _drive_print_start(test_engine, printer, download_ok=False)
  505. archive = await _created_archive(db_session, printer_id)
  506. archive_id = archive.id
  507. archive.status = "completed"
  508. await db_session.commit()
  509. _, _, notif = await _dispatch(db_session, printer_id, archive_id)
  510. kwargs = notif.on_print_confirm_request.await_args.kwargs
  511. assert kwargs["good_url"].startswith("http")
  512. assert kwargs["reject_url"].startswith("http")
  513. assert kwargs["confirm_url"].startswith("http")
  514. @pytest.mark.asyncio
  515. @pytest.mark.integration
  516. async def test_a_failed_prompt_leaves_the_session_usable_for_the_email(self, db_session):
  517. """The completion task sends the per-user print email on the same
  518. session right after the prompt, so a prompt that dies mid-flush must
  519. not take the email with it."""
  520. from backend.app.main import _dispatch_outcome_confirmation_safely
  521. async def _dies_mid_flush(db, *args, **kwargs):
  522. db.add(Settings(key="x_1898_duplicate", value="a"))
  523. db.add(Settings(key="x_1898_duplicate", value="b"))
  524. await db.flush()
  525. with patch("backend.app.main.dispatch_outcome_confirmation", _dies_mid_flush):
  526. await _dispatch_outcome_confirmation_safely(db_session, 1, "X1C", {}, 1, {})
  527. # What _dispatch_user_print_email does next: read from the session.
  528. # Without the rollback this raises PendingRollbackError.
  529. await db_session.execute(select(Settings).limit(1))
  530. @pytest.mark.asyncio
  531. @pytest.mark.integration
  532. async def test_an_already_answered_archive_is_not_asked_again(self, db_session, printer_factory, archive_factory):
  533. printer = await printer_factory()
  534. archive = await archive_factory(
  535. printer.id, status="completed", confirm_requested=True, user_verdict="good", with_run=False
  536. )
  537. sent, ws, notif = await _dispatch(db_session, printer.id, archive.id)
  538. assert sent is False
  539. ws.send_print_confirm_request.assert_not_awaited()
  540. notif.on_print_confirm_request.assert_not_awaited()
  541. @pytest.mark.asyncio
  542. @pytest.mark.integration
  543. async def test_an_archive_that_never_opted_in_is_not_asked(self, db_session, printer_factory, archive_factory):
  544. printer = await printer_factory()
  545. archive = await archive_factory(printer.id, status="completed", confirm_requested=False, with_run=False)
  546. sent, _, notif = await _dispatch(db_session, printer.id, archive.id)
  547. assert sent is False
  548. notif.on_print_confirm_request.assert_not_awaited()
  549. @pytest.mark.asyncio
  550. @pytest.mark.integration
  551. @pytest.mark.parametrize("auth_on", [True, False])
  552. async def test_an_owned_print_asks_its_owner_and_who_may_answer_for_everyone(
  553. test_engine, db_session, printer_factory, archive_factory, auth_on
  554. ):
  555. from backend.app.models.group import Group
  556. from backend.app.models.user import User
  557. printer = await printer_factory()
  558. if auth_on:
  559. await _set_setting(db_session, "auth_enabled", "true")
  560. staff = Group(name="staff", permissions=["archives:update_all"])
  561. others = Group(name="others", permissions=["archives:update_own"])
  562. owner = User(username="prompted", password_hash="x", role="user", groups=[others])
  563. manager = User(username="manager", password_hash="x", role="user", groups=[staff])
  564. bystander = User(username="bystander", password_hash="x", role="user", groups=[others])
  565. gone = User(username="gone", password_hash="x", role="user", groups=[staff], is_active=False)
  566. db_session.add_all([staff, others, owner, manager, bystander, gone])
  567. await db_session.commit()
  568. archive = await archive_factory(printer.id, status="completed", created_by_id=owner.id, confirm_requested=True)
  569. sent, ws, _ = await _dispatch(db_session, printer.id, archive.id)
  570. assert sent is True
  571. asked = ws.send_print_confirm_request.await_args.kwargs["user_ids"]
  572. assert asked == ({owner.id, manager.id} if auth_on else None)