test_cover_rechecks_3mf_cache_2957.py 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. """The cover endpoint stops re-fetching a 3MF another flow already has (#2957).
  2. Both the cover endpoint and the print-start archive flow want the running
  3. print's 3MF, and #972 gave them a shared cache so whichever gets it first hands
  4. it to the other. The cover endpoint looked in that cache exactly once, on the
  5. way in, and then fell into a retry loop that never looked again.
  6. On a P1S the two flows overlap for minutes. The reporter's log has the cover
  7. request starting at 13:31:09, its first attempt burning the whole 90-second
  8. path-walk cap, the archive flow publishing the file to the cache at 13:32:47 --
  9. and the cover's third attempt pulling its own 5,250,969-byte copy of that same
  10. file at 13:33:29, off a printer that was mid-print on the same SD card.
  11. These tests pin the re-check: the file is picked up between attempts, the
  12. retries still happen when there is genuinely nothing to pick up, and a file that
  13. came from the cache is neither re-registered under this endpoint's own name nor
  14. deleted on the way out -- it belongs to the archive flow.
  15. """
  16. from __future__ import annotations
  17. import zipfile
  18. from pathlib import Path
  19. from types import SimpleNamespace
  20. from unittest.mock import MagicMock, patch
  21. import pytest
  22. from fastapi import HTTPException
  23. import backend.app.api.routes.printers as printers_mod
  24. from backend.app.api.routes.printers import _produce_cover_image
  25. pytestmark = pytest.mark.asyncio
  26. SUBTASK = "bambu_lab_spool"
  27. COVER_BYTES = b"\x89PNG\r\n\x1a\nplate-1-thumbnail"
  28. def _write_3mf(path: Path) -> Path:
  29. path.parent.mkdir(parents=True, exist_ok=True)
  30. with zipfile.ZipFile(path, "w") as zf:
  31. zf.writestr("Metadata/plate_1.png", COVER_BYTES)
  32. return path
  33. @pytest.fixture(autouse=True)
  34. def _clear_cover_state():
  35. printers_mod._cover_cache.clear()
  36. printers_mod._cover_404_cache.clear()
  37. printers_mod._cover_inflight.clear()
  38. yield
  39. printers_mod._cover_cache.clear()
  40. printers_mod._cover_404_cache.clear()
  41. printers_mod._cover_inflight.clear()
  42. class _Harness:
  43. """The cover endpoint with its FTP, storage verdict and cache faked out."""
  44. def __init__(self, tmp_path: Path):
  45. self.tmp_path = tmp_path
  46. self.downloads = 0
  47. self.cache: dict[str, Path] = {}
  48. self.registered: list[tuple[int, str, Path]] = []
  49. self.on_download = None
  50. self.serves_the_file = False
  51. self.printer = SimpleNamespace(id=1, ip_address="172.25.12.149", access_code="x", model="P1S", name="P1S")
  52. def _get_cached(self, printer_id, name):
  53. return self.cache.get("path")
  54. async def _download(self, ip_address, access_code, remote_paths, local_path, **kwargs):
  55. self.downloads += 1
  56. if self.on_download is not None:
  57. self.on_download(self)
  58. if self.serves_the_file:
  59. _write_3mf(local_path)
  60. return remote_paths[0]
  61. return None
  62. async def run(self, **kwargs):
  63. async def _no_recovery(printer_id, name, path):
  64. return False
  65. with (
  66. patch.object(printers_mod.settings, "archive_dir", self.tmp_path / "archive"),
  67. patch.object(printers_mod.printer_manager, "get_status", MagicMock(return_value=SimpleNamespace())),
  68. patch.object(
  69. printers_mod,
  70. "print_file_reachable_over_ftp",
  71. MagicMock(return_value=SimpleNamespace(reachable=True, probe_filename=None, reason="")),
  72. ),
  73. patch.object(printers_mod, "get_cached_3mf", self._get_cached),
  74. patch.object(
  75. printers_mod,
  76. "cache_3mf_download",
  77. lambda pid, name, path: self.registered.append((pid, name, path)),
  78. ),
  79. patch.object(printers_mod, "download_file_try_paths_async", self._download),
  80. patch("backend.app.main.try_recover_fallback_archive", _no_recovery),
  81. patch.object(printers_mod.asyncio, "sleep", lambda *_: _noop()),
  82. ):
  83. return await _produce_cover_image(
  84. self.printer, 1, SUBTASK, None, "default", None, (SUBTASK, "default"), **kwargs
  85. )
  86. async def _noop():
  87. return None
  88. class TestItLooksAgainBetweenAttempts:
  89. async def test_a_file_published_mid_retry_is_picked_up(self, tmp_path):
  90. """The reported sequence: the archive flow finishes while this endpoint
  91. is between retries, and the retry must not spend a second transfer."""
  92. harness = _Harness(tmp_path)
  93. source = _write_3mf(tmp_path / "archive" / "temp" / f"{SUBTASK}.gcode.3mf")
  94. def publish(h):
  95. h.cache["path"] = source # the archive flow's download lands
  96. harness.on_download = publish
  97. assert await harness.run() == COVER_BYTES
  98. assert harness.downloads == 1, "the cover re-downloaded a 3MF the cache already held"
  99. async def test_the_cached_file_is_left_to_its_owner(self, tmp_path):
  100. """It is the archive flow's temp file. Re-registering it under this
  101. endpoint's own key would point the cache at bytes it does not own, and
  102. deleting it would force the archive flow to fetch it again."""
  103. harness = _Harness(tmp_path)
  104. source = _write_3mf(tmp_path / "archive" / "temp" / f"{SUBTASK}.gcode.3mf")
  105. harness.on_download = lambda h: h.cache.__setitem__("path", source)
  106. await harness.run()
  107. assert harness.registered == []
  108. assert source.exists()
  109. class TestWhatItMustNotChange:
  110. async def test_retries_still_run_when_there_is_nothing_to_pick_up(self, tmp_path):
  111. """max_retries + 1 attempts, exactly as before -- the re-check must not
  112. become an early exit for a printer that simply has not answered yet."""
  113. harness = _Harness(tmp_path)
  114. with pytest.raises(HTTPException) as exc:
  115. await harness.run()
  116. assert exc.value.status_code == 404
  117. assert harness.downloads == 3
  118. async def test_a_hit_on_the_way_in_still_skips_ftp_entirely(self, tmp_path):
  119. harness = _Harness(tmp_path)
  120. harness.cache["path"] = _write_3mf(tmp_path / "archive" / "temp" / f"{SUBTASK}.gcode.3mf")
  121. assert await harness.run() == COVER_BYTES
  122. assert harness.downloads == 0
  123. async def test_its_own_download_is_still_shared(self, tmp_path):
  124. """The other half of #972: a cover that really did fetch the bytes must
  125. still publish them, or the archive flow refetches the same file."""
  126. harness = _Harness(tmp_path)
  127. harness.serves_the_file = True
  128. assert await harness.run() == COVER_BYTES
  129. assert harness.downloads == 1
  130. assert [name for _, name, _ in harness.registered] == [f"{SUBTASK}.gcode.3mf"]