test_fallback_archive_photos_1820.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  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. async def test_a_photo_in_the_old_shared_location_is_still_served(
  46. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  47. ):
  48. """Manual uploads landed in <base_dir>/photos, where reads also looked,
  49. so those worked. Moving the lookup must not orphan them."""
  50. archive = await _fallback_archive(archive_factory, printer_factory, photos=[PHOTO])
  51. _write(base_dir / "photos")
  52. response = await async_client.get(f"/api/v1/archives/{archive.id}/photos/{PHOTO}")
  53. assert response.status_code == 200
  54. assert response.content == JPEG
  55. async def test_a_photo_that_is_in_neither_place_is_404(
  56. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  57. ):
  58. archive = await _fallback_archive(archive_factory, printer_factory, photos=[PHOTO])
  59. response = await async_client.get(f"/api/v1/archives/{archive.id}/photos/{PHOTO}")
  60. assert response.status_code == 404
  61. async def test_a_name_not_on_the_archive_is_404(
  62. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  63. ):
  64. """The membership check comes first and still does."""
  65. archive = await _fallback_archive(archive_factory, printer_factory, photos=[PHOTO])
  66. _write(base_dir / "archive" / str(archive.id) / "photos", name="someone_elses.jpg")
  67. response = await async_client.get(f"/api/v1/archives/{archive.id}/photos/someone_elses.jpg")
  68. assert response.status_code == 404
  69. class TestANormalArchiveIsUnaffected:
  70. async def test_it_reads_from_its_own_directory(
  71. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  72. ):
  73. printer = await printer_factory()
  74. archive = await archive_factory(printer.id, file_path="archives/test/print.gcode.3mf", photos=[PHOTO])
  75. _write(base_dir / "archives" / "test" / "photos")
  76. response = await async_client.get(f"/api/v1/archives/{archive.id}/photos/{PHOTO}")
  77. assert response.status_code == 200
  78. async def test_it_does_not_borrow_from_the_shared_location(
  79. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  80. ):
  81. """An archive with a real path has one location and only one. Looking
  82. in the shared pile as well would serve another archive's photo when
  83. the names ever collided."""
  84. printer = await printer_factory()
  85. archive = await archive_factory(printer.id, file_path="archives/test/print.gcode.3mf", photos=[PHOTO])
  86. _write(base_dir / "photos")
  87. response = await async_client.get(f"/api/v1/archives/{archive.id}/photos/{PHOTO}")
  88. assert response.status_code == 404
  89. class TestDeleting:
  90. async def test_a_captured_photo_is_removed_from_disk(
  91. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  92. ):
  93. """It used to drop the name and leave the file, so the photo became
  94. both invisible and unremovable."""
  95. archive = await _fallback_archive(archive_factory, printer_factory, photos=[PHOTO])
  96. photos_dir = base_dir / "archive" / str(archive.id) / "photos"
  97. _write(photos_dir)
  98. response = await async_client.delete(f"/api/v1/archives/{archive.id}/photos/{PHOTO}")
  99. assert response.status_code == 200
  100. assert not (photos_dir / PHOTO).exists()
  101. async def test_a_photo_in_the_old_location_is_removed_too(
  102. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  103. ):
  104. archive = await _fallback_archive(archive_factory, printer_factory, photos=[PHOTO])
  105. _write(base_dir / "photos")
  106. response = await async_client.delete(f"/api/v1/archives/{archive.id}/photos/{PHOTO}")
  107. assert response.status_code == 200
  108. assert not (base_dir / "photos" / PHOTO).exists()
  109. async def test_a_missing_file_still_clears_the_name(
  110. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  111. ):
  112. archive = await _fallback_archive(archive_factory, printer_factory, photos=[PHOTO])
  113. response = await async_client.delete(f"/api/v1/archives/{archive.id}/photos/{PHOTO}")
  114. assert response.status_code == 200
  115. assert response.json()["photos"] is None
  116. class TestUploading:
  117. async def test_an_uploaded_photo_can_be_read_back(
  118. self, async_client: AsyncClient, archive_factory, printer_factory, base_dir
  119. ):
  120. """Upload and read now agree on the location for these archives, which
  121. also means the directory has to be created with its parents."""
  122. archive = await _fallback_archive(archive_factory, printer_factory)
  123. upload = await async_client.post(
  124. f"/api/v1/archives/{archive.id}/photos",
  125. files={"file": ("shot.jpg", JPEG, "image/jpeg")},
  126. )
  127. assert upload.status_code == 200, upload.text
  128. filename = upload.json()["filename"]
  129. assert (base_dir / "archive" / str(archive.id) / "photos" / filename).is_file()
  130. read_back = await async_client.get(f"/api/v1/archives/{archive.id}/photos/{filename}")
  131. assert read_back.status_code == 200
  132. assert read_back.content == JPEG