Files
pdf/gateway/tests/test_final_extraction.py
T
2026-06-10 19:33:27 +05:30

315 lines
12 KiB
Python

"""
Tests verifying the three bug fixes from the connectivity audit:
BUG-1 — FontInfo field name: gateway must return `fontName` (not `name`)
BUG-2 — Search case-sensitivity and whole-word filtering
BUG-3 — applyEdits canonical route POST /documents/{id}/edits
"""
import contextlib
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.services import engine
from app.services.store import document_store
# ---------------------------------------------------------------------------
# Guard: skip entire module if the engine is unavailable / built without PDFium
# ---------------------------------------------------------------------------
has_pdfium = False
if engine.is_available():
with contextlib.suppress(Exception):
has_pdfium = engine.require().engine_has_pdfium()
pytestmark = pytest.mark.skipif(
not engine.is_available() or not has_pdfium,
reason="pdfengine pybind11 module is not compiled/available, or was compiled without PDFium support.",
)
CORPUS_DIR = Path(__file__).parent.parent.parent / "corpus"
HELLO_WORLD_PDF = CORPUS_DIR / "basic" / "hello_world.pdf"
@pytest.fixture(autouse=True)
def clean_store():
with document_store._lock:
document_store._documents.clear()
yield
# ---------------------------------------------------------------------------
# BUG-1 — FontInfo field name
# ---------------------------------------------------------------------------
class TestBug1FontInfoFieldName:
"""
Gateway must serialise font records with the key `fontName`, not `name`.
The frontend FontInfo interface expects `fontName`.
"""
def test_document_fonts_response_has_fontName_key(self, client: TestClient):
assert HELLO_WORLD_PDF.exists(), f"Test corpus file not found at {HELLO_WORLD_PDF}"
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
assert upload_resp.status_code == 201
doc_id = upload_resp.json()["id"]
fonts_resp = client.get(f"/documents/{doc_id}/fonts")
assert fonts_resp.status_code == 200
fonts = fonts_resp.json()
# There must be at least one font in hello_world.pdf
assert len(fonts) > 0, "Expected at least one font in hello_world.pdf"
for font in fonts:
# The key MUST be 'fontName', not 'name'
assert "fontName" in font, (
f"Response font object missing 'fontName' key. Got keys: {list(font.keys())}"
)
assert "name" not in font, (
"Response font object must NOT have a bare 'name' key (frontend expects 'fontName')"
)
assert isinstance(font["fontName"], str)
assert len(font["fontName"]) > 0
def test_page_fonts_response_has_fontName_key(self, client: TestClient):
assert HELLO_WORLD_PDF.exists()
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
doc_id = upload_resp.json()["id"]
page_fonts_resp = client.get(f"/documents/{doc_id}/pages/0/fonts")
assert page_fonts_resp.status_code == 200
fonts = page_fonts_resp.json()
for font in fonts:
assert "fontName" in font
assert "name" not in font
# ---------------------------------------------------------------------------
# BUG-2 — Search: case-sensitive and whole-word options
# ---------------------------------------------------------------------------
class TestBug2SearchOptions:
"""
GET /documents/{id}/search must honour the `case_sensitive` and
`whole_words` query parameters forwarded by the frontend.
"""
def _upload(self, client: TestClient) -> str:
assert HELLO_WORLD_PDF.exists()
with open(HELLO_WORLD_PDF, "rb") as f:
r = client.post(
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
assert r.status_code == 201
return r.json()["id"]
# -- Basic case-insensitive (the old default behaviour must still work) --
def test_search_basic_case_insensitive(self, client: TestClient):
doc_id = self._upload(client)
# hello_world.pdf contains "Hello" or "hello" — search lowercase
resp = client.get(f"/documents/{doc_id}/search?q=hello")
assert resp.status_code == 200
results = resp.json()
assert len(results) > 0, "Expected at least one match for 'hello' (case-insensitive)"
# -- Case-sensitive: exact match must find the right casing -----------
def test_search_case_sensitive_exact_match(self, client: TestClient):
doc_id = self._upload(client)
# First find what text is actually on the page
text_resp = client.get(f"/documents/{doc_id}/pages/0/text")
assert text_resp.status_code == 200
page_text: str = text_resp.json()["text"]
# Derive a mixed-case word that exists in the document
words = [w for w in page_text.split() if len(w) >= 3 and w[0].isupper()]
if not words:
pytest.skip("No suitable mixed-case word found in hello_world.pdf for this test")
word = words[0] # e.g. "Hello"
lower_word = word.lower()
# Case-sensitive search for the correctly-cased word must find it
resp_exact = client.get(
f"/documents/{doc_id}/search?q={word}&case_sensitive=true"
)
assert resp_exact.status_code == 200
assert len(resp_exact.json()) > 0, (
f"case_sensitive=true search for '{word}' returned no results"
)
# Case-sensitive search for the lowercase version must NOT find it
# (only when the document only has the upper-cased version)
if lower_word != word:
resp_wrong_case = client.get(
f"/documents/{doc_id}/search?q={lower_word}&case_sensitive=true"
)
assert resp_wrong_case.status_code == 200
# The lowercase version should yield zero hits when document uses title-case
assert len(resp_wrong_case.json()) == 0, (
f"case_sensitive=true search for lowercase '{lower_word}' should return 0 "
f"results when document only has '{word}'"
)
# -- Case-sensitive vs case-insensitive: count must differ when casing matters
def test_search_case_insensitive_finds_more_or_equal(self, client: TestClient):
doc_id = self._upload(client)
text_resp = client.get(f"/documents/{doc_id}/pages/0/text")
page_text: str = text_resp.json()["text"]
words = [w.strip(".,;:()") for w in page_text.split() if len(w) >= 3]
if not words:
pytest.skip("No words found")
q = words[0].lower()
insensitive = client.get(f"/documents/{doc_id}/search?q={q}&case_sensitive=false")
sensitive = client.get(f"/documents/{doc_id}/search?q={q}&case_sensitive=true")
assert insensitive.status_code == 200
assert sensitive.status_code == 200
# Case-insensitive must find at least as many results as case-sensitive
assert len(insensitive.json()) >= len(sensitive.json())
# -- Whole-word: partial substring must NOT match -----------------------
def test_search_whole_words_no_partial_match(self, client: TestClient):
doc_id = self._upload(client)
# Find a multi-character word in the document
text_resp = client.get(f"/documents/{doc_id}/pages/0/text")
page_text: str = text_resp.json()["text"]
words = [w.strip(".,;:()") for w in page_text.split() if len(w) >= 4]
if not words:
pytest.skip("No suitable word found")
full_word = words[0].lower()
# A prefix that is NOT itself a word
partial = full_word[:-1]
# Partial substring should match without whole_words constraint
resp_partial = client.get(f"/documents/{doc_id}/search?q={partial}&whole_words=false")
assert resp_partial.status_code == 200
# With whole_words=true the partial prefix must NOT match the full word
resp_whole = client.get(f"/documents/{doc_id}/search?q={partial}&whole_words=true")
assert resp_whole.status_code == 200
partial_count = len(resp_partial.json())
whole_count = len(resp_whole.json())
# Whole-word search must return <= partial results
assert whole_count <= partial_count, (
f"whole_words=true returned {whole_count} results but partial search returned {partial_count}"
)
# -- whole_words=true for an exact word must still find it --------------
def test_search_whole_words_exact_word_found(self, client: TestClient):
doc_id = self._upload(client)
text_resp = client.get(f"/documents/{doc_id}/pages/0/text")
page_text: str = text_resp.json()["text"]
words = [w.strip(".,;:()") for w in page_text.split() if len(w) >= 3]
if not words:
pytest.skip("No words found")
q = words[0].lower()
resp = client.get(f"/documents/{doc_id}/search?q={q}&whole_words=true")
assert resp.status_code == 200
# The exact word (cleaned of punctuation) should appear somewhere
# We don't assert count > 0 unconditionally because punctuation stripping
# may have altered the word boundary check, but the request must succeed.
# ---------------------------------------------------------------------------
# BUG-3 — Canonical edits route
# ---------------------------------------------------------------------------
class TestBug3CanonicalEditsRoute:
"""
Edits must be accepted at the canonical REST route:
POST /documents/{id}/edits
(not only at the legacy compat alias POST /edits/{id}).
"""
def test_apply_edits_via_canonical_route(self, client: TestClient):
assert HELLO_WORLD_PDF.exists()
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
assert upload_resp.status_code == 201
doc_id = upload_resp.json()["id"]
edits_payload = {
"version": "1.0",
"operations": [
{
"id": "op_canonical_route_test",
"type": "text_overlay",
"pageIndex": 0,
"data": {
"text": "Canonical Route Test",
"x": 50.0,
"y": 50.0,
"width": 200.0,
"height": 20.0,
"fontSize": 12.0,
"fontFamily": "Helvetica",
"color": "#000000",
},
}
],
}
# Use the CANONICAL route — must return 200 with success
resp = client.post(f"/documents/{doc_id}/edits", json=edits_payload)
assert resp.status_code == 200, f"Canonical route failed: {resp.text}"
payload = resp.json()
assert payload["success"] is True
assert payload["newDocumentId"] != doc_id
def test_compat_edits_route_still_works(self, client: TestClient):
"""Regression guard: the compat alias must continue to work."""
assert HELLO_WORLD_PDF.exists()
with open(HELLO_WORLD_PDF, "rb") as f:
upload_resp = client.post(
"/documents", files={"file": (HELLO_WORLD_PDF.name, f, "application/pdf")}
)
doc_id = upload_resp.json()["id"]
edits_payload = {
"version": "1.0",
"operations": [
{
"id": "op_compat_route_test",
"type": "page_rotation",
"pageIndex": 0,
"data": {"rotation": 90},
}
],
}
resp = client.post(f"/edits/{doc_id}", json=edits_payload)
assert resp.status_code == 200, f"Compat route failed: {resp.text}"
assert resp.json()["success"] is True