Files
bambuddy/backend/tests/integration/test_library_pdf_thumbnail.py
T
maziggy 86bd9e1bd4 fix(file-manager): harden the merged previews - PDF.js legacy build, server PDF thumbnails, STEP progress (issue #2976)
Follow-ups after merging PR #3128 (with the #2990 preview work):

- PDF preview failed on every browser without
  Map.prototype.getOrInsertComputed (Chrome < 145, Firefox < 144,
  older Safari): "This file cannot be previewed" for any PDF. Load
  pdf.js's legacy build, which bundles the polyfills for page and
  worker, and bundle the worker through Vite (?worker&url) so
  build.target lowers its class static block for Safari 16.0-16.3.
  The browser-baseline check only scanned .js output and never saw
  the copied .mjs worker; it scans .mjs too.
- PDF grid thumbnails only existed after someone opened the preview.
  Page one is now rendered server-side with PDFium (pypdfium2, new
  dependency, prebuilt wheels for every shipped platform) on upload,
  ZIP extraction and external-folder scans; Generate Thumbnails
  backfills PDFs as well. Renders are serialised behind a lock
  (PDFium is not thread-safe), run off the event loop, and scale
  from the page size so a huge MediaBox cannot allocate a huge
  bitmap. Unreadable PDFs land without a thumbnail and keep the
  browser fallback.
- A large STEP file takes over a minute to mesh in the browser and
  showed only a spinner. STEP loads now show "Converting STEP
  model... N s" and a note that large files can take a minute or
  more, in all 15 locales.
- Drop the two occt-import-js "externalized for browser
  compatibility" build warnings (path/crypto are only required in
  its Node branch); any other externalization still shows.
2026-09-26 09:51:52 +02:00

158 lines
5.5 KiB
Python

"""Integration tests for server-side PDF thumbnails (#2976).
A PDF gets its grid thumbnail when it enters the library - upload, ZIP
extraction, external-folder scan - instead of only after somebody has opened
the browser preview. The "Generate Thumbnails" batch backfills PDFs added
before that.
"""
import io
import zipfile
import pytest
from httpx import AsyncClient
from PIL import Image
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from sqlalchemy import select
from backend.app.core.config import settings as app_settings
from backend.app.models.library import LibraryFile
def _pdf_bytes() -> bytes:
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
c.rect(0, A4[1] - 200, 200, 200, fill=1, stroke=0)
c.drawString(72, 72, "Bambuddy")
c.showPage()
c.save()
return buf.getvalue()
@pytest.fixture
def isolated_storage(monkeypatch, tmp_path):
"""Point library and thumbnail storage at a throwaway directory.
A subdirectory, so an external share created beside it in ``tmp_path``
is not inside the Bambuddy-managed tree (which the mount refuses).
"""
base = tmp_path / "data"
base.mkdir()
monkeypatch.setattr(app_settings, "base_dir", base)
monkeypatch.setattr(app_settings, "archive_dir", base / "archive")
return base
def _assert_png_thumbnail(base_dir, thumbnail_path):
assert thumbnail_path
stored = base_dir / thumbnail_path
assert stored.exists()
with Image.open(stored) as img:
assert img.format == "PNG"
assert img.size == (256, 256)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_renders_pdf_thumbnail(async_client: AsyncClient, isolated_storage):
response = await async_client.post(
"/api/v1/library/files",
files={"file": ("manual.pdf", _pdf_bytes(), "application/pdf")},
)
assert response.status_code == 200
body = response.json()
assert body["file_type"] == "pdf"
_assert_png_thumbnail(isolated_storage, body["thumbnail_path"])
@pytest.mark.asyncio
@pytest.mark.integration
async def test_upload_of_unreadable_pdf_still_lands_without_thumbnail(async_client: AsyncClient, isolated_storage):
response = await async_client.post(
"/api/v1/library/files",
files={"file": ("broken.pdf", b"%PDF-1.4 not really", "application/pdf")},
)
assert response.status_code == 200
body = response.json()
assert body["file_type"] == "pdf"
# The browser preview can still post its own render later.
assert body["thumbnail_path"] is None
@pytest.mark.asyncio
@pytest.mark.integration
async def test_zip_extraction_renders_pdf_thumbnail(async_client: AsyncClient, db_session, isolated_storage):
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("docs/manual.pdf", _pdf_bytes())
response = await async_client.post(
"/api/v1/library/files/extract-zip",
files={"file": ("bundle.zip", buf.getvalue(), "application/zip")},
)
assert response.status_code == 200
row = (await db_session.execute(select(LibraryFile).where(LibraryFile.filename == "manual.pdf"))).scalar_one()
_assert_png_thumbnail(isolated_storage, row.thumbnail_path)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_external_scan_renders_pdf_thumbnail(
async_client: AsyncClient, db_session, isolated_storage, tmp_path, monkeypatch
):
monkeypatch.setenv("BAMBUDDY_EXTERNAL_ROOTS", str(tmp_path.parent))
share = tmp_path / "nas_share"
share.mkdir()
(share / "manual.pdf").write_bytes(_pdf_bytes())
created = await async_client.post(
"/api/v1/library/folders/external",
json={"name": "NAS", "external_path": str(share), "readonly": True, "show_hidden": False},
)
assert created.status_code == 200, created.text
folder = created.json()
response = await async_client.post(f"/api/v1/library/folders/{folder['id']}/scan")
assert response.status_code == 200
files = (await async_client.get(f"/api/v1/library/files?folder_id={folder['id']}")).json()
pdf = next(f for f in files if f["filename"] == "manual.pdf")
row = await db_session.get(LibraryFile, pdf["id"])
_assert_png_thumbnail(isolated_storage, row.thumbnail_path)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_batch_generate_backfills_pdf_without_thumbnail(async_client: AsyncClient, db_session, isolated_storage):
pdf_path = isolated_storage / "archive" / "library" / "files" / "old.pdf"
pdf_path.parent.mkdir(parents=True, exist_ok=True)
pdf_path.write_bytes(_pdf_bytes())
old = LibraryFile(
filename="old.pdf",
file_path="archive/library/files/old.pdf",
file_type="pdf",
file_size=pdf_path.stat().st_size,
)
# A spreadsheet is a client-thumbnail type too, but the server has no
# renderer for it - the batch must leave it alone.
sheet = LibraryFile(
filename="parts.csv",
file_path="archive/library/files/parts.csv",
file_type="csv",
file_size=10,
)
db_session.add_all([old, sheet])
await db_session.commit()
response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json={"all_missing": True})
assert response.status_code == 200
result = response.json()
assert result["processed"] == 1
assert result["succeeded"] == 1
assert result["results"][0]["file_id"] == old.id
await db_session.refresh(old)
await db_session.refresh(sheet)
_assert_png_thumbnail(isolated_storage, old.thumbnail_path)
assert sheet.thumbnail_path is None