mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-04 13:11:35 +02:00
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.
158 lines
5.5 KiB
Python
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
|