test_library_pdf_thumbnail.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157
  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.
  6. """
  7. import io
  8. import zipfile
  9. import pytest
  10. from httpx import AsyncClient
  11. from PIL import Image
  12. from reportlab.lib.pagesizes import A4
  13. from reportlab.pdfgen import canvas
  14. from sqlalchemy import select
  15. from backend.app.core.config import settings as app_settings
  16. from backend.app.models.library import LibraryFile
  17. def _pdf_bytes() -> bytes:
  18. buf = io.BytesIO()
  19. c = canvas.Canvas(buf, pagesize=A4)
  20. c.rect(0, A4[1] - 200, 200, 200, fill=1, stroke=0)
  21. c.drawString(72, 72, "Bambuddy")
  22. c.showPage()
  23. c.save()
  24. return buf.getvalue()
  25. @pytest.fixture
  26. def isolated_storage(monkeypatch, tmp_path):
  27. """Point library and thumbnail storage at a throwaway directory.
  28. A subdirectory, so an external share created beside it in ``tmp_path``
  29. is not inside the Bambuddy-managed tree (which the mount refuses).
  30. """
  31. base = tmp_path / "data"
  32. base.mkdir()
  33. monkeypatch.setattr(app_settings, "base_dir", base)
  34. monkeypatch.setattr(app_settings, "archive_dir", base / "archive")
  35. return base
  36. def _assert_png_thumbnail(base_dir, thumbnail_path):
  37. assert thumbnail_path
  38. stored = base_dir / thumbnail_path
  39. assert stored.exists()
  40. with Image.open(stored) as img:
  41. assert img.format == "PNG"
  42. assert img.size == (256, 256)
  43. @pytest.mark.asyncio
  44. @pytest.mark.integration
  45. async def test_upload_renders_pdf_thumbnail(async_client: AsyncClient, isolated_storage):
  46. response = await async_client.post(
  47. "/api/v1/library/files",
  48. files={"file": ("manual.pdf", _pdf_bytes(), "application/pdf")},
  49. )
  50. assert response.status_code == 200
  51. body = response.json()
  52. assert body["file_type"] == "pdf"
  53. _assert_png_thumbnail(isolated_storage, body["thumbnail_path"])
  54. @pytest.mark.asyncio
  55. @pytest.mark.integration
  56. async def test_upload_of_unreadable_pdf_still_lands_without_thumbnail(async_client: AsyncClient, isolated_storage):
  57. response = await async_client.post(
  58. "/api/v1/library/files",
  59. files={"file": ("broken.pdf", b"%PDF-1.4 not really", "application/pdf")},
  60. )
  61. assert response.status_code == 200
  62. body = response.json()
  63. assert body["file_type"] == "pdf"
  64. # The browser preview can still post its own render later.
  65. assert body["thumbnail_path"] is None
  66. @pytest.mark.asyncio
  67. @pytest.mark.integration
  68. async def test_zip_extraction_renders_pdf_thumbnail(async_client: AsyncClient, db_session, isolated_storage):
  69. buf = io.BytesIO()
  70. with zipfile.ZipFile(buf, "w") as zf:
  71. zf.writestr("docs/manual.pdf", _pdf_bytes())
  72. response = await async_client.post(
  73. "/api/v1/library/files/extract-zip",
  74. files={"file": ("bundle.zip", buf.getvalue(), "application/zip")},
  75. )
  76. assert response.status_code == 200
  77. row = (await db_session.execute(select(LibraryFile).where(LibraryFile.filename == "manual.pdf"))).scalar_one()
  78. _assert_png_thumbnail(isolated_storage, row.thumbnail_path)
  79. @pytest.mark.asyncio
  80. @pytest.mark.integration
  81. async def test_external_scan_renders_pdf_thumbnail(
  82. async_client: AsyncClient, db_session, isolated_storage, tmp_path, monkeypatch
  83. ):
  84. monkeypatch.setenv("BAMBUDDY_EXTERNAL_ROOTS", str(tmp_path.parent))
  85. share = tmp_path / "nas_share"
  86. share.mkdir()
  87. (share / "manual.pdf").write_bytes(_pdf_bytes())
  88. created = await async_client.post(
  89. "/api/v1/library/folders/external",
  90. json={"name": "NAS", "external_path": str(share), "readonly": True, "show_hidden": False},
  91. )
  92. assert created.status_code == 200, created.text
  93. folder = created.json()
  94. response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
  95. assert response.status_code == 200
  96. files = (await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")).json()
  97. pdf = next(f for f in files if f["filename"] == "manual.pdf")
  98. row = await db_session.get(LibraryFile, pdf["id"])
  99. _assert_png_thumbnail(isolated_storage, row.thumbnail_path)
  100. @pytest.mark.asyncio
  101. @pytest.mark.integration
  102. async def test_batch_generate_backfills_pdf_without_thumbnail(async_client: AsyncClient, db_session, isolated_storage):
  103. pdf_path = isolated_storage / "archive" / "library" / "files" / "old.pdf"
  104. pdf_path.parent.mkdir(parents=True, exist_ok=True)
  105. pdf_path.write_bytes(_pdf_bytes())
  106. old = LibraryFile(
  107. filename="old.pdf",
  108. file_path="archive/library/files/old.pdf",
  109. file_type="pdf",
  110. file_size=pdf_path.stat().st_size,
  111. )
  112. # A spreadsheet is a client-thumbnail type too, but the server has no
  113. # renderer for it - the batch must leave it alone.
  114. sheet = LibraryFile(
  115. filename="parts.csv",
  116. file_path="archive/library/files/parts.csv",
  117. file_type="csv",
  118. file_size=10,
  119. )
  120. db_session.add_all([old, sheet])
  121. await db_session.commit()
  122. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json={"all_missing": True})
  123. assert response.status_code == 200
  124. result = response.json()
  125. assert result["processed"] == 1
  126. assert result["succeeded"] == 1
  127. assert result["results"][0]["file_id"] == old.id
  128. await db_session.refresh(old)
  129. await db_session.refresh(sheet)
  130. _assert_png_thumbnail(isolated_storage, old.thumbnail_path)
  131. assert sheet.thumbnail_path is None