| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168 |
- """The cover endpoint stops re-fetching a 3MF another flow already has (#2957).
- Both the cover endpoint and the print-start archive flow want the running
- print's 3MF, and #972 gave them a shared cache so whichever gets it first hands
- it to the other. The cover endpoint looked in that cache exactly once, on the
- way in, and then fell into a retry loop that never looked again.
- On a P1S the two flows overlap for minutes. The reporter's log has the cover
- request starting at 13:31:09, its first attempt burning the whole 90-second
- path-walk cap, the archive flow publishing the file to the cache at 13:32:47 --
- and the cover's third attempt pulling its own 5,250,969-byte copy of that same
- file at 13:33:29, off a printer that was mid-print on the same SD card.
- These tests pin the re-check: the file is picked up between attempts, the
- retries still happen when there is genuinely nothing to pick up, and a file that
- came from the cache is neither re-registered under this endpoint's own name nor
- deleted on the way out -- it belongs to the archive flow.
- """
- from __future__ import annotations
- import zipfile
- from pathlib import Path
- from types import SimpleNamespace
- from unittest.mock import MagicMock, patch
- import pytest
- from fastapi import HTTPException
- import backend.app.api.routes.printers as printers_mod
- from backend.app.api.routes.printers import _produce_cover_image
- pytestmark = pytest.mark.asyncio
- SUBTASK = "bambu_lab_spool"
- COVER_BYTES = b"\x89PNG\r\n\x1a\nplate-1-thumbnail"
- def _write_3mf(path: Path) -> Path:
- path.parent.mkdir(parents=True, exist_ok=True)
- with zipfile.ZipFile(path, "w") as zf:
- zf.writestr("Metadata/plate_1.png", COVER_BYTES)
- return path
- @pytest.fixture(autouse=True)
- def _clear_cover_state():
- printers_mod._cover_cache.clear()
- printers_mod._cover_404_cache.clear()
- printers_mod._cover_inflight.clear()
- yield
- printers_mod._cover_cache.clear()
- printers_mod._cover_404_cache.clear()
- printers_mod._cover_inflight.clear()
- class _Harness:
- """The cover endpoint with its FTP, storage verdict and cache faked out."""
- def __init__(self, tmp_path: Path):
- self.tmp_path = tmp_path
- self.downloads = 0
- self.cache: dict[str, Path] = {}
- self.registered: list[tuple[int, str, Path]] = []
- self.on_download = None
- self.serves_the_file = False
- self.printer = SimpleNamespace(id=1, ip_address="172.25.12.149", access_code="x", model="P1S", name="P1S")
- def _get_cached(self, printer_id, name):
- return self.cache.get("path")
- async def _download(self, ip_address, access_code, remote_paths, local_path, **kwargs):
- self.downloads += 1
- if self.on_download is not None:
- self.on_download(self)
- if self.serves_the_file:
- _write_3mf(local_path)
- return remote_paths[0]
- return None
- async def run(self, **kwargs):
- async def _no_recovery(printer_id, name, path):
- return False
- with (
- patch.object(printers_mod.settings, "archive_dir", self.tmp_path / "archive"),
- patch.object(printers_mod.printer_manager, "get_status", MagicMock(return_value=SimpleNamespace())),
- patch.object(
- printers_mod,
- "print_file_reachable_over_ftp",
- MagicMock(return_value=SimpleNamespace(reachable=True, probe_filename=None, reason="")),
- ),
- patch.object(printers_mod, "get_cached_3mf", self._get_cached),
- patch.object(
- printers_mod,
- "cache_3mf_download",
- lambda pid, name, path: self.registered.append((pid, name, path)),
- ),
- patch.object(printers_mod, "download_file_try_paths_async", self._download),
- patch("backend.app.main.try_recover_fallback_archive", _no_recovery),
- patch.object(printers_mod.asyncio, "sleep", lambda *_: _noop()),
- ):
- return await _produce_cover_image(
- self.printer, 1, SUBTASK, None, "default", None, (SUBTASK, "default"), **kwargs
- )
- async def _noop():
- return None
- class TestItLooksAgainBetweenAttempts:
- async def test_a_file_published_mid_retry_is_picked_up(self, tmp_path):
- """The reported sequence: the archive flow finishes while this endpoint
- is between retries, and the retry must not spend a second transfer."""
- harness = _Harness(tmp_path)
- source = _write_3mf(tmp_path / "archive" / "temp" / f"{SUBTASK}.gcode.3mf")
- def publish(h):
- h.cache["path"] = source # the archive flow's download lands
- harness.on_download = publish
- assert await harness.run() == COVER_BYTES
- assert harness.downloads == 1, "the cover re-downloaded a 3MF the cache already held"
- async def test_the_cached_file_is_left_to_its_owner(self, tmp_path):
- """It is the archive flow's temp file. Re-registering it under this
- endpoint's own key would point the cache at bytes it does not own, and
- deleting it would force the archive flow to fetch it again."""
- harness = _Harness(tmp_path)
- source = _write_3mf(tmp_path / "archive" / "temp" / f"{SUBTASK}.gcode.3mf")
- harness.on_download = lambda h: h.cache.__setitem__("path", source)
- await harness.run()
- assert harness.registered == []
- assert source.exists()
- class TestWhatItMustNotChange:
- async def test_retries_still_run_when_there_is_nothing_to_pick_up(self, tmp_path):
- """max_retries + 1 attempts, exactly as before -- the re-check must not
- become an early exit for a printer that simply has not answered yet."""
- harness = _Harness(tmp_path)
- with pytest.raises(HTTPException) as exc:
- await harness.run()
- assert exc.value.status_code == 404
- assert harness.downloads == 3
- async def test_a_hit_on_the_way_in_still_skips_ftp_entirely(self, tmp_path):
- harness = _Harness(tmp_path)
- harness.cache["path"] = _write_3mf(tmp_path / "archive" / "temp" / f"{SUBTASK}.gcode.3mf")
- assert await harness.run() == COVER_BYTES
- assert harness.downloads == 0
- async def test_its_own_download_is_still_shared(self, tmp_path):
- """The other half of #972: a cover that really did fetch the bytes must
- still publish them, or the archive flow refetches the same file."""
- harness = _Harness(tmp_path)
- harness.serves_the_file = True
- assert await harness.run() == COVER_BYTES
- assert harness.downloads == 1
- assert [name for _, name, _ in harness.registered] == [f"{SUBTASK}.gcode.3mf"]
|