"""A printer deleted while its print-start handler downloads the 3MF.
Deleting a printer cuts its MQTT link, but an ``on_print_start`` already in its
FTP phase (seconds to minutes) carried on: it wrote a "printing" archive that no
completion would ever end, registered it in ``_active_prints``, started a layer
timelapse and re-added the 3MF cache entry ``forget_printer`` had cleared. It
now checks the printer still exists once the downloads are done, and forgets it
again if not.
"""
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy import delete, select, update
from backend.app.main import _active_prints
from backend.app.models.archive import PrintArchive
from backend.app.models.printer import Printer
from backend.tests.integration.test_external_print_confirmation_1898 import (
_clear_print_state, # noqa: F401 - autouse fixture
_drive_print_start,
_StubArchiveService,
)
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
def _download_deleting(db_session, printer_id: int, *, ok: bool):
"""The FTP download, during which the printer is deleted."""
async def download(*_args, **_kwargs):
await db_session.execute(delete(Printer).where(Printer.id == printer_id))
await db_session.commit()
return ok
return patch("backend.app.main.download_file_async", new=AsyncMock(side_effect=download))
class _ArchiveService(_StubArchiveService):
"""The #1898 stub, recording its writes, with the real delete (the check
after the archive's commit deletes through it)."""
written: list[int] = []
async def archive_print(self, **kwargs):
_ArchiveService.written.append(kwargs.get("printer_id"))
return await super().archive_print(**kwargs)
async def delete_archive(self, archive_id: int) -> bool:
from backend.app.services.archive import ArchiveService
return await ArchiveService(self.db).delete_archive(archive_id)
async def _archives(db_session, printer_id: int) -> list[PrintArchive]:
db_session.expire_all()
return list((await db_session.scalars(select(PrintArchive).where(PrintArchive.printer_id == printer_id))).all())
@pytest.mark.parametrize("download_ok", [False, True], ids=["no-3mf fallback", "downloaded 3mf"])
async def test_a_printer_deleted_during_the_download_gets_nothing(
test_engine, db_session, printer_factory, download_ok
):
printer = await printer_factory()
printer_id = printer.id
_ArchiveService.written = []
ask_outcome = AsyncMock(return_value=False) # builds the fallback row
with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
mocks = await _drive_print_start(
test_engine,
printer,
download_ok=download_ok,
extra_patches=(
_download_deleting(db_session, printer_id, ok=download_ok),
patch("backend.app.main.ArchiveService", _ArchiveService),
patch("backend.app.main._ask_outcome_for_external_print", new=ask_outcome),
),
)
# Checked before writing: no archive was even started (the check after the
# commit would remove one, but only after it was written).
assert _ArchiveService.written == []
ask_outcome.assert_not_awaited()
assert await _archives(db_session, printer_id) == []
assert not [key for key in _active_prints if key[0] == printer_id]
mocks["_maybe_start_layer_timelapse"].assert_not_called()
# What the download wrote under the id (the 3MF cache entry) goes again.
forget.assert_awaited_once_with(printer_id)
@pytest.mark.parametrize("download_ok", [False, True], ids=["no-3mf fallback", "downloaded 3mf"])
async def test_a_printer_deleted_while_its_archive_is_written_gets_nothing(
test_engine, db_session, printer_factory, download_ok
):
"""The delete commits between the check and the archive's commit (the route
sweeps the printer's archives before it commits): the row written after
that sweep is removed again."""
printer = await printer_factory()
printer_id = printer.id
async def delete_printer():
await db_session.execute(delete(Printer).where(Printer.id == printer_id))
await db_session.commit()
class _DeletingArchiveService(_ArchiveService):
async def archive_print(self, **kwargs):
await delete_printer()
return await super().archive_print(**kwargs)
async def ask_outcome(*_args, **_kwargs): # runs while the fallback row is built
await delete_printer()
return False
with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
mocks = await _drive_print_start(
test_engine,
printer,
download_ok=download_ok,
extra_patches=(
patch("backend.app.main.ArchiveService", _DeletingArchiveService),
patch("backend.app.main._ask_outcome_for_external_print", new=AsyncMock(side_effect=ask_outcome)),
),
)
assert await _archives(db_session, printer_id) == []
assert not [key for key in _active_prints if key[0] == printer_id]
mocks["_maybe_start_layer_timelapse"].assert_not_called()
forget.assert_awaited_once_with(printer_id)
async def test_an_archive_kept_by_the_delete_is_left_alone(test_engine, db_session, printer_factory):
"""Deleted with "keep archives" right after the archive's commit: the route
detached it (printer_id NULL) as the user asked, so it stays. Nothing is
registered for it."""
printer = await printer_factory()
printer_id = printer.id
async def delete_keeping_archives(*_args, **_kwargs): # runs after archive_print's commit
await db_session.execute(
update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None)
)
await db_session.execute(delete(Printer).where(Printer.id == printer_id))
await db_session.commit()
return False
with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
mocks = await _drive_print_start(
test_engine,
printer,
download_ok=True,
extra_patches=(
patch("backend.app.main.ArchiveService", _ArchiveService),
patch(
"backend.app.main._ask_outcome_for_external_print",
new=AsyncMock(side_effect=delete_keeping_archives),
),
),
)
db_session.expire_all()
kept = (await db_session.scalars(select(PrintArchive))).all()
assert len(kept) == 1 and kept[0].printer_id is None
assert not [key for key in _active_prints if key[0] == printer_id]
mocks["_maybe_start_layer_timelapse"].assert_not_called()
forget.assert_awaited_once_with(printer_id)
async def test_a_printer_still_there_gets_its_archive(test_engine, db_session, printer_factory):
printer = await printer_factory()
with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
await _drive_print_start(test_engine, printer, download_ok=False)
assert len(await _archives(db_session, printer.id)) == 1
forget.assert_not_awaited()
async def test_a_queue_print_whose_printer_was_deleted_during_the_upload_credits_nobody(
db_session, test_engine, monkeypatch
):
"""The dispatch would write the owner under the deleted printer's id after
forget_printer cleared it."""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from backend.app.models.print_queue import PrintQueueItem
from backend.app.models.user import User
from backend.app.services import print_scheduler as scheduler_module
from backend.app.services.print_scheduler import PrintScheduler
owner = User(username="olga", password_hash="x", role="user")
printer = Printer(name="P", serial_number="DELMID1", ip_address="192.0.2.11", access_code="x")
db_session.add_all([owner, printer])
await db_session.commit()
item = PrintQueueItem(printer_id=printer.id, position=1, created_by_id=owner.id)
db_session.add(item)
await db_session.commit()
other = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with other() as admin_db: # meanwhile, through the API (SQLite keeps the item)
await admin_db.execute(delete(Printer).where(Printer.id == printer.id))
await admin_db.commit()
credited: list = []
monkeypatch.setattr(scheduler_module.printer_manager, "set_current_print_user", lambda *args: credited.append(args))
await PrintScheduler()._propagate_owner_to_printer_manager(db_session, item)
assert credited == []
async def _queued_archive(db_session, printer_id: int) -> int:
"""An archive Bambuddy dispatched to the printer, waiting for its start."""
from datetime import datetime, timezone
from backend.app.main import register_expected_print
archive = PrintArchive(
printer_id=printer_id,
filename="Bracket_plate_1.3mf",
file_path="archives/test/Bracket_plate_1.3mf",
file_size=1,
print_name="Bracket_plate_1",
status="completed",
started_at=datetime.now(timezone.utc),
)
db_session.add(archive)
await db_session.commit()
register_expected_print(printer_id, "Bracket_plate_1.3mf", archive.id)
return archive.id
async def _delete_keeping_archives(db_session, printer_id: int) -> None:
await db_session.execute(update(PrintArchive).where(PrintArchive.printer_id == printer_id).values(printer_id=None))
await db_session.execute(delete(Printer).where(Printer.id == printer_id))
await db_session.commit()
async def _archive(db_session, archive_id: int) -> PrintArchive:
db_session.expire_all()
return await db_session.get(PrintArchive, archive_id)
async def test_a_queued_print_of_a_deleted_printer_is_not_adopted(test_engine, db_session, printer_factory):
"""Deleted with "keep archives" before the start was handled (the plate
check waits seconds), but before the route forgot the expected print."""
from backend.app import main
printer = await printer_factory()
printer_id = printer.id
archive_id = await _queued_archive(db_session, printer_id)
real = main._printer_gone
deleted = []
async def deleted_before_the_check(db, pid):
if not deleted: # after the handler loaded the printer, before the branch
deleted.append(pid)
await _delete_keeping_archives(db_session, printer_id)
return await real(db, pid)
with (
patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
patch("backend.app.main._printer_gone", new=deleted_before_the_check),
):
mocks = await _drive_print_start(test_engine, printer, download_ok=False)
archive = await _archive(db_session, archive_id)
assert archive.printer_id is None and archive.status == "completed"
assert not [key for key in _active_prints if key[0] == printer_id]
mocks["_maybe_start_layer_timelapse"].assert_not_called()
forget.assert_awaited_once_with(printer_id)
async def test_a_queued_print_whose_printer_goes_while_it_is_adopted_is_left_as_kept(
test_engine, db_session, printer_factory, tmp_path, monkeypatch
):
"""The route's "keep archives" update lands between the check and the
commit (the route's transaction lasts seconds), which then adopted the
archive anyway: pointed back at the deleted printer, "printing" forever,
its verdict cleared and its timelapse video deleted from disk."""
from backend.app import main
monkeypatch.setattr(main.app_settings, "base_dir", tmp_path)
video = tmp_path / "archives" / "run.mp4"
video.parent.mkdir(parents=True)
video.write_bytes(b"mp4")
printer = await printer_factory()
printer_id = printer.id
archive_id = await _queued_archive(db_session, printer_id)
kept = await db_session.get(PrintArchive, archive_id)
kept.timelapse_path = "archives/run.mp4" # same (unrecorded) plate: a reprint would delete it
kept.confirm_requested = True
kept.user_verdict = "good"
await db_session.commit()
real = main._printer_gone
calls = []
async def gone_after_the_check(db, pid):
calls.append(pid)
if len(calls) == 1:
await _delete_keeping_archives(db_session, printer_id)
return False # read before the delete committed
return await real(db, pid)
with (
patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
patch("backend.app.main._printer_gone", new=gone_after_the_check),
):
mocks = await _drive_print_start(test_engine, printer, download_ok=False)
archive = await _archive(db_session, archive_id)
assert archive.printer_id is None
assert archive.status == "completed"
assert archive.user_verdict == "good"
assert archive.timelapse_path == "archives/run.mp4"
assert video.exists()
assert not [key for key in _active_prints if key[0] == printer_id]
mocks["_maybe_start_layer_timelapse"].assert_not_called()
forget.assert_awaited_once_with(printer_id)
async def test_another_printers_archive_adopted_for_a_deleted_printer_stays_that_printers(
test_engine, db_session, printer_factory
):
"""A reprint or queue item can reuse printer A's archive on printer B. B
deleted while it is adopted: the route never touched the row (it was A's),
so it goes back to A, not to no printer."""
from backend.app import main
owner = await printer_factory()
target = await printer_factory()
owner_id, target_id = owner.id, target.id
archive_id = await _queued_archive(db_session, owner_id)
main._expected_prints.clear()
main.register_expected_print(target_id, "Bracket_plate_1.3mf", archive_id)
real = main._printer_gone
calls = []
async def gone_after_the_check(db, pid):
calls.append(pid)
if len(calls) == 1:
await db_session.execute(delete(Printer).where(Printer.id == target_id))
await db_session.commit()
return False # read before the delete committed
return await real(db, pid)
with (
patch("backend.app.main.forget_printer", new_callable=AsyncMock),
patch("backend.app.main._printer_gone", new=gone_after_the_check),
):
await _drive_print_start(test_engine, target, download_ok=False)
archive = await _archive(db_session, archive_id)
assert archive.printer_id == owner_id
assert archive.status == "completed"
@pytest.mark.sqlite_only # deletes the printer with its archives still attached
@pytest.mark.parametrize("branch", ["fallback", "downloaded", "queued"])
async def test_a_printer_deleted_during_the_last_steps_is_forgotten_again(
test_engine, db_session, printer_factory, branch
):
"""After the last check the handler still waits on the smart plug, the
notifications and the timelapse baseline's FTP listing; the route may
delete the printer and forget it meanwhile, and the handler then writes
again (the baseline). It forgets the printer once more at the end."""
printer = await printer_factory()
printer_id = printer.id
if branch == "queued":
await _queued_archive(db_session, printer_id)
async def deleted_during_the_listing(*_args, **_kwargs):
await db_session.execute(delete(Printer).where(Printer.id == printer_id))
await db_session.commit()
with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
await _drive_print_start(
test_engine,
printer,
download_ok=branch == "downloaded",
extra_patches=(
patch("backend.app.main.ArchiveService", _ArchiveService),
patch(
"backend.app.main._capture_timelapse_baseline_at_start",
new=AsyncMock(side_effect=deleted_during_the_listing),
),
),
)
forget.assert_awaited_once_with(printer_id)
async def test_the_last_check_works_on_a_session_the_late_steps_broke(test_engine, db_session, printer_factory):
"""The smart plug's energy write hits the archive the delete removed and
leaves the handler's session needing a rollback: the check must not use it."""
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from backend.app import main
printer = await printer_factory()
printer_id = printer.id
archive_id = await _queued_archive(db_session, printer_id)
maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
async with maker() as handler_db:
archive = await handler_db.get(PrintArchive, archive_id)
await db_session.execute(delete(PrintArchive).where(PrintArchive.id == archive_id))
await db_session.execute(delete(Printer).where(Printer.id == printer_id))
await db_session.commit()
archive.energy_start_kwh = 1.0
with pytest.raises(Exception): # noqa: B017 - StaleDataError, as _record_energy_start swallows it
await handler_db.commit()
with (
patch("backend.app.main.async_session", maker),
patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
):
await main._forget_if_printer_gone(printer_id)
forget.assert_awaited_once_with(printer_id)
async def test_the_last_check_never_raises(printer_factory):
"""It runs inside the fallback branch's try: an error there would report the
archive as failed and send a second start notification."""
from backend.app import main
def broken_session():
raise RuntimeError("database unavailable")
with (
patch("backend.app.main.async_session", broken_session),
patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
):
await main._forget_if_printer_gone(1)
forget.assert_not_awaited()
async def test_a_refused_adoption_is_a_deleted_printer_not_an_error(test_engine, db_session, printer_factory):
"""PostgreSQL: an adoption rewriting printer_id (a VP-queue or another
printer's archive) waits on the delete route's lock on the printer, then
the printer foreign key refuses it (measured). That is the printer being
deleted, not a failure of the print start."""
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app import main
printer = await printer_factory()
printer_id = printer.id
archive_id = await _queued_archive(db_session, printer_id)
real_gone = main._printer_gone
real_commit = AsyncSession.commit
refuse = []
async def deleted_with_its_archives_kept(db, pid):
if not refuse:
await _delete_keeping_archives(db_session, printer_id)
refuse.append(True) # the adoption's commit, next
return False # read before the delete committed
return await real_gone(db, pid)
async def commit(self):
if refuse and refuse[0]:
refuse[0] = False
raise IntegrityError("UPDATE print_archives", {}, Exception("violates foreign key constraint"))
return await real_commit(self)
with (
patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
patch("backend.app.main._printer_gone", new=deleted_with_its_archives_kept),
patch.object(AsyncSession, "commit", commit),
):
mocks = await _drive_print_start(test_engine, printer, download_ok=False)
archive = await _archive(db_session, archive_id)
assert archive.printer_id is None and archive.status == "completed"
assert not [key for key in _active_prints if key[0] == printer_id]
mocks["_maybe_start_layer_timelapse"].assert_not_called()
forget.assert_awaited_once_with(printer_id)
async def test_a_refused_adoption_of_a_printer_still_there_is_still_an_error(test_engine, db_session, printer_factory):
"""Only the printer's delete explains a refused adoption; anything else
is a real error and must not be swallowed as one."""
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.app import main
printer = await printer_factory()
await _queued_archive(db_session, printer.id)
real_gone = main._printer_gone
real_commit = AsyncSession.commit
refuse = []
async def checked(db, pid):
refuse.append(True) # the adoption's commit, next
return await real_gone(db, pid)
async def commit(self):
if len(refuse) == 1:
refuse.append(False)
raise IntegrityError("UPDATE print_archives", {}, Exception("some other constraint"))
return await real_commit(self)
with (
patch("backend.app.main.forget_printer", new_callable=AsyncMock),
patch("backend.app.main._printer_gone", new=checked),
patch.object(AsyncSession, "commit", commit),
pytest.raises(IntegrityError),
):
await _drive_print_start(test_engine, printer, download_ok=False)
@pytest.mark.parametrize("keep", [False, True], ids=["delete archives", "keep archives"])
async def test_the_delete_locks_the_printer_from_its_archive_sweep_to_its_commit(
async_client, db_session, printer_factory, keep
):
"""PostgreSQL (measured): a print start adopting a VP-queue or another
printer's archive after the sweep passed the foreign key, and the delete's
ORM cascade then deleted that archive, also with "keep archives". Locked,
the adoption waits and is then refused. (SQLite drops FOR UPDATE; its
single writer serialises this anyway, so the statement is what is checked.)"""
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import Select
printer = await printer_factory()
real_execute = AsyncSession.execute
statements: list = []
async def recording(self, statement, *args, **kwargs):
statements.append(statement)
return await real_execute(self, statement, *args, **kwargs)
with patch.object(AsyncSession, "execute", recording):
response = await async_client.delete(
f"/api/v1/printers/{printer.id}", params={"delete_archives": str(not keep).lower()}
)
assert response.status_code == 200, response.text
def is_lock(s):
return isinstance(s, Select) and s._for_update_arg is not None and "printers" in str(s)
def is_sweep(s):
return not isinstance(s, Select) and "print_archives" in str(s)
locks = [i for i, s in enumerate(statements) if is_lock(s)]
sweeps = [i for i, s in enumerate(statements) if is_sweep(s)]
assert locks and sweeps and locks[0] < sweeps[0]
def _refusing_first_commit_after_the_check(db_session, printer_id, main_module):
"""The printer deleted right after the handler's check; the next commit is
refused by its foreign key (PostgreSQL, the delete holding the printer)."""
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
real_gone = main_module._printer_gone
real_commit = AsyncSession.commit
state: list = []
async def gone(db, pid):
if not state:
await db_session.execute(delete(Printer).where(Printer.id == printer_id))
await db_session.commit()
state.append(True) # refuse the next commit
return False # read before the delete committed
return await real_gone(db, pid)
async def commit(self):
if state and state[0]:
state[0] = False
raise IntegrityError("INSERT INTO print_archives", {}, Exception("violates foreign key constraint"))
return await real_commit(self)
return (
patch("backend.app.main._printer_gone", new=gone),
patch.object(AsyncSession, "commit", commit),
)
@pytest.mark.parametrize("download_ok", [False, True], ids=["no-3mf fallback", "downloaded 3mf"])
async def test_a_refused_new_archive_is_a_deleted_printer_not_an_error(
test_engine, db_session, printer_factory, download_ok
):
"""With the delete locking the printer, an archive written meanwhile waits
and is refused: no error escaping the print start, no "Failed to create
fallback archive", no start notification for the deleted printer."""
from backend.app import main
printer = await printer_factory()
printer_id = printer.id
with patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget:
mocks = await _drive_print_start(
test_engine,
printer,
download_ok=download_ok,
extra_patches=(
patch("backend.app.main.ArchiveService", _ArchiveService),
*_refusing_first_commit_after_the_check(db_session, printer_id, main),
),
)
assert await _archives(db_session, printer_id) == []
assert not [key for key in _active_prints if key[0] == printer_id]
mocks["_maybe_start_layer_timelapse"].assert_not_called()
mocks["_send_print_start_notification"].assert_not_awaited()
forget.assert_awaited_once_with(printer_id)
async def test_an_archive_whose_commit_fails_leaves_no_folder(db_session, printer_factory, tmp_path, monkeypatch):
"""archive_print copies the 3MF into the new archive's folder before the
row is written; a refused commit left that folder behind."""
import zipfile
from sqlalchemy.exc import IntegrityError
from backend.app.core.config import settings
from backend.app.services.archive import ArchiveService
monkeypatch.setattr(settings, "base_dir", tmp_path) # archive paths are stored relative to it
monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
source = tmp_path / "Cube.3mf"
with zipfile.ZipFile(source, "w") as zf:
zf.writestr("Metadata/slice_info.config", "")
printer = await printer_factory()
async def refused():
raise IntegrityError("INSERT INTO print_archives", {}, Exception("violates foreign key constraint"))
monkeypatch.setattr(db_session, "commit", refused)
with pytest.raises(IntegrityError):
await ArchiveService(db_session).archive_print(printer_id=printer.id, source_file=source)
assert [p for p in (tmp_path / "archive").rglob("*") if p.is_file()] == []
async def test_a_failed_archive_leaves_a_folder_it_did_not_create(db_session, printer_factory, tmp_path, monkeypatch):
"""Same name, same second: the folder was another archive's."""
import zipfile
from datetime import datetime
from sqlalchemy.exc import IntegrityError
from backend.app.core.config import settings
from backend.app.services import archive as archive_module
from backend.app.services.archive import ArchiveService
class _Frozen(datetime):
@classmethod
def now(cls, tz=None):
return cls(2026, 1, 2, 3, 4, 5, tzinfo=tz)
monkeypatch.setattr(archive_module, "datetime", _Frozen)
monkeypatch.setattr(settings, "base_dir", tmp_path)
monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
source = tmp_path / "Cube.3mf"
with zipfile.ZipFile(source, "w") as zf:
zf.writestr("Metadata/slice_info.config", "")
printer = await printer_factory()
other = tmp_path / "archive" / str(printer.id) / "20260102_030405_Cube" / "other.3mf"
other.parent.mkdir(parents=True)
other.write_bytes(b"another archive's file")
async def refused():
raise IntegrityError("INSERT INTO print_archives", {}, Exception("violates foreign key constraint"))
monkeypatch.setattr(db_session, "commit", refused)
with pytest.raises(IntegrityError):
await ArchiveService(db_session).archive_print(printer_id=printer.id, source_file=source)
assert other.exists()
async def test_a_reprint_whose_archive_the_delete_removed_is_a_deleted_printer_not_an_error(
test_engine, db_session, printer_factory
):
""" "Delete archives" removes the archive under the adoption (a reprint of
the printer's own archive; a queue print is refused earlier by the route's
409): the adoption's update matches no row (StaleDataError, not an
IntegrityError)."""
from backend.app import main
printer = await printer_factory()
printer_id = printer.id
archive_id = await _queued_archive(db_session, printer_id)
real = main._printer_gone
calls = []
async def deleted_with_its_archives(db, pid):
calls.append(pid)
if len(calls) == 1:
await db_session.execute(delete(PrintArchive).where(PrintArchive.printer_id == printer_id))
await db_session.execute(delete(Printer).where(Printer.id == printer_id))
await db_session.commit()
return False # read before the delete committed
return await real(db, pid)
with (
patch("backend.app.main.forget_printer", new_callable=AsyncMock) as forget,
patch("backend.app.main._printer_gone", new=deleted_with_its_archives),
):
mocks = await _drive_print_start(test_engine, printer, download_ok=False)
assert await _archive(db_session, archive_id) is None
assert not [key for key in _active_prints if key[0] == printer_id]
mocks["_maybe_start_layer_timelapse"].assert_not_called()
forget.assert_awaited_once_with(printer_id)
async def test_an_archive_whose_commit_may_have_landed_keeps_its_folder(
db_session, printer_factory, tmp_path, monkeypatch
):
"""A connection lost during COMMIT may still have written the row: its
files stay. Only a refusal (IntegrityError) proves the row was not written."""
import zipfile
from sqlalchemy.exc import OperationalError
from backend.app.core.config import settings
from backend.app.services.archive import ArchiveService
monkeypatch.setattr(settings, "base_dir", tmp_path)
monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
source = tmp_path / "Cube.3mf"
with zipfile.ZipFile(source, "w") as zf:
zf.writestr("Metadata/slice_info.config", "")
printer = await printer_factory()
async def connection_lost():
raise OperationalError("COMMIT", {}, Exception("connection was closed in the middle of operation"))
monkeypatch.setattr(db_session, "commit", connection_lost)
with pytest.raises(OperationalError):
await ArchiveService(db_session).archive_print(printer_id=printer.id, source_file=source)
assert [p.name for p in (tmp_path / "archive").rglob("*") if p.is_file()] == ["Cube.3mf"]