Rescue Paperjet v1 implementation
This commit is contained in:
parent
7ff5c8130d
commit
db9e2ca51b
83 changed files with 6186 additions and 3894 deletions
121
backend/app/tests/test_api_workflows.py
Normal file
121
backend/app/tests/test_api_workflows.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""End-to-end coverage for the user-critical backend workflows."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pymupdf
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
_ROOT = Path.cwd() / ".pytest-paperjet-api"
|
||||
shutil.rmtree(_ROOT, ignore_errors=True)
|
||||
_ROOT.mkdir()
|
||||
os.environ.update(
|
||||
{
|
||||
"PAPERJET_SECRET_KEY": "test-secret-key",
|
||||
"PAPERJET_DATABASE_PATH": str(_ROOT / "db" / "app.sqlite"),
|
||||
"PAPERJET_PDF_STORAGE_PATH": str(_ROOT / "pdfs"),
|
||||
"PAPERJET_THUMBNAILS_PATH": str(_ROOT / "thumbnails"),
|
||||
"PAPERJET_DEBUG": "false",
|
||||
}
|
||||
)
|
||||
|
||||
from app.main import app # noqa: E402
|
||||
|
||||
|
||||
def _pdf_bytes() -> bytes:
|
||||
document = pymupdf.open()
|
||||
document.new_page(width=240, height=320)
|
||||
content = document.tobytes()
|
||||
document.close()
|
||||
return content
|
||||
|
||||
|
||||
def test_setup_upload_annotate_version_export_and_trash_restore() -> None:
|
||||
csrf = {"X-Requested-With": "XMLHttpRequest"}
|
||||
annotation_id = str(uuid.uuid4())
|
||||
annotation = {
|
||||
"id": annotation_id,
|
||||
"page": 0,
|
||||
"type": "text",
|
||||
"rect": {"x": 20, "y": 30, "width": 120, "height": 30},
|
||||
"rotation": 0,
|
||||
"z": 0,
|
||||
"props": {
|
||||
"text": "Persisted note",
|
||||
"fontFamily": "Liberation Sans",
|
||||
"fontSize": 14,
|
||||
"color": "#000000",
|
||||
},
|
||||
"createdAt": "2026-08-14T00:00:00Z",
|
||||
"updatedAt": "2026-08-14T00:00:00Z",
|
||||
}
|
||||
|
||||
with TestClient(app) as client:
|
||||
assert client.get("/api/v1/health").json()["status"] == "ok"
|
||||
assert client.get("/api/v1/auth/status").json()["setupRequired"] is True
|
||||
|
||||
setup = client.post("/api/v1/auth/setup", json={"password": "correct horse"}, headers=csrf)
|
||||
assert setup.status_code == 200
|
||||
assert client.get("/api/v1/auth/status").json()["loggedIn"] is True
|
||||
|
||||
upload = client.post(
|
||||
"/api/v1/documents",
|
||||
files={"file": ("sample.pdf", _pdf_bytes(), "application/pdf")},
|
||||
headers=csrf,
|
||||
)
|
||||
assert upload.status_code == 201
|
||||
document_id = upload.json()["id"]
|
||||
|
||||
saved = client.put(
|
||||
f"/api/v1/documents/{document_id}/annotations",
|
||||
json={"data": [annotation]},
|
||||
headers=csrf,
|
||||
)
|
||||
assert saved.status_code == 200
|
||||
assert client.get(f"/api/v1/documents/{document_id}/annotations").json()["data"] == [
|
||||
annotation
|
||||
]
|
||||
|
||||
version = client.post(
|
||||
f"/api/v1/documents/{document_id}/versions",
|
||||
json={"label": "Before export"},
|
||||
headers=csrf,
|
||||
)
|
||||
assert version.status_code == 201
|
||||
version_id = version.json()["id"]
|
||||
|
||||
changed = {**annotation, "props": {**annotation["props"], "text": "Changed"}}
|
||||
client.put(
|
||||
f"/api/v1/documents/{document_id}/annotations",
|
||||
json={"data": [changed]},
|
||||
headers=csrf,
|
||||
)
|
||||
restored = client.post(
|
||||
f"/api/v1/documents/{document_id}/versions/{version_id}/restore",
|
||||
headers=csrf,
|
||||
)
|
||||
assert restored.status_code == 200
|
||||
assert client.get(f"/api/v1/documents/{document_id}/annotations").json()["data"] == [
|
||||
annotation
|
||||
]
|
||||
|
||||
exported = client.post(
|
||||
f"/api/v1/documents/{document_id}/export",
|
||||
json={"flatten": True},
|
||||
headers=csrf,
|
||||
)
|
||||
assert exported.status_code == 200
|
||||
with pymupdf.open(stream=exported.content, filetype="pdf") as document:
|
||||
assert "Persisted note" in document[0].get_text("text")
|
||||
|
||||
assert client.delete(f"/api/v1/documents/{document_id}", headers=csrf).status_code == 200
|
||||
assert client.get("/api/v1/documents").json()["total"] == 0
|
||||
assert client.get("/api/v1/documents/trash").json()["total"] == 1
|
||||
assert (
|
||||
client.post(f"/api/v1/documents/{document_id}/restore", headers=csrf).status_code == 200
|
||||
)
|
||||
assert client.get("/api/v1/documents").json()["total"] == 1
|
||||
|
||||
shutil.rmtree(_ROOT, ignore_errors=True)
|
||||
121
backend/app/tests/test_export.py
Normal file
121
backend/app/tests/test_export.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"""Regression coverage for canonical-coordinate PDF export."""
|
||||
|
||||
import json
|
||||
|
||||
import pymupdf
|
||||
|
||||
from app.services.export.renderer import export_annotations
|
||||
|
||||
|
||||
def _source_pdf(path) -> None:
|
||||
document = pymupdf.open()
|
||||
page = document.new_page(width=720, height=936)
|
||||
page.set_cropbox(pymupdf.Rect(36, 72, 648, 864))
|
||||
page.set_rotation(90)
|
||||
document.save(path)
|
||||
document.close()
|
||||
|
||||
|
||||
def test_export_preserves_rotation_and_flattens_supported_annotations(tmp_path) -> None:
|
||||
source = tmp_path / "source.pdf"
|
||||
_source_pdf(source)
|
||||
annotations = [
|
||||
{
|
||||
"id": "text",
|
||||
"page": 0,
|
||||
"type": "text",
|
||||
"rect": {"x": 40, "y": 50, "width": 180, "height": 40},
|
||||
"props": {
|
||||
"text": "Exported text",
|
||||
"fontFamily": "Liberation Sans",
|
||||
"fontSize": 14,
|
||||
"color": "#112233",
|
||||
"styles": {"0": {"0": {"fill": "#ff0000", "fontWeight": "bold"}}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "line",
|
||||
"page": 0,
|
||||
"type": "draw",
|
||||
"rect": {"x": 40, "y": 110, "width": 80, "height": 40},
|
||||
"props": {
|
||||
"paths": [[0, 0], [80, 40]],
|
||||
"strokeColor": "#ff0000",
|
||||
"strokeWidth": 2,
|
||||
"opacity": 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "highlight",
|
||||
"page": 0,
|
||||
"type": "highlight",
|
||||
"rect": {"x": 40, "y": 170, "width": 100, "height": 20},
|
||||
"props": {"color": "#ffff00", "opacity": 0.4},
|
||||
},
|
||||
{
|
||||
"id": "signature",
|
||||
"page": 0,
|
||||
"type": "signature",
|
||||
"rect": {"x": 40, "y": 220, "width": 160, "height": 50},
|
||||
"props": {
|
||||
"mode": "type",
|
||||
"text": "Ava",
|
||||
"fontFamily": "Great Vibes",
|
||||
"color": "#000000",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "shape",
|
||||
"page": 0,
|
||||
"type": "shape",
|
||||
"rect": {"x": 180, "y": 170, "width": 60, "height": 30},
|
||||
"props": {"kind": "ellipse", "strokeColor": "#0000ff", "strokeWidth": 2},
|
||||
},
|
||||
{
|
||||
"id": "future",
|
||||
"page": 0,
|
||||
"type": "future-stamp",
|
||||
"rect": {"x": 0, "y": 0, "width": 10, "height": 10},
|
||||
"props": {},
|
||||
},
|
||||
]
|
||||
|
||||
exported = export_annotations(source, annotations, tmp_path / "assets")
|
||||
output = tmp_path / "exported.pdf"
|
||||
output.write_bytes(exported)
|
||||
|
||||
with pymupdf.open(output) as document:
|
||||
page = document[0]
|
||||
assert page.rotation == 90
|
||||
assert page.rect.width == 792
|
||||
assert page.rect.height == 612
|
||||
assert "Exported text" in page.get_text("text")
|
||||
assert "Ava" in page.get_text("text")
|
||||
|
||||
# Normalize only for inspection: the exported PDF still retains the
|
||||
# original page rotation above.
|
||||
page.set_rotation(0)
|
||||
words = page.get_text("words")
|
||||
text_word = next(word for word in words if word[4] == "Exported")
|
||||
assert 35 <= text_word[0] <= 45
|
||||
assert 45 <= text_word[1] <= 60
|
||||
assert len(page.get_drawings()) >= 3
|
||||
|
||||
|
||||
def test_export_does_not_mutate_annotation_input(tmp_path) -> None:
|
||||
source = tmp_path / "source.pdf"
|
||||
_source_pdf(source)
|
||||
annotations = [
|
||||
{
|
||||
"id": "unknown",
|
||||
"page": 0,
|
||||
"type": "not-yet-supported",
|
||||
"rect": {"x": 1, "y": 2, "width": 3, "height": 4},
|
||||
"props": {"future": True},
|
||||
}
|
||||
]
|
||||
before = json.loads(json.dumps(annotations))
|
||||
|
||||
export_annotations(source, annotations, tmp_path / "assets")
|
||||
|
||||
assert annotations == before
|
||||
Loading…
Add table
Add a link
Reference in a new issue