test_library_preview_thumbnail_api.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. """Integration tests for the client-rendered preview thumbnail upload (#2976).
  2. STEP/PDF/spreadsheet previews render in the browser and post their first
  3. render to POST /library/files/{id}/preview-thumbnail. These tests pin the
  4. endpoint's contract: PNG-only, capped size, only for the client-preview file
  5. types, and never replacing an existing thumbnail.
  6. """
  7. import io
  8. import pytest
  9. from httpx import AsyncClient
  10. from PIL import Image
  11. from backend.app.core.config import settings as app_settings
  12. from backend.app.models.library import LibraryFile
  13. def _png_bytes(size: tuple[int, int] = (300, 300), color: str = "red") -> bytes:
  14. buf = io.BytesIO()
  15. Image.new("RGB", size, color).save(buf, "PNG")
  16. return buf.getvalue()
  17. @pytest.fixture
  18. def isolated_storage(monkeypatch, tmp_path):
  19. """Point thumbnail storage at a throwaway directory."""
  20. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  21. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  22. return tmp_path
  23. @pytest.fixture
  24. async def file_factory(db_session):
  25. """Factory for LibraryFile rows of arbitrary file_type."""
  26. _counter = [0]
  27. async def _create_file(**kwargs):
  28. _counter[0] += 1
  29. counter = _counter[0]
  30. defaults = {
  31. "filename": f"part{counter}.step",
  32. "file_path": f"library/files/part{counter}.step",
  33. "file_type": "step",
  34. "file_size": 100,
  35. }
  36. defaults.update(kwargs)
  37. library_file = LibraryFile(**defaults)
  38. db_session.add(library_file)
  39. await db_session.commit()
  40. await db_session.refresh(library_file)
  41. return library_file
  42. return _create_file
  43. class TestPreviewThumbnailUpload:
  44. @pytest.mark.asyncio
  45. @pytest.mark.integration
  46. async def test_upload_sets_thumbnail_path(
  47. self, async_client: AsyncClient, db_session, file_factory, isolated_storage
  48. ):
  49. library_file = await file_factory(file_type="step")
  50. response = await async_client.post(
  51. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  52. files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
  53. )
  54. assert response.status_code == 200
  55. assert response.json() == {"updated": True}
  56. await db_session.refresh(library_file)
  57. assert library_file.thumbnail_path
  58. stored = isolated_storage / library_file.thumbnail_path
  59. assert stored.exists()
  60. with Image.open(stored) as img:
  61. assert img.format == "PNG"
  62. @pytest.mark.asyncio
  63. @pytest.mark.integration
  64. async def test_upload_downscales_oversized_image(
  65. self, async_client: AsyncClient, db_session, file_factory, isolated_storage
  66. ):
  67. library_file = await file_factory(file_type="pdf", filename="doc.pdf", file_path="library/files/doc.pdf")
  68. response = await async_client.post(
  69. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  70. files={"thumbnail": ("preview.png", _png_bytes(size=(1024, 1024)), "image/png")},
  71. )
  72. assert response.status_code == 200
  73. await db_session.refresh(library_file)
  74. with Image.open(isolated_storage / library_file.thumbnail_path) as img:
  75. assert max(img.size) <= 512
  76. @pytest.mark.asyncio
  77. @pytest.mark.integration
  78. async def test_upload_skips_when_thumbnail_exists(
  79. self, async_client: AsyncClient, db_session, file_factory, isolated_storage
  80. ):
  81. library_file = await file_factory(file_type="csv", thumbnail_path="archive/library/thumbnails/existing.png")
  82. response = await async_client.post(
  83. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  84. files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
  85. )
  86. assert response.status_code == 200
  87. assert response.json() == {"updated": False}
  88. await db_session.refresh(library_file)
  89. assert library_file.thumbnail_path == "archive/library/thumbnails/existing.png"
  90. @pytest.mark.asyncio
  91. @pytest.mark.integration
  92. async def test_upload_rejected_for_server_rendered_types(
  93. self, async_client: AsyncClient, file_factory, isolated_storage
  94. ):
  95. # STL thumbnails are generated server-side; the client route must not
  96. # be able to overwrite them.
  97. library_file = await file_factory(file_type="stl", filename="part.stl")
  98. response = await async_client.post(
  99. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  100. files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
  101. )
  102. assert response.status_code == 400
  103. @pytest.mark.asyncio
  104. @pytest.mark.integration
  105. async def test_upload_rejects_non_png(self, async_client: AsyncClient, file_factory, isolated_storage):
  106. library_file = await file_factory(file_type="step")
  107. buf = io.BytesIO()
  108. Image.new("RGB", (64, 64), "blue").save(buf, "JPEG")
  109. response = await async_client.post(
  110. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  111. files={"thumbnail": ("preview.png", buf.getvalue(), "image/png")},
  112. )
  113. assert response.status_code == 400
  114. @pytest.mark.asyncio
  115. @pytest.mark.integration
  116. async def test_upload_rejects_garbage_bytes(self, async_client: AsyncClient, file_factory, isolated_storage):
  117. library_file = await file_factory(file_type="xlsx")
  118. response = await async_client.post(
  119. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  120. files={"thumbnail": ("preview.png", b"not an image at all", "image/png")},
  121. )
  122. assert response.status_code == 400
  123. @pytest.mark.asyncio
  124. @pytest.mark.integration
  125. async def test_upload_rejects_oversized_payload(self, async_client: AsyncClient, file_factory, isolated_storage):
  126. library_file = await file_factory(file_type="ods")
  127. oversized = b"\x89PNG\r\n\x1a\n" + b"\x00" * (2 * 1024 * 1024)
  128. response = await async_client.post(
  129. f"/api/v1/library/files/{library_file.id}/preview-thumbnail",
  130. files={"thumbnail": ("preview.png", oversized, "image/png")},
  131. )
  132. assert response.status_code == 413
  133. @pytest.mark.asyncio
  134. @pytest.mark.integration
  135. async def test_upload_missing_file_returns_404(self, async_client: AsyncClient, isolated_storage):
  136. response = await async_client.post(
  137. "/api/v1/library/files/999999/preview-thumbnail",
  138. files={"thumbnail": ("preview.png", _png_bytes(), "image/png")},
  139. )
  140. assert response.status_code == 404