test_timelapse_scan_session.py 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  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. matched = {
  85. "name": "test_print.gcode.mp4",
  86. "path": "/timelapse/test_print.gcode.mp4",
  87. "is_directory": False,
  88. "size": 4096,
  89. "mtime": None,
  90. }
  91. video_bytes = b"fake-timelapse-video-bytes"
  92. with (
  93. patch("backend.app.services.bambu_ftp.list_files_async", AsyncMock(return_value=[matched])),
  94. patch(
  95. "backend.app.services.bambu_ftp.get_ftp_retry_settings",
  96. AsyncMock(return_value=(False, 3, 2.0, 30.0)),
  97. ),
  98. patch(
  99. "backend.app.services.bambu_ftp.download_file_bytes_async",
  100. AsyncMock(return_value=video_bytes),
  101. ) as mock_download,
  102. ):
  103. response = await async_client.post(f"/api/v1/archives/{archive.id}/timelapse/scan")
  104. assert response.status_code == 200, response.text
  105. data = response.json()
  106. assert data["status"] == "attached"
  107. assert data["filename"] == "test_print.gcode.mp4"
  108. mock_download.assert_awaited_once()
  109. # The write happened in the route's fresh session; confirm it was committed
  110. # by re-reading the row on the separate test session.
  111. await db_session.refresh(archive)
  112. assert archive.timelapse_path is not None
  113. assert archive.timelapse_path.endswith("test_print.gcode.mp4")
  114. # And the bytes actually landed on disk under the staged archive dir.
  115. assert (archive_dir / "test_print.gcode.mp4").read_bytes() == video_bytes