Files
bambuddy/backend/tests/unit/test_fallback_archive_recovery_2957.py
T
maziggy 4e79f9c2f7 Fill in a fallback archive when the 3MF finally arrives (issue #2957)
A failed TLS handshake pauses a printer's file service for five minutes, and
the archive flow checks that pause at the top of its path loop and gives up
before opening a connection. A print that starts inside one gets an empty
fallback archive 13 milliseconds later, having never touched the network.

Four minutes on, the pause clears and the cover endpoint downloads the same
file, parses it, takes a thumbnail out of it, and publishes it to the shared
3MF cache under the exact key the archive flow looks up. Nothing ever looks:
all three readers of that cache run before or during the print-start handler
that already gave up, and print completion drops the cache as its first act,
deleting the file. No path existed by which a fallback archive could become a
real one.

Offer a later 3MF to the running print's archive, filling the existing row
rather than adding a second -- the row id carries the energy reading, the
timelapse session and the start notification. That covers the reported case at
no network cost, since opening the printer card already downloads the file.

Schedule a bounded retry when the pause is what caused the fallback, spending
the cache first and the printer only if that misses. Not scheduled for the
other cause: a print kept on internal eMMC has no FTPS copy to come back for,
and retrying it is the sweep removed in #2780. The two reasons are now recorded
separately instead of both landing as "no 3MF".

Recovery refuses anything that is not a readable 3MF -- a truncated download
would replace an honest empty archive with wrong metadata -- and leaves an
archive alone once it has a real file.

Three things the recovery path has to get right, each with a test:

Reduce every retry candidate to a bare name. MQTT hands `filename` over as
"/data/Metadata/plate_1.gcode" on some firmware, and joining that onto the temp
directory yields the absolute path itself, so the retry is fed the flow's own
sanitised candidate list and strips the path again on its own account.

Serialise recovery per printer. The cover endpoint's single-flight coalesces by
view, so two views race each other, and the retry task and print completion can
land on top of either -- each reads file_path == "" and runs a full copy,
leaving the row on one timestamped directory and the rest orphaned.

Keep looking for photos in the pre-recovery directory. `archive_dir` derives
from `file_path`, so filling the row moves the archive's directory, and a photo
uploaded to the empty card while the print ran stays where it was put.
2026-08-25 09:33:52 +02:00

570 lines
26 KiB
Python

"""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",
"<?xml version='1.0' encoding='UTF-8'?>"
"<config><plate>"
"<metadata key='index' value='1'/>"
"<metadata key='prediction' value='3600'/>"
"<metadata key='weight' value='42.5'/>"
"<filament id='1' type='PLA' color='#00AE42' used_g='42.5' used_m='14.2'/>"
"</plate></config>",
)
zf.writestr(
"Metadata/model_settings.config",
f"<config><plate><metadata key='name' value='{print_name}'/></plate></config>",
)
zf.writestr("3D/3dmodel.model", "<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
# "<second-resolution timestamp>_<file stem>" 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"