"""A fallback archive is filled in when the 3MF finally turns up (#2957).
The reporter's P1S started a print while Bambuddy was inside the five-minute
FTPS cool-off armed by an earlier failed TLS handshake. The archive flow checks
that cool-off at the top of its path loop and breaks before opening a single
connection, so it gave up 13 ms after print start and wrote an empty fallback
archive. Four minutes later the cool-off cleared and the cover endpoint
downloaded the very same file -- all 8,956,942 bytes of it -- read a thumbnail
out of it, and published it to the shared 3MF cache under the exact key the
archive flow looks up.
Nothing ever looked. Every ``get_cached_3mf`` caller runs before or during the
print-start handler that had already given up, and ``on_print_complete`` drops
the cache as its first statement, deleting the file. The archive stayed an empty
shell for a print whose source Bambuddy had held, parsed and indexed.
These tests pin the recovery: the row is filled in place (its id is load-bearing
-- the energy reading, the timelapse session and the start notification were all
written against it), it is only ever offered a readable 3MF, and it is left
alone once it has a real file.
"""
from __future__ import annotations
import uuid
import zipfile
from pathlib import Path
from unittest.mock import patch
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from backend.app.models.archive import PrintArchive
from backend.app.models.printer import Printer
pytestmark = pytest.mark.asyncio
PRINT_NAME = "Desktop_Goose"
DISPATCH_FILENAME = "Desktop_Goose.gcode.3mf"
def _write_3mf(path: Path, print_name: str = PRINT_NAME) -> Path:
"""A 3MF the archive parser can read metadata out of."""
path.parent.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr(
"Metadata/slice_info.config",
""
""
""
""
""
""
"",
)
zf.writestr(
"Metadata/model_settings.config",
f"",
)
zf.writestr("3D/3dmodel.model", "")
return path
async def _seed(engine, tmp_path: Path) -> tuple[async_sessionmaker, int, int]:
"""A printer plus the empty fallback archive the cool-off produced."""
maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with maker() as db:
printer = Printer(
name="P1S",
serial_number="01P00A3B1200579",
ip_address="172.25.12.149",
access_code="12345678",
model="P1S",
)
db.add(printer)
await db.commit()
await db.refresh(printer)
archive = PrintArchive(
printer_id=printer.id,
filename=DISPATCH_FILENAME,
file_path="", # the shell
file_size=0,
print_name=PRINT_NAME,
status="printing",
subtask_id="4242",
extra_data={
"no_3mf_available": True,
"no_3mf_reason": "ftps_cooloff",
"original_subtask": PRINT_NAME,
"_print_data": {"filename": DISPATCH_FILENAME},
},
)
db.add(archive)
await db.commit()
await db.refresh(archive)
return maker, printer.id, archive.id
class TestRecoveryFillsTheExistingRow:
async def test_the_cover_endpoints_download_recovers_the_archive(self, test_engine, tmp_path):
"""The reporter's case, end to end from the download onwards."""
from backend.app import main as main_module
maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
# The cover endpoint's own temp name, as it appears in the report:
# /app/data/archive/temp/cover_1_Desktop_Goose.gcode.3mf
source = _write_3mf(tmp_path / "temp" / f"cover_{printer_id}_{DISPATCH_FILENAME}")
with (
patch.object(main_module, "async_session", maker),
patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
):
recovered = await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source)
assert recovered is True
async with maker() as db:
archive = await db.get(PrintArchive, archive_id)
# Same row. A second archive would orphan the energy reading, the
# timelapse session and the notification already sent against it.
assert archive.id == archive_id
assert archive.file_path
assert archive.file_size == source.stat().st_size
assert archive.subtask_id == "4242"
assert archive.status == "printing"
# No longer a fallback, so the Archives banner stops counting it.
assert not archive.extra_data.get("no_3mf_available")
assert archive.extra_data.get("recovered_no_3mf") is True
# The start payload is diagnostic history and survives.
assert archive.extra_data["_print_data"]["filename"] == DISPATCH_FILENAME
# And exactly one archive, not the original shell plus a new one.
async with maker() as db:
rows = (await db.execute(select(PrintArchive).where(PrintArchive.printer_id == printer_id))).scalars().all()
assert [row.id for row in rows] == [archive_id]
async def test_metadata_from_the_3mf_lands_on_the_row(self, test_engine, tmp_path):
from backend.app import main as main_module
maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
with (
patch.object(main_module, "async_session", maker),
patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
):
assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source) is True
async with maker() as db:
archive = await db.get(PrintArchive, archive_id)
# The empty shell had none of these.
assert archive.filament_used_grams == pytest.approx(42.5)
assert archive.filament_type == "PLA"
assert archive.print_time_seconds == 3600
async def test_a_name_variant_still_finds_the_archive(self, test_engine, tmp_path):
"""The cover endpoint arrives with whichever spelling its own path built.
`_active_prints` is keyed on the raw names seen at print start, so an
exact-string lookup would miss "Desktop_Goose.gcode.3mf" against an
archive registered under "Desktop_Goose".
"""
from backend.app import main as main_module
maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
with (
patch.object(main_module, "async_session", maker),
patch.dict(main_module._active_prints, {(printer_id, PRINT_NAME): archive_id}, clear=True),
):
assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source) is True
class TestRecoveryRefusesTheWrongInput:
async def test_a_truncated_download_is_refused(self, test_engine, tmp_path):
"""Half a file would replace an honest empty archive with wrong metadata."""
from backend.app import main as main_module
maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
source = tmp_path / "temp" / DISPATCH_FILENAME
source.parent.mkdir(parents=True, exist_ok=True)
source.write_bytes(b"PK\x03\x04 truncated, not a readable zip")
with (
patch.object(main_module, "async_session", maker),
patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
):
assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source) is False
async with maker() as db:
assert (await db.get(PrintArchive, archive_id)).file_path == ""
async def test_an_empty_file_is_refused(self, test_engine, tmp_path):
from backend.app import main as main_module
maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
source = tmp_path / "temp" / DISPATCH_FILENAME
source.parent.mkdir(parents=True, exist_ok=True)
source.write_bytes(b"")
with (
patch.object(main_module, "async_session", maker),
patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
):
assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source) is False
async def test_an_archive_that_already_has_a_3mf_is_left_alone(self, test_engine, tmp_path):
"""The normal case: every cover request during a healthy print hits this."""
from backend.app import main as main_module
maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
async with maker() as db:
archive = await db.get(PrintArchive, archive_id)
archive.file_path = "archives/1/real/Desktop_Goose.gcode.3mf"
archive.file_size = 8956942
await db.commit()
source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
with (
patch.object(main_module, "async_session", maker),
patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
):
assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source) is False
async with maker() as db:
archive = await db.get(PrintArchive, archive_id)
assert archive.file_path == "archives/1/real/Desktop_Goose.gcode.3mf"
assert archive.file_size == 8956942
async def test_no_running_print_for_this_printer_is_a_no_op(self, test_engine, tmp_path):
from backend.app import main as main_module
maker, printer_id, _archive_id = await _seed(test_engine, tmp_path)
source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
with (
patch.object(main_module, "async_session", maker),
patch.dict(main_module._active_prints, {}, clear=True),
):
assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source) is False
async def test_a_deleted_archive_is_not_resurrected(self, test_engine, tmp_path):
from datetime import datetime, timezone
from backend.app import main as main_module
maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
async with maker() as db:
archive = await db.get(PrintArchive, archive_id)
archive.deleted_at = datetime.now(timezone.utc)
await db.commit()
source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
with (
patch.object(main_module, "async_session", maker),
patch.dict(main_module._active_prints, {(printer_id, DISPATCH_FILENAME): archive_id}, clear=True),
):
assert await main_module.try_recover_fallback_archive(printer_id, DISPATCH_FILENAME, source) is False
class TestTheGiveUpReasonIsRecorded:
@pytest.mark.filterwarnings("ignore::pytest.PytestWarning")
async def test_the_cooloff_slug_is_distinct_from_the_storage_verdicts(self):
"""The retry decision keys off it: a cool-off clears in minutes with the
file still on the printer, while an eMMC job never appears at any FTPS
path and retrying it is the sweep #2780 removed."""
from backend.app.services.print_storage import (
REASON_FTPS_COOLOFF,
REASON_INTERNAL_STORAGE,
REASON_NO_EXTERNAL_STORAGE,
)
assert REASON_FTPS_COOLOFF not in (REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE)
async def test_the_banner_endpoint_does_not_leak_the_new_slug(self):
"""The two storage slugs are a UI contract; a cool-off is not one of them
and must degrade to the generic banner rather than a missing string."""
from backend.app.api.routes.archives import REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE
from backend.app.services.print_storage import REASON_FTPS_COOLOFF
assert REASON_FTPS_COOLOFF not in (REASON_INTERNAL_STORAGE, REASON_NO_EXTERNAL_STORAGE)
class TestTheCooloffRetry:
"""The other half: nothing may ever download the file on its own."""
async def test_the_retry_recovers_from_the_shared_cache(self, test_engine, tmp_path, monkeypatch):
"""The cover endpoint's copy is the same bytes, so the retry spends no
FTP connection when the cache already holds it."""
import asyncio
from backend.app import main as main_module
from backend.app.services import bambu_ftp
maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
source = _write_3mf(tmp_path / "temp" / DISPATCH_FILENAME)
monkeypatch.setattr(main_module, "_FALLBACK_3MF_RETRY_DELAYS_SECONDS", (0.01,))
bambu_ftp.cache_3mf_download(printer_id, DISPATCH_FILENAME, source)
try:
with patch.object(main_module, "async_session", maker):
main_module._schedule_fallback_3mf_retry(
printer_id=printer_id, archive_id=archive_id, filenames=[DISPATCH_FILENAME]
)
task = main_module._fallback_3mf_retry_tasks[printer_id]
await asyncio.wait_for(task, timeout=5)
finally:
bambu_ftp.clear_3mf_cache(printer_id, delete_files=False)
async with maker() as db:
archive = await db.get(PrintArchive, archive_id)
assert archive.file_path
assert not archive.extra_data.get("no_3mf_available")
async def test_the_retry_stops_once_the_archive_has_a_3mf(self, test_engine, tmp_path, monkeypatch):
"""Something else recovered it first — usually the cover endpoint."""
import asyncio
from backend.app import main as main_module
maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
async with maker() as db:
archive = await db.get(PrintArchive, archive_id)
archive.file_path = "archives/1/real/Desktop_Goose.gcode.3mf"
await db.commit()
monkeypatch.setattr(main_module, "_FALLBACK_3MF_RETRY_DELAYS_SECONDS", (0.01, 0.01))
downloads = []
async def _never(*args, **kwargs):
downloads.append(args)
return False
with (
patch.object(main_module, "async_session", maker),
patch.object(main_module, "download_file_try_paths_async", _never),
):
main_module._schedule_fallback_3mf_retry(
printer_id=printer_id, archive_id=archive_id, filenames=[DISPATCH_FILENAME]
)
await asyncio.wait_for(main_module._fallback_3mf_retry_tasks[printer_id], timeout=5)
assert downloads == []
async def test_a_second_schedule_replaces_the_first(self, test_engine, tmp_path, monkeypatch):
"""One printer prints one job at a time; two live retry tasks would race
to write the same row."""
import asyncio
from backend.app import main as main_module
maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
monkeypatch.setattr(main_module, "_FALLBACK_3MF_RETRY_DELAYS_SECONDS", (30.0,))
with patch.object(main_module, "async_session", maker):
main_module._schedule_fallback_3mf_retry(
printer_id=printer_id, archive_id=archive_id, filenames=[DISPATCH_FILENAME]
)
first = main_module._fallback_3mf_retry_tasks[printer_id]
main_module._schedule_fallback_3mf_retry(
printer_id=printer_id, archive_id=archive_id, filenames=[DISPATCH_FILENAME]
)
second = main_module._fallback_3mf_retry_tasks[printer_id]
assert first is not second
await asyncio.sleep(0)
assert first.cancelled() or first.done()
second.cancel()
with pytest.raises(asyncio.CancelledError):
await second
main_module._fallback_3mf_retry_tasks.pop(printer_id, None)
async def test_the_retry_downloads_from_the_printer_when_the_cache_is_empty(
self, test_engine, tmp_path, monkeypatch
):
"""Nothing else fetched the file, so the retry has to go and get it —
the branch the reporter would have hit had they never opened the card."""
import asyncio
from backend.app import main as main_module
maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
monkeypatch.setattr(main_module, "_FALLBACK_3MF_RETRY_DELAYS_SECONDS", (0.01,))
# Left on the real archive dir: ArchiveService stores the destination
# relative to settings.base_dir, so a temp path outside it cannot be
# archived at all.
asked: list[list[str]] = []
async def _serve(ip, code, paths, dest, **kwargs):
asked.append(list(paths))
_write_3mf(Path(dest))
return paths[0]
with (
patch.object(main_module, "async_session", maker),
patch.object(main_module, "ftps_handshake_blocked", return_value=False),
patch.object(main_module, "get_ftp_retry_settings", return_value=(True, 3, 2.0, 30.0)),
patch.object(main_module, "download_file_try_paths_async", _serve),
):
main_module._schedule_fallback_3mf_retry(
printer_id=printer_id, archive_id=archive_id, filenames=[DISPATCH_FILENAME]
)
await asyncio.wait_for(main_module._fallback_3mf_retry_tasks[printer_id], timeout=5)
assert asked, "the retry never asked the printer for the file"
async with maker() as db:
archive = await db.get(PrintArchive, archive_id)
assert archive.file_path
assert archive.id == archive_id
async def test_a_printer_still_in_cool_off_is_not_contacted(self, test_engine, tmp_path, monkeypatch):
"""Retrying into a live cool-off is the failure that created the fallback."""
import asyncio
from backend.app import main as main_module
maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
monkeypatch.setattr(main_module, "_FALLBACK_3MF_RETRY_DELAYS_SECONDS", (0.01,))
downloads = []
async def _never(*args, **kwargs):
downloads.append(args)
return False
with (
patch.object(main_module, "async_session", maker),
patch.object(main_module, "ftps_handshake_blocked", return_value=True),
patch.object(main_module, "download_file_try_paths_async", _never),
):
main_module._schedule_fallback_3mf_retry(
printer_id=printer_id, archive_id=archive_id, filenames=[DISPATCH_FILENAME]
)
await asyncio.wait_for(main_module._fallback_3mf_retry_tasks[printer_id], timeout=5)
assert downloads == []
class TestConcurrentRecoveryIsSerialised:
async def test_two_racing_callers_produce_one_archive_directory(self, test_engine, tmp_path):
"""The cover endpoint coalesces by view, so two views race each other —
and the cool-off retry can land on top of either. Unserialised, each
caller reads file_path == "" and runs its own copy, leaving the row
pointing at one timestamped directory with the others orphaned."""
import asyncio
from backend.app import main as main_module
from backend.app.core.config import settings as app_config
maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
# A name unique to this run. `archive_print` builds its directory as
# "_" with exist_ok=True, so a
# shared stem collides with the directory another test in this file made
# a moment ago, and the count below would measure that instead.
unique = f"Racing_{uuid.uuid4().hex[:12]}.gcode.3mf"
source = _write_3mf(tmp_path / "temp" / unique)
printer_root = app_config.archive_dir / str(printer_id)
before = set(printer_root.iterdir()) if printer_root.exists() else set()
with (
patch.object(main_module, "async_session", maker),
patch.dict(main_module._active_prints, {(printer_id, unique): archive_id}, clear=True),
):
results = await asyncio.gather(
*(main_module.try_recover_fallback_archive(printer_id, unique, source) for _ in range(4))
)
# Exactly one caller did the work; the rest saw a recovered archive.
assert results.count(True) == 1
created = (set(printer_root.iterdir()) if printer_root.exists() else set()) - before
assert len(created) == 1, f"expected one archive directory, got {sorted(p.name for p in created)}"
async with maker() as db:
rows = (await db.execute(select(PrintArchive).where(PrintArchive.printer_id == printer_id))).scalars().all()
assert [row.id for row in rows] == [archive_id]
assert (app_config.base_dir / rows[0].file_path).is_file()
class TestTheRetryWritesInsideTheDataVolume:
async def test_a_path_shaped_name_cannot_escape_the_temp_directory(self, test_engine, tmp_path, monkeypatch):
"""MQTT hands `filename` over as a path on some firmware — the print-start
log shows "/data/Metadata/plate_1.gcode". Joining that onto a directory
with `/` yields the absolute path itself, so the temp write has to reduce
every candidate to a bare name of its own accord."""
import asyncio
from backend.app import main as main_module
from backend.app.core.config import settings as app_config
maker, printer_id, archive_id = await _seed(test_engine, tmp_path)
monkeypatch.setattr(main_module, "_FALLBACK_3MF_RETRY_DELAYS_SECONDS", (0.01,))
temp_root = (app_config.archive_dir / "temp").resolve()
written: list[Path] = []
async def _record(ip, code, paths, dest, **kwargs):
written.append(Path(dest))
return None # a miss, so the loop walks every candidate
with (
patch.object(main_module, "async_session", maker),
patch.object(main_module, "ftps_handshake_blocked", return_value=False),
patch.object(main_module, "get_ftp_retry_settings", return_value=(True, 3, 2.0, 30.0)),
patch.object(main_module, "download_file_try_paths_async", _record),
):
main_module._schedule_fallback_3mf_retry(
printer_id=printer_id,
archive_id=archive_id,
filenames=[
"/data/Metadata/plate_1.gcode",
"../../../../etc/passwd",
"/etc/cron.d/evil.3mf",
"..",
],
)
await asyncio.wait_for(main_module._fallback_3mf_retry_tasks[printer_id], timeout=5)
assert written, "the retry never attempted a download"
for dest in written:
assert dest.resolve().parent == temp_root, f"{dest} escaped {temp_root}"
class TestPhotosSurviveRecovery:
"""Recovery moves the archive's directory, because `archive_dir` derives it
from `file_path` and that goes from empty to a real path. A photo uploaded
to the empty card while the print ran is still where it was put."""
async def test_a_photo_written_before_recovery_is_still_found_after(self, tmp_path, monkeypatch):
from types import SimpleNamespace
from backend.app.core.config import settings as app_config
from backend.app.utils.archive_paths import find_archive_photo
monkeypatch.setattr(app_config, "archive_dir", tmp_path / "archive")
monkeypatch.setattr(app_config, "base_dir", tmp_path)
archive = SimpleNamespace(id=83, file_path="")
# Uploaded while the archive was still an empty fallback.
before_dir = tmp_path / "archive" / "83" / "photos"
before_dir.mkdir(parents=True)
(before_dir / "snap.jpg").write_bytes(b"jpeg")
assert find_archive_photo(archive, "snap.jpg") == before_dir / "snap.jpg"
# The 3MF turns up and the row gains a file_path in a new directory.
archive.file_path = "archive/1/20260825_000000_Desktop_Goose/Desktop_Goose.gcode.3mf"
(tmp_path / "archive/1/20260825_000000_Desktop_Goose").mkdir(parents=True)
assert find_archive_photo(archive, "snap.jpg") == before_dir / "snap.jpg"
async def test_the_current_directory_still_wins(self, tmp_path, monkeypatch):
from types import SimpleNamespace
from backend.app.core.config import settings as app_config
from backend.app.utils.archive_paths import find_archive_photo
monkeypatch.setattr(app_config, "archive_dir", tmp_path / "archive")
monkeypatch.setattr(app_config, "base_dir", tmp_path)
archive = SimpleNamespace(id=83, file_path="archive/1/run/Desktop_Goose.gcode.3mf")
current = tmp_path / "archive/1/run/photos"
current.mkdir(parents=True)
(current / "snap.jpg").write_bytes(b"new")
stale = tmp_path / "archive" / "83" / "photos"
stale.mkdir(parents=True)
(stale / "snap.jpg").write_bytes(b"old")
assert find_archive_photo(archive, "snap.jpg") == current / "snap.jpg"