test_timelapse_scan_session.py 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. """Regression tests for the #2572 timelapse-scan session-boundary refactor.
  2. ``POST /archives/{id}/timelapse/scan`` used to hold its ``Depends(get_db)``
  3. session open across the FTP directory listing *and* the multi-MB video
  4. download. It now (1) reads the archive + printer in a short session and
  5. releases the pooled connection *before* the FTP work, then (2) re-opens a
  6. fresh short session only to attach the downloaded file.
  7. Two things that refactor could have broken, one test each:
  8. * The matching logic reads ``archive.filename/started_at/completed_at/
  9. created_at`` and ``printer.ip_address/...`` AFTER the read session has
  10. closed. If any were a lazy-loaded relationship (or an expired column) that
  11. would raise ``DetachedInstanceError``. The not-found test drives every
  12. match strategy, exercising all of those detached reads.
  13. * The attach write runs in a *fresh* ``async_session()``, which — unlike
  14. ``get_db`` — does NOT auto-commit on block exit. If ``attach_timelapse``
  15. didn't commit internally the write would be silently dropped. The attach
  16. test asserts the row is actually persisted.
  17. FTP is fully mocked, so no printer is contacted.
  18. """
  19. from __future__ import annotations
  20. from unittest.mock import AsyncMock, MagicMock, patch
  21. import pytest
  22. from httpx import AsyncClient
  23. @pytest.mark.asyncio
  24. @pytest.mark.integration
  25. async def test_scan_timelapse_no_match_reads_detached_archive_scalars(
  26. async_client: AsyncClient, archive_factory, printer_factory, db_session
  27. ):
  28. """Two non-matching videos → 200 not_found, driving every match strategy.
  29. Strategies 2-4 read archive.started_at/completed_at/created_at after the
  30. read session closed; this fails with DetachedInstanceError if the refactor
  31. left one of those as a lazy load.
  32. """
  33. printer = await printer_factory()
  34. archive = await archive_factory(printer.id, filename="test_print.gcode.3mf")
  35. # Two videos, neither matching by name, no mtime, and the archive has no
  36. # started_at — so strategy 1 (name) misses, 2 (start time) and 3 (mtime)
  37. # are skipped, and 4 (single-file fallback) is disqualified by len == 2.
  38. listing = [
  39. {"name": "clip_a.mp4", "path": "/timelapse/clip_a.mp4", "is_directory": False, "size": 10, "mtime": None},
  40. {"name": "clip_b.mp4", "path": "/timelapse/clip_b.mp4", "is_directory": False, "size": 20, "mtime": None},
  41. ]
  42. with (
  43. patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=listing)),
  44. patch(
  45. "backend.app.services.bambu_ftp.get_ftp_retry_settings",
  46. AsyncMock(return_value=(False, 3, 2.0, 30.0)),
  47. ),
  48. patch(
  49. "backend.app.services.bambu_ftp.download_file_bytes_async",
  50. AsyncMock(return_value=b"should-not-be-called"),
  51. ) as mock_download,
  52. ):
  53. response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
  54. assert response.status_code == 200, response.text
  55. data = response.json()
  56. assert data["status"] == "not_found"
  57. assert {f["name"] for f in data["available_files"]} == {"clip_a.mp4", "clip_b.mp4"}
  58. # No match → we never download.
  59. mock_download.assert_not_called()
  60. @pytest.mark.asyncio
  61. @pytest.mark.integration
  62. async def test_scan_timelapse_attaches_and_persists_via_fresh_session(
  63. async_client: AsyncClient, archive_factory, printer_factory, db_session, tmp_path, monkeypatch
  64. ):
  65. """A name-matched video is downloaded and the attach PERSISTS.
  66. Guards the fresh-session write boundary: attach_timelapse runs in a new
  67. async_session that does not auto-commit on exit, so this only passes if
  68. the service commits internally.
  69. """
  70. printer = await printer_factory()
  71. archive = await archive_factory(printer.id, filename="test_print.gcode.3mf")
  72. # attach_timelapse writes into settings.base_dir / archive.file_path's
  73. # parent, then stores a base_dir-relative timelapse_path. Point base_dir at
  74. # tmp and stage the archive dir so the real write succeeds (mirrors
  75. # test_attach_timelapse_safe_path).
  76. monkeypatch.setattr(
  77. "backend.app.services.archive.settings",
  78. MagicMock(base_dir=tmp_path),
  79. )
  80. archive_dir = tmp_path / "archives" / "test"
  81. archive_dir.mkdir(parents=True)
  82. # base_name = Path("test_print.gcode.3mf").stem = "test_print.gcode", so this
  83. # video matches by name (strategy 1). .mp4 → no background conversion task.
  84. video_bytes = b"fake-timelapse-video-bytes"
  85. matched = {
  86. "name": "test_print.gcode.mp4",
  87. "path": "/timelapse/test_print.gcode.mp4",
  88. "is_directory": False,
  89. # Must equal len(video_bytes): the download is checked against the
  90. # listing, and the file is re-listed afterwards to confirm the printer
  91. # has stopped writing it (#2704).
  92. "size": len(video_bytes),
  93. "mtime": None,
  94. }
  95. with (
  96. patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[matched])),
  97. patch(
  98. "backend.app.services.bambu_ftp.get_ftp_retry_settings",
  99. AsyncMock(return_value=(False, 3, 2.0, 30.0)),
  100. ),
  101. patch(
  102. "backend.app.services.bambu_ftp.download_file_bytes_async",
  103. AsyncMock(return_value=video_bytes),
  104. ) as mock_download,
  105. # A successful attach now removes the printer's copy (#2704); without
  106. # this the endpoint would open a real FTP connection to the fixture IP.
  107. patch("backend.app.services.bambu_ftp.delete_archived_timelapse", AsyncMock()) as mock_delete,
  108. ):
  109. response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
  110. assert response.status_code == 200, response.text
  111. data = response.json()
  112. assert data["status"] == "attached"
  113. assert data["filename"] == "test_print.gcode.mp4"
  114. mock_download.assert_awaited_once()
  115. mock_delete.assert_awaited_once()
  116. # The write happened in the route's fresh session; confirm it was committed
  117. # by re-reading the row on the separate test session.
  118. await db_session.refresh(archive)
  119. assert archive.timelapse_path is not None
  120. assert archive.timelapse_path.endswith("test_print.gcode.mp4")
  121. # And the bytes actually landed on disk under the staged archive dir.
  122. assert (archive_dir / "test_print.gcode.mp4").read_bytes() == video_bytes
  123. @pytest.mark.asyncio
  124. @pytest.mark.integration
  125. async def test_scan_timelapse_reports_a_wedged_file_service_as_503(
  126. async_client: AsyncClient, archive_factory, printer_factory, db_session
  127. ):
  128. """A printer that cannot negotiate TLS is named as such, not as a 500.
  129. #2780's reporter triggered this scan to reproduce their problem and got
  130. HTTP 500 with "Failed to connect to printer or no timelapse directory
  131. found" — one message for two unrelated causes, neither of which pointed at
  132. the printer's file service being wedged.
  133. """
  134. printer = await printer_factory()
  135. archive = await archive_factory(printer.id, filename="test_print.gcode.3mf")
  136. with (
  137. patch("backend.app.services.bambu_ftp.ftps_handshake_blocked", return_value=True),
  138. patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[])) as mock_list,
  139. ):
  140. response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
  141. assert response.status_code == 503, response.text
  142. assert "TLS" in response.json()["detail"]
  143. # And we did not walk all four candidate directories to find that out.
  144. mock_list.assert_not_called()
  145. @pytest.mark.asyncio
  146. @pytest.mark.integration
  147. async def test_scan_timelapse_reports_a_missing_directory_as_404(
  148. async_client: AsyncClient, archive_factory, printer_factory, db_session
  149. ):
  150. """A reachable printer with no timelapse directory is a 404, not a 500."""
  151. printer = await printer_factory()
  152. archive = await archive_factory(printer.id, filename="test_print.gcode.3mf")
  153. with (
  154. patch("backend.app.services.bambu_ftp.ftps_handshake_blocked", return_value=False),
  155. patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[])),
  156. ):
  157. response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
  158. assert response.status_code == 404, response.text
  159. assert "timelapse" in response.json()["detail"].lower()