test_print_start_printer_deleted_midway.py 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739
  1. """A printer deleted while its print-start handler downloads the 3MF.
  2. Deleting a printer cuts its MQTT link, but an ``on_print_start`` already in its
  3. FTP phase (seconds to minutes) carried on: it wrote a "printing" archive that no
  4. completion would ever end, registered it in ``_active_prints``, started a layer
  5. timelapse and re-added the 3MF cache entry ``forget_printer`` had cleared. It
  6. now checks the printer still exists once the downloads are done, and forgets it
  7. again if not.
  8. """
  9. from unittest.mock import AsyncMock, patch
  10. import pytest
  11. from sqlalchemy import delete, select, update
  12. from backend.app.main import _active_prints
  13. from backend.app.models.archive import PrintArchive
  14. from backend.app.models.printer import Printer
  15. from backend.tests.integration.test_external_print_confirmation_1898 import (
  16. _clear_print_state, # noqa: F401 - autouse fixture
  17. _drive_print_start,
  18. _StubArchiveService,
  19. )
  20. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  21. def _download_deleting(db_session, printer_id: int, *, ok: bool):
  22. """The FTP download, during which the printer is deleted."""
  23. async def download(*_args, **_kwargs):
  24. await db_session.execute(delete(Printer).where(Printer.id == printer_id))
  25. await db_session.commit()
  26. return ok
  27. return patch("backend.app.main.download_file_async", new=AsyncMock(side_effect=download))
  28. class _ArchiveService(_StubArchiveService):
  29. """The #1898 stub, recording its writes, with the real delete (the check
  30. after the archive's commit deletes through it)."""
  31. written: list[int] = []
  32. async def archive_print(self, **kwargs):
  33. _ArchiveService.written.append(kwargs.get("printer_id"))
  34. return await super().archive_print(**kwargs)
  35. async def delete_archive(self, archive_id: int) -> bool:
  36. from backend.app.services.archive import ArchiveService
  37. return await ArchiveService(self.db).delete_archive(archive_id)
  38. async def _archives(db_session, printer_id: int) -> list[PrintArchive]:
  39. db_session.expire_all()
  40. return list((await db_session.scalars(select(PrintArchive).where(PrintArchive.printer_id == printer_id))).all())
  41. @pytest.mark.parametrize("download_ok", [False, True], ids=["no-3mf fallback", "downloaded 3mf"])
  42. async def test_a_printer_deleted_during_the_download_gets_nothing(
  43. test_engine, db_session, printer_factory, download_ok
  44. ):
  45. printer = await printer_factory()
  46. printer_id = printer.id
  47. _ArchiveService.written = []
  48. ask_outcome = AsyncMock(return_value=False) # builds the fallback row
  49. with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
  50. mocks = await _drive_print_start(
  51. test_engine,
  52. printer,
  53. download_ok=download_ok,
  54. extra_patches=(
  55. _download_deleting(db_session, printer_id, ok=download_ok),
  56. patch("backend.app.main.ArchiveService", _ArchiveService),
  57. patch("backend.app.main._ask_outcome_for_external_print", new=ask_outcome),
  58. ),
  59. )
  60. # Checked before writing: no archive was even started (the check after the
  61. # commit would remove one, but only after it was written).
  62. assert _ArchiveService.written == []
  63. ask_outcome.assert_not_awaited()
  64. assert await _archives(db_session, printer_id) == []
  65. assert not [key for key in _active_prints if key[0] == printer_id]
  66. mocks["_maybe_start_layer_timelapse"].assert_not_called()
  67. # What the download wrote under the id (the 3MF cache entry) goes again.
  68. forget.assert_awaited_once_with(printer_id)
  69. @pytest.mark.parametrize("download_ok", [False, True], ids=["no-3mf fallback", "downloaded 3mf"])
  70. async def test_a_printer_deleted_while_its_archive_is_written_gets_nothing(
  71. test_engine, db_session, printer_factory, download_ok
  72. ):
  73. """The delete commits between the check and the archive's commit (the route
  74. sweeps the printer's archives before it commits): the row written after
  75. that sweep is removed again."""
  76. printer = await printer_factory()
  77. printer_id = printer.id
  78. async def delete_printer():
  79. await db_session.execute(delete(Printer).where(Printer.id == printer_id))
  80. await db_session.commit()
  81. class _DeletingArchiveService(_ArchiveService):
  82. async def archive_print(self, **kwargs):
  83. await delete_printer()
  84. return await super().archive_print(**kwargs)
  85. async def ask_outcome(*_args, **_kwargs): # runs while the fallback row is built
  86. await delete_printer()
  87. return False
  88. with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
  89. mocks = await _drive_print_start(
  90. test_engine,
  91. printer,
  92. download_ok=download_ok,
  93. extra_patches=(
  94. patch("backend.app.main.ArchiveService", _DeletingArchiveService),
  95. patch("backend.app.main._ask_outcome_for_external_print", new=AsyncMock(side_effect=ask_outcome)),
  96. ),
  97. )
  98. assert await _archives(db_session, printer_id) == []
  99. assert not [key for key in _active_prints if key[0] == printer_id]
  100. mocks["_maybe_start_layer_timelapse"].assert_not_called()
  101. forget.assert_awaited_once_with(printer_id)
  102. async def test_an_archive_kept_by_the_delete_is_left_alone(test_engine, db_session, printer_factory):
  103. """Deleted with "keep archives" right after the archive's commit: the route
  104. detached it (printer_id NULL) as the user asked, so it stays. Nothing is
  105. registered for it."""
  106. printer = await printer_factory()
  107. printer_id = printer.id
  108. async def delete_keeping_archives(*_args, **_kwargs): # runs after archive_print's commit
  109. await db_session.execute(
  110. update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None)
  111. )
  112. await db_session.execute(delete(Printer).where(Printer.id == printer_id))
  113. await db_session.commit()
  114. return False
  115. with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
  116. mocks = await _drive_print_start(
  117. test_engine,
  118. printer,
  119. download_ok=True,
  120. extra_patches=(
  121. patch("backend.app.main.ArchiveService", _ArchiveService),
  122. patch(
  123. "backend.app.main._ask_outcome_for_external_print",
  124. new=AsyncMock(side_effect=delete_keeping_archives),
  125. ),
  126. ),
  127. )
  128. db_session.expire_all()
  129. kept = (await db_session.scalars(select(PrintArchive))).all()
  130. assert len(kept) == 1 and kept[0].printer_id is None
  131. assert not [key for key in _active_prints if key[0] == printer_id]
  132. mocks["_maybe_start_layer_timelapse"].assert_not_called()
  133. forget.assert_awaited_once_with(printer_id)
  134. async def test_a_printer_still_there_gets_its_archive(test_engine, db_session, printer_factory):
  135. printer = await printer_factory()
  136. with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
  137. await _drive_print_start(test_engine, printer, download_ok=False)
  138. assert len(await _archives(db_session, printer.id)) == 1
  139. forget.assert_not_awaited()
  140. async def test_a_queue_print_whose_printer_was_deleted_during_the_upload_credits_nobody(
  141. db_session, test_engine, monkeypatch
  142. ):
  143. """The dispatch would write the owner under the deleted printer's id after
  144. forget_printer cleared it."""
  145. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  146. from backend.app.models.print_queue import PrintQueueItem
  147. from backend.app.models.user import User
  148. from backend.app.services import print_scheduler as scheduler_module
  149. from backend.app.services.print_scheduler import PrintScheduler
  150. owner = User(username="olga", password_hash="x", role="user")
  151. printer = Printer(name="P", serial_number="DELMID1", ip_address="192.0.2.11", access_code="x")
  152. db_session.add_all([owner, printer])
  153. await db_session.commit()
  154. item = PrintQueueItem(printer_id=printer.id, position=1, created_by_id=owner.id)
  155. db_session.add(item)
  156. await db_session.commit()
  157. other = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  158. async with other() as admin_db: # meanwhile, through the API (SQLite keeps the item)
  159. await admin_db.execute(delete(Printer).where(Printer.id == printer.id))
  160. await admin_db.commit()
  161. credited: list = []
  162. monkeypatch.setattr(scheduler_module.printer_manager, "set_current_print_user", lambda *args: credited.append(args))
  163. await PrintScheduler()._propagate_owner_to_printer_manager(db_session, item)
  164. assert credited == []
  165. async def _queued_archive(db_session, printer_id: int) -> int:
  166. """An archive Bambuddy dispatched to the printer, waiting for its start."""
  167. from datetime import datetime, timezone
  168. from backend.app.main import register_expected_print
  169. archive = PrintArchive(
  170. printer_id=printer_id,
  171. filename="Bracket_plate_1.3mf",
  172. file_path="archives/test/Bracket_plate_1.3mf",
  173. file_size=1,
  174. print_name="Bracket_plate_1",
  175. status="completed",
  176. started_at=datetime.now(timezone.utc),
  177. )
  178. db_session.add(archive)
  179. await db_session.commit()
  180. register_expected_print(printer_id, "Bracket_plate_1.3mf", archive.id)
  181. return archive.id
  182. async def _delete_keeping_archives(db_session, printer_id: int) -> None:
  183. await db_session.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None))
  184. await db_session.execute(delete(Printer).where(Printer.id == printer_id))
  185. await db_session.commit()
  186. async def _archive(db_session, archive_id: int) -> PrintArchive:
  187. db_session.expire_all()
  188. return await db_session.get(PrintArchive, archive_id)
  189. async def test_a_queued_print_of_a_deleted_printer_is_not_adopted(test_engine, db_session, printer_factory):
  190. """Deleted with "keep archives" before the start was handled (the plate
  191. check waits seconds), but before the route forgot the expected print."""
  192. from backend.app import main
  193. printer = await printer_factory()
  194. printer_id = printer.id
  195. archive_id = await _queued_archive(db_session, printer_id)
  196. real = main._printer_gone
  197. deleted = []
  198. async def deleted_before_the_check(db, pid):
  199. if not deleted: # after the handler loaded the printer, before the branch
  200. deleted.append(pid)
  201. await _delete_keeping_archives(db_session, printer_id)
  202. return await real(db, pid)
  203. with (
  204. patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
  205. patch("backend.app.main._printer_gone", new=deleted_before_the_check),
  206. ):
  207. mocks = await _drive_print_start(test_engine, printer, download_ok=False)
  208. archive = await _archive(db_session, archive_id)
  209. assert archive.printer_id is None and archive.status == "completed"
  210. assert not [key for key in _active_prints if key[0] == printer_id]
  211. mocks["_maybe_start_layer_timelapse"].assert_not_called()
  212. forget.assert_awaited_once_with(printer_id)
  213. async def test_a_queued_print_whose_printer_goes_while_it_is_adopted_is_left_as_kept(
  214. test_engine, db_session, printer_factory, tmp_path, monkeypatch
  215. ):
  216. """The route's "keep archives" update lands between the check and the
  217. commit (the route's transaction lasts seconds), which then adopted the
  218. archive anyway: pointed back at the deleted printer, "printing" forever,
  219. its verdict cleared and its timelapse video deleted from disk."""
  220. from backend.app import main
  221. monkeypatch.setattr(main.app_settings, "base_dir", tmp_path)
  222. video = tmp_path / "archives" / "run.mp4"
  223. video.parent.mkdir(parents=True)
  224. video.write_bytes(b"mp4")
  225. printer = await printer_factory()
  226. printer_id = printer.id
  227. archive_id = await _queued_archive(db_session, printer_id)
  228. kept = await db_session.get(PrintArchive, archive_id)
  229. kept.timelapse_path = "archives/run.mp4" # same (unrecorded) plate: a reprint would delete it
  230. kept.confirm_requested = True
  231. kept.user_verdict = "good"
  232. await db_session.commit()
  233. real = main._printer_gone
  234. calls = []
  235. async def gone_after_the_check(db, pid):
  236. calls.append(pid)
  237. if len(calls) == 1:
  238. await _delete_keeping_archives(db_session, printer_id)
  239. return False # read before the delete committed
  240. return await real(db, pid)
  241. with (
  242. patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
  243. patch("backend.app.main._printer_gone", new=gone_after_the_check),
  244. ):
  245. mocks = await _drive_print_start(test_engine, printer, download_ok=False)
  246. archive = await _archive(db_session, archive_id)
  247. assert archive.printer_id is None
  248. assert archive.status == "completed"
  249. assert archive.user_verdict == "good"
  250. assert archive.timelapse_path == "archives/run.mp4"
  251. assert video.exists()
  252. assert not [key for key in _active_prints if key[0] == printer_id]
  253. mocks["_maybe_start_layer_timelapse"].assert_not_called()
  254. forget.assert_awaited_once_with(printer_id)
  255. async def test_another_printers_archive_adopted_for_a_deleted_printer_stays_that_printers(
  256. test_engine, db_session, printer_factory
  257. ):
  258. """A reprint or queue item can reuse printer A's archive on printer B. B
  259. deleted while it is adopted: the route never touched the row (it was A's),
  260. so it goes back to A, not to no printer."""
  261. from backend.app import main
  262. owner = await printer_factory()
  263. target = await printer_factory()
  264. owner_id, target_id = owner.id, target.id
  265. archive_id = await _queued_archive(db_session, owner_id)
  266. main._expected_prints.clear()
  267. main.register_expected_print(target_id, "Bracket_plate_1.3mf", archive_id)
  268. real = main._printer_gone
  269. calls = []
  270. async def gone_after_the_check(db, pid):
  271. calls.append(pid)
  272. if len(calls) == 1:
  273. await db_session.execute(delete(Printer).where(Printer.id == target_id))
  274. await db_session.commit()
  275. return False # read before the delete committed
  276. return await real(db, pid)
  277. with (
  278. patch("backend.app.main.forget_printer", new_callable=AsyncMock),
  279. patch("backend.app.main._printer_gone", new=gone_after_the_check),
  280. ):
  281. await _drive_print_start(test_engine, target, download_ok=False)
  282. archive = await _archive(db_session, archive_id)
  283. assert archive.printer_id == owner_id
  284. assert archive.status == "completed"
  285. @pytest.mark.sqlite_only # deletes the printer with its archives still attached
  286. @pytest.mark.parametrize("branch", ["fallback", "downloaded", "queued"])
  287. async def test_a_printer_deleted_during_the_last_steps_is_forgotten_again(
  288. test_engine, db_session, printer_factory, branch
  289. ):
  290. """After the last check the handler still waits on the smart plug, the
  291. notifications and the timelapse baseline's FTP listing; the route may
  292. delete the printer and forget it meanwhile, and the handler then writes
  293. again (the baseline). It forgets the printer once more at the end."""
  294. printer = await printer_factory()
  295. printer_id = printer.id
  296. if branch == "queued":
  297. await _queued_archive(db_session, printer_id)
  298. async def deleted_during_the_listing(*_args, **_kwargs):
  299. await db_session.execute(delete(Printer).where(Printer.id == printer_id))
  300. await db_session.commit()
  301. with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
  302. await _drive_print_start(
  303. test_engine,
  304. printer,
  305. download_ok=branch == "downloaded",
  306. extra_patches=(
  307. patch("backend.app.main.ArchiveService", _ArchiveService),
  308. patch(
  309. "backend.app.main._capture_timelapse_baseline_at_start",
  310. new=AsyncMock(side_effect=deleted_during_the_listing),
  311. ),
  312. ),
  313. )
  314. forget.assert_awaited_once_with(printer_id)
  315. async def test_the_last_check_works_on_a_session_the_late_steps_broke(test_engine, db_session, printer_factory):
  316. """The smart plug's energy write hits the archive the delete removed and
  317. leaves the handler's session needing a rollback: the check must not use it."""
  318. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  319. from backend.app import main
  320. printer = await printer_factory()
  321. printer_id = printer.id
  322. archive_id = await _queued_archive(db_session, printer_id)
  323. maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  324. async with maker() as handler_db:
  325. archive = await handler_db.get(PrintArchive, archive_id)
  326. await db_session.execute(delete(PrintArchive).where(PrintArchive.id == archive_id))
  327. await db_session.execute(delete(Printer).where(Printer.id == printer_id))
  328. await db_session.commit()
  329. archive.energy_start_kwh = 1.0
  330. with pytest.raises(Exception): # noqa: B017 - StaleDataError, as _record_energy_start swallows it
  331. await handler_db.commit()
  332. with (
  333. patch("backend.app.main.async_session", maker),
  334. patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
  335. ):
  336. await main._forget_if_printer_gone(printer_id)
  337. forget.assert_awaited_once_with(printer_id)
  338. async def test_the_last_check_never_raises(printer_factory):
  339. """It runs inside the fallback branch's try: an error there would report the
  340. archive as failed and send a second start notification."""
  341. from backend.app import main
  342. def broken_session():
  343. raise RuntimeError("database unavailable")
  344. with (
  345. patch("backend.app.main.async_session", broken_session),
  346. patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
  347. ):
  348. await main._forget_if_printer_gone(1)
  349. forget.assert_not_awaited()
  350. async def test_a_refused_adoption_is_a_deleted_printer_not_an_error(test_engine, db_session, printer_factory):
  351. """PostgreSQL: an adoption rewriting printer_id (a VP-queue or another
  352. printer's archive) waits on the delete route's lock on the printer, then
  353. the printer foreign key refuses it (measured). That is the printer being
  354. deleted, not a failure of the print start."""
  355. from sqlalchemy.exc import IntegrityError
  356. from sqlalchemy.ext.asyncio import AsyncSession
  357. from backend.app import main
  358. printer = await printer_factory()
  359. printer_id = printer.id
  360. archive_id = await _queued_archive(db_session, printer_id)
  361. real_gone = main._printer_gone
  362. real_commit = AsyncSession.commit
  363. refuse = []
  364. async def deleted_with_its_archives_kept(db, pid):
  365. if not refuse:
  366. await _delete_keeping_archives(db_session, printer_id)
  367. refuse.append(True) # the adoption's commit, next
  368. return False # read before the delete committed
  369. return await real_gone(db, pid)
  370. async def commit(self):
  371. if refuse and refuse[0]:
  372. refuse[0] = False
  373. raise IntegrityError("UPDATE print_archives", {}, Exception("violates foreign key constraint"))
  374. return await real_commit(self)
  375. with (
  376. patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
  377. patch("backend.app.main._printer_gone", new=deleted_with_its_archives_kept),
  378. patch.object(AsyncSession, "commit", commit),
  379. ):
  380. mocks = await _drive_print_start(test_engine, printer, download_ok=False)
  381. archive = await _archive(db_session, archive_id)
  382. assert archive.printer_id is None and archive.status == "completed"
  383. assert not [key for key in _active_prints if key[0] == printer_id]
  384. mocks["_maybe_start_layer_timelapse"].assert_not_called()
  385. forget.assert_awaited_once_with(printer_id)
  386. async def test_a_refused_adoption_of_a_printer_still_there_is_still_an_error(test_engine, db_session, printer_factory):
  387. """Only the printer's delete explains a refused adoption; anything else
  388. is a real error and must not be swallowed as one."""
  389. from sqlalchemy.exc import IntegrityError
  390. from sqlalchemy.ext.asyncio import AsyncSession
  391. from backend.app import main
  392. printer = await printer_factory()
  393. await _queued_archive(db_session, printer.id)
  394. real_gone = main._printer_gone
  395. real_commit = AsyncSession.commit
  396. refuse = []
  397. async def checked(db, pid):
  398. refuse.append(True) # the adoption's commit, next
  399. return await real_gone(db, pid)
  400. async def commit(self):
  401. if len(refuse) == 1:
  402. refuse.append(False)
  403. raise IntegrityError("UPDATE print_archives", {}, Exception("some other constraint"))
  404. return await real_commit(self)
  405. with (
  406. patch("backend.app.main.forget_printer", new_callable=AsyncMock),
  407. patch("backend.app.main._printer_gone", new=checked),
  408. patch.object(AsyncSession, "commit", commit),
  409. pytest.raises(IntegrityError),
  410. ):
  411. await _drive_print_start(test_engine, printer, download_ok=False)
  412. @pytest.mark.parametrize("keep", [False, True], ids=["delete archives", "keep archives"])
  413. async def test_the_delete_locks_the_printer_from_its_archive_sweep_to_its_commit(
  414. async_client, db_session, printer_factory, keep
  415. ):
  416. """PostgreSQL (measured): a print start adopting a VP-queue or another
  417. printer's archive after the sweep passed the foreign key, and the delete's
  418. ORM cascade then deleted that archive, also with "keep archives". Locked,
  419. the adoption waits and is then refused. (SQLite drops FOR UPDATE; its
  420. single writer serialises this anyway, so the statement is what is checked.)"""
  421. from sqlalchemy.ext.asyncio import AsyncSession
  422. from sqlalchemy.sql import Select
  423. printer = await printer_factory()
  424. real_execute = AsyncSession.execute
  425. statements: list = []
  426. async def recording(self, statement, *args, **kwargs):
  427. statements.append(statement)
  428. return await real_execute(self, statement, *args, **kwargs)
  429. with patch.object(AsyncSession, "execute", recording):
  430. response = await async_client.delete(
  431. f"/api/v1/printers/{printer.id}", params={"delete_archives": str(not keep).lower()}
  432. )
  433. assert response.status_code == 200, response.text
  434. def is_lock(s):
  435. return isinstance(s, Select) and s._for_update_arg is not None and "printers" in str(s)
  436. def is_sweep(s):
  437. return not isinstance(s, Select) and "print_archives" in str(s)
  438. locks = [i for i, s in enumerate(statements) if is_lock(s)]
  439. sweeps = [i for i, s in enumerate(statements) if is_sweep(s)]
  440. assert locks and sweeps and locks[0] < sweeps[0]
  441. def _refusing_first_commit_after_the_check(db_session, printer_id, main_module):
  442. """The printer deleted right after the handler's check; the next commit is
  443. refused by its foreign key (PostgreSQL, the delete holding the printer)."""
  444. from sqlalchemy.exc import IntegrityError
  445. from sqlalchemy.ext.asyncio import AsyncSession
  446. real_gone = main_module._printer_gone
  447. real_commit = AsyncSession.commit
  448. state: list = []
  449. async def gone(db, pid):
  450. if not state:
  451. await db_session.execute(delete(Printer).where(Printer.id == printer_id))
  452. await db_session.commit()
  453. state.append(True) # refuse the next commit
  454. return False # read before the delete committed
  455. return await real_gone(db, pid)
  456. async def commit(self):
  457. if state and state[0]:
  458. state[0] = False
  459. raise IntegrityError("INSERT INTO print_archives", {}, Exception("violates foreign key constraint"))
  460. return await real_commit(self)
  461. return (
  462. patch("backend.app.main._printer_gone", new=gone),
  463. patch.object(AsyncSession, "commit", commit),
  464. )
  465. @pytest.mark.parametrize("download_ok", [False, True], ids=["no-3mf fallback", "downloaded 3mf"])
  466. async def test_a_refused_new_archive_is_a_deleted_printer_not_an_error(
  467. test_engine, db_session, printer_factory, download_ok
  468. ):
  469. """With the delete locking the printer, an archive written meanwhile waits
  470. and is refused: no error escaping the print start, no "Failed to create
  471. fallback archive", no start notification for the deleted printer."""
  472. from backend.app import main
  473. printer = await printer_factory()
  474. printer_id = printer.id
  475. with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
  476. mocks = await _drive_print_start(
  477. test_engine,
  478. printer,
  479. download_ok=download_ok,
  480. extra_patches=(
  481. patch("backend.app.main.ArchiveService", _ArchiveService),
  482. *_refusing_first_commit_after_the_check(db_session, printer_id, main),
  483. ),
  484. )
  485. assert await _archives(db_session, printer_id) == []
  486. assert not [key for key in _active_prints if key[0] == printer_id]
  487. mocks["_maybe_start_layer_timelapse"].assert_not_called()
  488. mocks["_send_print_start_notification"].assert_not_awaited()
  489. forget.assert_awaited_once_with(printer_id)
  490. async def test_an_archive_whose_commit_fails_leaves_no_folder(db_session, printer_factory, tmp_path, monkeypatch):
  491. """archive_print copies the 3MF into the new archive's folder before the
  492. row is written; a refused commit left that folder behind."""
  493. import zipfile
  494. from sqlalchemy.exc import IntegrityError
  495. from backend.app.core.config import settings
  496. from backend.app.services.archive import ArchiveService
  497. monkeypatch.setattr(settings, "base_dir", tmp_path) # archive paths are stored relative to it
  498. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  499. source = tmp_path / "Cube.3mf"
  500. with zipfile.ZipFile(source, "w") as zf:
  501. zf.writestr("Metadata/slice_info.config", "<config><plate></plate></config>")
  502. printer = await printer_factory()
  503. async def refused():
  504. raise IntegrityError("INSERT INTO print_archives", {}, Exception("violates foreign key constraint"))
  505. monkeypatch.setattr(db_session, "commit", refused)
  506. with pytest.raises(IntegrityError):
  507. await ArchiveService(db_session).archive_print(printer_id=printer.id, source_file=source)
  508. assert [p for p in (tmp_path / "archive").rglob("*") if p.is_file()] == []
  509. async def test_a_failed_archive_leaves_a_folder_it_did_not_create(db_session, printer_factory, tmp_path, monkeypatch):
  510. """Same name, same second: the folder was another archive's."""
  511. import zipfile
  512. from datetime import datetime
  513. from sqlalchemy.exc import IntegrityError
  514. from backend.app.core.config import settings
  515. from backend.app.services import archive as archive_module
  516. from backend.app.services.archive import ArchiveService
  517. class _Frozen(datetime):
  518. @classmethod
  519. def now(cls, tz=None):
  520. return cls(2026, 1, 2, 3, 4, 5, tzinfo=tz)
  521. monkeypatch.setattr(archive_module, "datetime", _Frozen)
  522. monkeypatch.setattr(settings, "base_dir", tmp_path)
  523. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  524. source = tmp_path / "Cube.3mf"
  525. with zipfile.ZipFile(source, "w") as zf:
  526. zf.writestr("Metadata/slice_info.config", "<config><plate></plate></config>")
  527. printer = await printer_factory()
  528. other = tmp_path / "archive" / str(printer.id) / "20260102_030405_Cube" / "other.3mf"
  529. other.parent.mkdir(parents=True)
  530. other.write_bytes(b"another archive's file")
  531. async def refused():
  532. raise IntegrityError("INSERT INTO print_archives", {}, Exception("violates foreign key constraint"))
  533. monkeypatch.setattr(db_session, "commit", refused)
  534. with pytest.raises(IntegrityError):
  535. await ArchiveService(db_session).archive_print(printer_id=printer.id, source_file=source)
  536. assert other.exists()
  537. async def test_a_reprint_whose_archive_the_delete_removed_is_a_deleted_printer_not_an_error(
  538. test_engine, db_session, printer_factory
  539. ):
  540. """ "Delete archives" removes the archive under the adoption (a reprint of
  541. the printer's own archive; a queue print is refused earlier by the route's
  542. 409): the adoption's update matches no row (StaleDataError, not an
  543. IntegrityError)."""
  544. from backend.app import main
  545. printer = await printer_factory()
  546. printer_id = printer.id
  547. archive_id = await _queued_archive(db_session, printer_id)
  548. real = main._printer_gone
  549. calls = []
  550. async def deleted_with_its_archives(db, pid):
  551. calls.append(pid)
  552. if len(calls) == 1:
  553. await db_session.execute(delete(PrintArchive).where(PrintArchive.printer_id == printer_id))
  554. await db_session.execute(delete(Printer).where(Printer.id == printer_id))
  555. await db_session.commit()
  556. return False # read before the delete committed
  557. return await real(db, pid)
  558. with (
  559. patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
  560. patch("backend.app.main._printer_gone", new=deleted_with_its_archives),
  561. ):
  562. mocks = await _drive_print_start(test_engine, printer, download_ok=False)
  563. assert await _archive(db_session, archive_id) is None
  564. assert not [key for key in _active_prints if key[0] == printer_id]
  565. mocks["_maybe_start_layer_timelapse"].assert_not_called()
  566. forget.assert_awaited_once_with(printer_id)
  567. async def test_an_archive_whose_commit_may_have_landed_keeps_its_folder(
  568. db_session, printer_factory, tmp_path, monkeypatch
  569. ):
  570. """A connection lost during COMMIT may still have written the row: its
  571. files stay. Only a refusal (IntegrityError) proves the row was not written."""
  572. import zipfile
  573. from sqlalchemy.exc import OperationalError
  574. from backend.app.core.config import settings
  575. from backend.app.services.archive import ArchiveService
  576. monkeypatch.setattr(settings, "base_dir", tmp_path)
  577. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  578. source = tmp_path / "Cube.3mf"
  579. with zipfile.ZipFile(source, "w") as zf:
  580. zf.writestr("Metadata/slice_info.config", "<config><plate></plate></config>")
  581. printer = await printer_factory()
  582. async def connection_lost():
  583. raise OperationalError("COMMIT", {}, Exception("connection was closed in the middle of operation"))
  584. monkeypatch.setattr(db_session, "commit", connection_lost)
  585. with pytest.raises(OperationalError):
  586. await ArchiveService(db_session).archive_print(printer_id=printer.id, source_file=source)
  587. assert [p.name for p in (tmp_path / "archive").rglob("*") if p.is_file()] == ["Cube.3mf"]