| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157 |
- """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
|