test_library_pdf_thumbnail.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. """Integration tests for server-side PDF thumbnails (#2976).
  2. A PDF gets its grid thumbnail when it enters the library - upload, ZIP
  3. extraction, external-folder scan - instead of only after somebody has opened
  4. the browser preview. The "Generate Thumbnails" batch backfills PDFs added
  5. before that. An external scan renders them in its background backfill, not
  6. inside the scan request.
  7. """
  8. import io
  9. import zipfile
  10. import pytest
  11. from httpx import AsyncClient
  12. from PIL import Image
  13. from reportlab.lib.pagesizes import A4
  14. from reportlab.pdfgen import canvas
  15. from sqlalchemy import select
  16. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  17. from backend.app.api.routes import library as library_routes
  18. from backend.app.core.config import settings as app_settings
  19. from backend.app.models.library import LibraryFile
  20. from backend.tests.integration.test_ownership_permissions import TestOwnershipPermissionsSetup
  21. def _pdf_bytes() -> bytes:
  22. buf = io.BytesIO()
  23. c = canvas.Canvas(buf, pagesize=A4)
  24. c.rect(0, A4[1] - 200, 200, 200, fill=1, stroke=0)
  25. c.drawString(72, 72, "Bambuddy")
  26. c.showPage()
  27. c.save()
  28. return buf.getvalue()
  29. @pytest.fixture
  30. def isolated_storage(monkeypatch, tmp_path):
  31. """Point library and thumbnail storage at a throwaway directory.
  32. A subdirectory, so an external share created beside it in ``tmp_path``
  33. is not inside the Bambuddy-managed tree (which the mount refuses).
  34. """
  35. base = tmp_path / "data"
  36. base.mkdir()
  37. monkeypatch.setattr(app_settings, "base_dir", base)
  38. monkeypatch.setattr(app_settings, "archive_dir", base / "archive")
  39. return base
  40. def _assert_png_thumbnail(base_dir, thumbnail_path):
  41. assert thumbnail_path
  42. stored = base_dir / thumbnail_path
  43. assert stored.exists()
  44. with Image.open(stored) as img:
  45. assert img.format == "PNG"
  46. assert img.size == (256, 256)
  47. @pytest.mark.asyncio
  48. @pytest.mark.integration
  49. async def test_upload_renders_pdf_thumbnail(async_client: AsyncClient, isolated_storage):
  50. response = await async_client.post(
  51. "/api/v1/library/files",
  52. files={"file": ("manual.pdf", _pdf_bytes(), "application/pdf")},
  53. )
  54. assert response.status_code == 200
  55. body = response.json()
  56. assert body["file_type"] == "pdf"
  57. _assert_png_thumbnail(isolated_storage, body["thumbnail_path"])
  58. @pytest.mark.asyncio
  59. @pytest.mark.integration
  60. async def test_upload_of_unreadable_pdf_still_lands_without_thumbnail(async_client: AsyncClient, isolated_storage):
  61. response = await async_client.post(
  62. "/api/v1/library/files",
  63. files={"file": ("broken.pdf", b"%PDF-1.4 not really", "application/pdf")},
  64. )
  65. assert response.status_code == 200
  66. body = response.json()
  67. assert body["file_type"] == "pdf"
  68. # The browser preview can still post its own render later.
  69. assert body["thumbnail_path"] is None
  70. @pytest.mark.asyncio
  71. @pytest.mark.integration
  72. async def test_zip_extraction_renders_pdf_thumbnail(async_client: AsyncClient, db_session, isolated_storage):
  73. buf = io.BytesIO()
  74. with zipfile.ZipFile(buf, "w") as zf:
  75. zf.writestr("docs/manual.pdf", _pdf_bytes())
  76. response = await async_client.post(
  77. "/api/v1/library/files/extract-zip",
  78. files={"file": ("bundle.zip", buf.getvalue(), "application/zip")},
  79. )
  80. assert response.status_code == 200
  81. row = (await db_session.execute(select(LibraryFile).where(LibraryFile.filename == "manual.pdf"))).scalar_one()
  82. _assert_png_thumbnail(isolated_storage, row.thumbnail_path)
  83. @pytest.mark.asyncio
  84. @pytest.mark.integration
  85. async def test_external_scan_renders_pdf_thumbnail(
  86. async_client: AsyncClient, db_session, isolated_storage, tmp_path, monkeypatch
  87. ):
  88. monkeypatch.setenv("BAMBUDDY_EXTERNAL_ROOTS", str(tmp_path.parent))
  89. share = tmp_path / "nas_share"
  90. share.mkdir()
  91. (share / "manual.pdf").write_bytes(_pdf_bytes())
  92. # The scan hands thumbnails to a background task. Capture it instead of
  93. # letting it run on its own, and give it the test database: the route
  94. # module holds its own reference to the real async_session.
  95. spawned = []
  96. monkeypatch.setattr(library_routes, "spawn_background_task", lambda coro, **_: spawned.append(coro))
  97. monkeypatch.setattr(
  98. library_routes,
  99. "async_session",
  100. async_sessionmaker(db_session.bind, class_=AsyncSession, expire_on_commit=False),
  101. )
  102. created = await async_client.post(
  103. "/api/v1/library/folders/external",
  104. json={"name": "NAS", "external_path": str(share), "readonly": True, "show_hidden": False},
  105. )
  106. assert created.status_code == 200, created.text
  107. folder = created.json()
  108. response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  109. assert response.status_code == 200
  110. files = (await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")).json()
  111. pdf = next(f for f in files if f["filename"] == "manual.pdf")
  112. row = await db_session.get(LibraryFile, pdf["id"])
  113. # The scan request itself leaves the rendering to the backfill...
  114. assert row.thumbnail_path is None
  115. assert len(spawned) == 1
  116. # ...which fills it in.
  117. await spawned[0]
  118. await db_session.refresh(row)
  119. _assert_png_thumbnail(isolated_storage, row.thumbnail_path)
  120. @pytest.mark.asyncio
  121. @pytest.mark.integration
  122. async def test_batch_generate_backfills_pdf_without_thumbnail(async_client: AsyncClient, db_session, isolated_storage):
  123. pdf_path = isolated_storage / "archive" / "library" / "files" / "old.pdf"
  124. pdf_path.parent.mkdir(parents=True, exist_ok=True)
  125. pdf_path.write_bytes(_pdf_bytes())
  126. old = LibraryFile(
  127. filename="old.pdf",
  128. file_path="archive/library/files/old.pdf",
  129. file_type="pdf",
  130. file_size=pdf_path.stat().st_size,
  131. )
  132. # A spreadsheet is a client-thumbnail type too, but the server has no
  133. # renderer for it - the batch must leave it alone.
  134. sheet = LibraryFile(
  135. filename="parts.csv",
  136. file_path="archive/library/files/parts.csv",
  137. file_type="csv",
  138. file_size=10,
  139. )
  140. db_session.add_all([old, sheet])
  141. await db_session.commit()
  142. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json={"all_missing": True})
  143. assert response.status_code == 200
  144. result = response.json()
  145. assert result["processed"] == 1
  146. assert result["succeeded"] == 1
  147. assert result["results"][0]["file_id"] == old.id
  148. await db_session.refresh(old)
  149. await db_session.refresh(sheet)
  150. _assert_png_thumbnail(isolated_storage, old.thumbnail_path)
  151. assert sheet.thumbnail_path is None
  152. class TestBatchThumbnailOwnership(TestOwnershipPermissionsSetup):
  153. """Operators hold library:update_own only. The File Manager offers them the
  154. toolbar button and the per-file "Generate Thumbnail" entry, so the batch
  155. route must serve them - narrowed to their own files."""
  156. @staticmethod
  157. async def _pdf_row(db_session, base_dir, name, owner_id):
  158. pdf_path = base_dir / "archive" / "library" / "files" / name
  159. pdf_path.parent.mkdir(parents=True, exist_ok=True)
  160. pdf_path.write_bytes(_pdf_bytes())
  161. row = LibraryFile(
  162. filename=name,
  163. file_path=f"archive/library/files/{name}",
  164. file_type="pdf",
  165. file_size=pdf_path.stat().st_size,
  166. created_by_id=owner_id,
  167. )
  168. db_session.add(row)
  169. await db_session.commit()
  170. await db_session.refresh(row)
  171. return row
  172. @pytest.mark.asyncio
  173. @pytest.mark.integration
  174. async def test_operator_all_missing_covers_only_their_own_files(
  175. self, async_client: AsyncClient, db_session, auth_setup, isolated_storage
  176. ):
  177. mine = await self._pdf_row(db_session, isolated_storage, "mine.pdf", auth_setup["operator_user"]["id"])
  178. theirs = await self._pdf_row(db_session, isolated_storage, "theirs.pdf", auth_setup["operator2_user"]["id"])
  179. ownerless = await self._pdf_row(db_session, isolated_storage, "ownerless.pdf", None)
  180. response = await async_client.post(
  181. "/api/v1/library/generate-stl-thumbnails",
  182. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  183. json={"all_missing": True},
  184. )
  185. assert response.status_code == 200, response.text
  186. assert [r["file_id"] for r in response.json()["results"]] == [mine.id]
  187. for row in (mine, theirs, ownerless):
  188. await db_session.refresh(row)
  189. _assert_png_thumbnail(isolated_storage, mine.thumbnail_path)
  190. assert theirs.thumbnail_path is None
  191. assert ownerless.thumbnail_path is None
  192. @pytest.mark.asyncio
  193. @pytest.mark.integration
  194. async def test_operator_cannot_generate_for_someone_elses_file(
  195. self, async_client: AsyncClient, db_session, auth_setup, isolated_storage
  196. ):
  197. theirs = await self._pdf_row(db_session, isolated_storage, "theirs.pdf", auth_setup["operator2_user"]["id"])
  198. response = await async_client.post(
  199. "/api/v1/library/generate-stl-thumbnails",
  200. headers={"Authorization": f"Bearer {auth_setup['operator_token']}"},
  201. json={"file_ids": [theirs.id]},
  202. )
  203. assert response.status_code == 200, response.text
  204. assert response.json()["processed"] == 0
  205. await db_session.refresh(theirs)
  206. assert theirs.thumbnail_path is None
  207. @pytest.mark.asyncio
  208. @pytest.mark.integration
  209. async def test_admin_all_missing_covers_everyone(
  210. self, async_client: AsyncClient, db_session, auth_setup, isolated_storage
  211. ):
  212. mine = await self._pdf_row(db_session, isolated_storage, "mine.pdf", auth_setup["operator_user"]["id"])
  213. ownerless = await self._pdf_row(db_session, isolated_storage, "ownerless.pdf", None)
  214. response = await async_client.post(
  215. "/api/v1/library/generate-stl-thumbnails",
  216. headers={"Authorization": f"Bearer {auth_setup['admin_token']}"},
  217. json={"all_missing": True},
  218. )
  219. assert response.status_code == 200, response.text
  220. assert sorted(r["file_id"] for r in response.json()["results"]) == sorted([mine.id, ownerless.id])