test_fallback_archive_photos_1820.py 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. """Photos on an archive that has no 3MF (#1820).
  2. The finish-photo capture writes to ``<archive_dir>/<id>/photos/`` when the
  3. archive has no ``file_path``. Every reader derived the directory from
  4. ``file_path`` instead, and ``Path("").parent`` is ``Path(".")`` -- so they all
  5. resolved to ``<base_dir>/photos``. The photo was written to one place and
  6. looked for in another: reads 404'd, deletes removed the name and left the file,
  7. and the notification attachment never found the image.
  8. The reporter hit the read. These cover all of it, plus the photos already
  9. written to the old location, which must not become unreachable in the fix.
  10. """
  11. import pytest
  12. from httpx import AsyncClient
  13. pytestmark = [pytest.mark.integration, pytest.mark.asyncio]
  14. PHOTO = "deadbeef.jpg"
  15. JPEG = b"\xff\xd8\xff\xe0" + b"0" * 64
  16. @pytest.fixture
  17. def base_dir(monkeypatch, tmp_path):
  18. """Point both roots at a tmp dir, keeping their real relationship."""
  19. from backend.app.core.config import settings
  20. monkeypatch.setattr(settings, "base_dir", tmp_path)
  21. monkeypatch.setattr(settings, "archive_dir", tmp_path / "archive")
  22. return tmp_path
  23. async def _fallback_archive(archive_factory, printer_factory, **kwargs):
  24. printer = await printer_factory()
  25. return await archive_factory(
  26. printer.id,
  27. print_name="Started From The Printer",
  28. filename="Started From The Printer.3mf",
  29. file_path="",
  30. **kwargs,
  31. )
  32. def _write(directory, name=PHOTO, content=JPEG):
  33. directory.mkdir(parents=True, exist_ok=True)
  34. (directory / name).write_bytes(content)
  35. class TestReadingACapturedPhoto:
  36. async def test_a_captured_finish_photo_is_served(
  37. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  38. ):
  39. """The reporter's 404: written by the capture, unreadable forever."""
  40. archive = await _fallback_archive(archive_factory, printer_factory, photos=[PHOTO])
  41. _write(base_dir / "archive" / str(archive.id) / "photos")
  42. response = await async_client.get(f"/api/v1/archives/{archive.id}/photos/{PHOTO}")
  43. assert response.status_code == 200
  44. assert response.content == JPEG
  45. # A late timelapse frame is written over the live finish photo under
  46. # its name (#2547): the browser must revalidate, not keep the old one.
  47. assert response.headers["cache-control"] == "no-cache"
  48. async def test_a_photo_in_the_old_shared_location_is_still_served(
  49. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  50. ):
  51. """Manual uploads landed in <base_dir>/photos, where reads also looked,
  52. so those worked. Moving the lookup must not orphan them."""
  53. archive = await _fallback_archive(archive_factory, printer_factory, photos=[PHOTO])
  54. _write(base_dir / "photos")
  55. response = await async_client.get(f"/api/v1/archives/{archive.id}/photos/{PHOTO}")
  56. assert response.status_code == 200
  57. assert response.content == JPEG
  58. async def test_a_photo_that_is_in_neither_place_is_404(
  59. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  60. ):
  61. archive = await _fallback_archive(archive_factory, printer_factory, photos=[PHOTO])
  62. response = await async_client.get(f"/api/v1/archives/{archive.id}/photos/{PHOTO}")
  63. assert response.status_code == 404
  64. async def test_a_name_not_on_the_archive_is_404(
  65. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  66. ):
  67. """The membership check comes first and still does."""
  68. archive = await _fallback_archive(archive_factory, printer_factory, photos=[PHOTO])
  69. _write(base_dir / "archive" / str(archive.id) / "photos", name="someone_elses.jpg")
  70. response = await async_client.get(f"/api/v1/archives/{archive.id}/photos/someone_elses.jpg")
  71. assert response.status_code == 404
  72. class TestANormalArchiveIsUnaffected:
  73. async def test_it_reads_from_its_own_directory(
  74. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  75. ):
  76. printer = await printer_factory()
  77. archive = await archive_factory(printer.id, file_path="archives/test/print.gcode.3mf", photos=[PHOTO])
  78. _write(base_dir / "archives" / "test" / "photos")
  79. response = await async_client.get(f"/api/v1/archives/{archive.id}/photos/{PHOTO}")
  80. assert response.status_code == 200
  81. async def test_it_does_not_borrow_from_the_shared_location(
  82. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  83. ):
  84. """An archive with a real path has one location and only one. Looking
  85. in the shared pile as well would serve another archive's photo when
  86. the names ever collided."""
  87. printer = await printer_factory()
  88. archive = await archive_factory(printer.id, file_path="archives/test/print.gcode.3mf", photos=[PHOTO])
  89. _write(base_dir / "photos")
  90. response = await async_client.get(f"/api/v1/archives/{archive.id}/photos/{PHOTO}")
  91. assert response.status_code == 404
  92. class TestDeleting:
  93. async def test_a_captured_photo_is_removed_from_disk(
  94. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  95. ):
  96. """It used to drop the name and leave the file, so the photo became
  97. both invisible and unremovable."""
  98. archive = await _fallback_archive(archive_factory, printer_factory, photos=[PHOTO])
  99. photos_dir = base_dir / "archive" / str(archive.id) / "photos"
  100. _write(photos_dir)
  101. response = await async_client.delete(f"/api/v1/archives/{archive.id}/photos/{PHOTO}")
  102. assert response.status_code == 200
  103. assert not (photos_dir / PHOTO).exists()
  104. async def test_a_photo_in_the_old_location_is_removed_too(
  105. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  106. ):
  107. archive = await _fallback_archive(archive_factory, printer_factory, photos=[PHOTO])
  108. _write(base_dir / "photos")
  109. response = await async_client.delete(f"/api/v1/archives/{archive.id}/photos/{PHOTO}")
  110. assert response.status_code == 200
  111. assert not (base_dir / "photos" / PHOTO).exists()
  112. async def test_a_missing_file_still_clears_the_name(
  113. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  114. ):
  115. archive = await _fallback_archive(archive_factory, printer_factory, photos=[PHOTO])
  116. response = await async_client.delete(f"/api/v1/archives/{archive.id}/photos/{PHOTO}")
  117. assert response.status_code == 200
  118. assert response.json()["photos"] is None
  119. class TestUploading:
  120. async def test_an_uploaded_photo_can_be_read_back(
  121. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  122. ):
  123. """Upload and read now agree on the location for these archives, which
  124. also means the directory has to be created with its parents."""
  125. archive = await _fallback_archive(archive_factory, printer_factory)
  126. upload = await async_client.post(
  127. f"/api/v1/archives/{archive.id}/photos",
  128. files={"file": ("shot.jpg", JPEG, "image/jpeg")},
  129. )
  130. assert upload.status_code == 200, upload.text
  131. filename = upload.json()["filename"]
  132. assert (base_dir / "archive" / str(archive.id) / "photos" / filename).is_file()
  133. read_back = await async_client.get(f"/api/v1/archives/{archive.id}/photos/{filename}")
  134. assert read_back.status_code == 200
  135. assert read_back.content == JPEG