test_printer_file_download_errors.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. """A printer file that could not be fetched is not reported as missing (TO-C2, TO-C3, TO-C1).
  2. The file browser's previews (G-code, plates, plate thumbnails) and its single
  3. file download answered every failure with 404 "File not found", including a
  4. timeout on a file the browser had just listed. They now answer 404 only when
  5. the printer says the path is not there, 502 when the download failed, and 504
  6. when it ran out of the time its size allows. The timelapse scan answered a
  7. failed download with 500; it now gives 502, and 504 for a deadline.
  8. """
  9. from unittest.mock import AsyncMock, MagicMock, patch
  10. import pytest
  11. from httpx import AsyncClient
  12. from backend.app.services.bambu_ftp import DownloadDeadlineExceeded, FileNotOnPrinterError
  13. from backend.app.services.printer_media import PrinterFileDownloadError
  14. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  15. PREVIEWS = [
  16. ("/files/gcode", {"path": "/cache/part.gcode"}),
  17. ("/files/plates", {"path": "/cache/part.3mf"}),
  18. ("/files/plate-thumbnail/1", {"path": "/cache/part.3mf"}),
  19. ]
  20. @pytest.mark.parametrize(("route", "params"), PREVIEWS)
  21. @pytest.mark.parametrize(
  22. ("outcome", "status"),
  23. [
  24. (FileNotOnPrinterError("/cache/part"), 404),
  25. (None, 502),
  26. (DownloadDeadlineExceeded("/cache/part"), 504),
  27. ],
  28. )
  29. async def test_a_preview_says_what_went_wrong(
  30. async_client: AsyncClient, printer_factory, route, params, outcome, status
  31. ):
  32. printer = await printer_factory()
  33. download = AsyncMock(side_effect=outcome) if isinstance(outcome, Exception) else AsyncMock(return_value=outcome)
  34. with patch("backend.app.api.routes.printers.download_file_bytes_async", download):
  35. resp = await async_client.get(f"/api/v1/printers/{printer.id}{route}", params=params)
  36. assert resp.status_code == status, resp.text
  37. # One FTP session per preview: the plate picker asks once per plate.
  38. assert download.await_count == 1
  39. assert download.await_args.kwargs["report_missing"] is True
  40. # The file browser's budget: with print start's 300 s ceiling a slow file
  41. # would get no more than the flat base deadline.
  42. from backend.app.services.printer_media import MAX_PRINTER_ZIP_PREPARE_SECONDS
  43. assert download.await_args.kwargs["max_total_timeout"] == MAX_PRINTER_ZIP_PREPARE_SECONDS
  44. @pytest.mark.parametrize(
  45. ("raised", "status"),
  46. [
  47. (PrinterFileDownloadError("could not be downloaded"), 502),
  48. (TimeoutError("took longer than its size allows"), 504),
  49. (FileNotFoundError("Invalid printer file path"), 404),
  50. ],
  51. )
  52. async def test_the_file_download_says_what_went_wrong(async_client: AsyncClient, printer_factory, raised, status):
  53. printer = await printer_factory()
  54. with patch("backend.app.api.routes.printers.build_printer_file", AsyncMock(side_effect=raised)):
  55. resp = await async_client.get(f"/api/v1/printers/{printer.id}/files/download", params={"path": "/ipcam/a.mp4"})
  56. assert resp.status_code == status, resp.text
  57. @pytest.mark.parametrize(
  58. ("outcome", "status"),
  59. [(None, 502), (DownloadDeadlineExceeded("/timelapse/t.mp4"), 504)],
  60. )
  61. async def test_a_timelapse_scan_says_what_went_wrong(
  62. async_client: AsyncClient, archive_factory, printer_factory, outcome, status
  63. ):
  64. printer = await printer_factory()
  65. archive = await archive_factory(printer.id, filename="t.gcode.3mf")
  66. listing = [
  67. {"name": "t.gcode.mp4", "path": "/timelapse/t.gcode.mp4", "is_directory": False, "size": 10, "mtime": None}
  68. ]
  69. download = AsyncMock(side_effect=outcome) if isinstance(outcome, Exception) else AsyncMock(return_value=outcome)
  70. with (
  71. patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=listing)),
  72. patch("backend.app.services.bambu_ftp.get_ftp_retry_settings", AsyncMock(return_value=(True, 3, 0.0, 30.0))),
  73. patch("backend.app.services.bambu_ftp.download_file_bytes_async", download),
  74. ):
  75. resp = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
  76. assert resp.status_code == status, resp.text
  77. if status == 504:
  78. assert download.await_count == 1 # a size-fitted deadline is not retried
  79. async def test_an_automatic_timelapse_attach_survives_a_deadline():
  80. """The background attach treats it as a failed round: the printer keeps its copy."""
  81. import logging
  82. from backend.app import main
  83. printer = MagicMock(ip_address="10.0.0.5", access_code="c", model="P1S")
  84. download = AsyncMock(side_effect=DownloadDeadlineExceeded("/timelapse/new.mp4"))
  85. with patch("backend.app.services.bambu_ftp.download_file_bytes_async", download):
  86. result = await main._attach_first_unclaimed_timelapse(
  87. 1,
  88. printer,
  89. [{"name": "new.mp4", "path": "/timelapse/new.mp4", "size": 10}],
  90. set(),
  91. set(),
  92. 1,
  93. logging.getLogger(__name__),
  94. )
  95. assert result is False
  96. download.assert_awaited_once()
  97. async def test_the_plate_picker_downloads_the_file_once(async_client: AsyncClient, printer_factory):
  98. """The plate list and every plate thumbnail share one download (TO-C2)."""
  99. import io
  100. import zipfile
  101. printer = await printer_factory()
  102. buf = io.BytesIO()
  103. with zipfile.ZipFile(buf, "w") as zf:
  104. for n in (1, 2, 3):
  105. zf.writestr(f"Metadata/plate_{n}.gcode", "; gcode")
  106. zf.writestr(f"Metadata/plate_{n}.png", b"\x89PNG fake")
  107. download = AsyncMock(return_value=buf.getvalue())
  108. params = {"path": "/cache/multi.3mf"}
  109. with patch("backend.app.api.routes.printers.download_file_bytes_async", download):
  110. plates = await async_client.get(f"/api/v1/printers/{printer.id}/files/plates", params=params)
  111. thumbs = [
  112. await async_client.get(f"/api/v1/printers/{printer.id}/files/plate-thumbnail/{n}", params=params)
  113. for n in (1, 2, 3)
  114. ]
  115. assert plates.status_code == 200, plates.text
  116. assert [t.status_code for t in thumbs] == [200, 200, 200]
  117. assert download.await_count == 1
  118. async def test_a_deleted_printer_file_is_downloaded_again(async_client: AsyncClient, printer_factory):
  119. from backend.app.services.bambu_ftp import DeleteResult
  120. printer = await printer_factory()
  121. download = AsyncMock(return_value=b"; gcode")
  122. params = {"path": "/cache/part.gcode"}
  123. with (
  124. patch("backend.app.api.routes.printers.download_file_bytes_async", download),
  125. patch("backend.app.api.routes.printers.delete_file_async", AsyncMock(return_value=DeleteResult.DELETED)),
  126. ):
  127. await async_client.get(f"/api/v1/printers/{printer.id}/files/gcode", params=params)
  128. deleted = await async_client.delete(f"/api/v1/printers/{printer.id}/files", params=params)
  129. await async_client.get(f"/api/v1/printers/{printer.id}/files/gcode", params=params)
  130. assert deleted.status_code == 200, deleted.text
  131. assert download.await_count == 2