105 lines
4.1 KiB
Python
105 lines
4.1 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.core.errors import EncryptedPDFError, InvalidPDFError, PDFTextUnavailableError
|
|
from app.services.pdf import extract_resume, sanitize_filename
|
|
from tests.conftest import make_encrypted_pdf, make_pdf, resume_pdf
|
|
|
|
|
|
class TestSanitizeFilename:
|
|
@pytest.mark.parametrize(
|
|
("raw", "expected"),
|
|
[
|
|
("resume.pdf", "resume.pdf"),
|
|
("../../etc/passwd.pdf", "passwd.pdf"),
|
|
# PurePosixPath alone leaves this untouched on POSIX -- the reason
|
|
# PureWindowsPath runs first.
|
|
(r"..\..\windows\system32\evil.pdf", "evil.pdf"),
|
|
(r"C:\Users\me\Desktop\cv.pdf", "cv.pdf"),
|
|
("/absolute/path/cv.pdf", "cv.pdf"),
|
|
("mixed/sep\\cv.pdf", "cv.pdf"),
|
|
('cv<>:"|?*.pdf', "cv_______.pdf"),
|
|
("", "resume.pdf"),
|
|
(None, "resume.pdf"),
|
|
("..", "resume.pdf"),
|
|
("...", "resume.pdf"),
|
|
],
|
|
)
|
|
def test_reduces_to_safe_basename(self, raw: str | None, expected: str) -> None:
|
|
assert sanitize_filename(raw) == expected
|
|
|
|
def test_strips_null_bytes(self) -> None:
|
|
assert sanitize_filename("cv\x00.pdf") == "cv_.pdf"
|
|
|
|
def test_caps_length(self) -> None:
|
|
assert len(sanitize_filename("a" * 400 + ".pdf")) == 255
|
|
|
|
|
|
class TestExtractResume:
|
|
def test_extracts_text_from_a_valid_pdf(self) -> None:
|
|
resume = extract_resume(resume_pdf("Ada"), "ada.pdf", 60_000)
|
|
|
|
assert "Candidate Ada" in resume.text
|
|
assert resume.page_count == 1
|
|
assert resume.truncated is False
|
|
assert resume.candidate_id
|
|
|
|
def test_joins_pages_in_order_with_separators(self) -> None:
|
|
data = make_pdf([["First page content here."], ["Second page content here."]])
|
|
|
|
resume = extract_resume(data, "multi.pdf", 60_000)
|
|
|
|
assert resume.page_count == 2
|
|
assert "[Page 1]" in resume.text
|
|
assert "[Page 2]" in resume.text
|
|
assert resume.text.index("[Page 1]") < resume.text.index("[Page 2]")
|
|
assert resume.text.index("First page") < resume.text.index("Second page")
|
|
|
|
def test_rejects_a_pdf_with_no_extractable_text(self) -> None:
|
|
"""Stands in for a scanned/image-only resume."""
|
|
with pytest.raises(PDFTextUnavailableError):
|
|
extract_resume(make_pdf([[]]), "scanned.pdf", 60_000)
|
|
|
|
def test_rejects_text_below_the_usable_threshold(self) -> None:
|
|
with pytest.raises(PDFTextUnavailableError):
|
|
extract_resume(make_pdf([["hi"]]), "tiny.pdf", 60_000)
|
|
|
|
def test_rejects_missing_pdf_signature(self) -> None:
|
|
with pytest.raises(InvalidPDFError):
|
|
extract_resume(b"this is plain text, not a pdf at all", "fake.pdf", 60_000)
|
|
|
|
def test_rejects_malformed_pdf(self) -> None:
|
|
with pytest.raises(InvalidPDFError):
|
|
extract_resume(b"%PDF-1.4\ngarbage garbage garbage", "broken.pdf", 60_000)
|
|
|
|
def test_rejects_encrypted_pdf(self) -> None:
|
|
with pytest.raises(EncryptedPDFError):
|
|
extract_resume(make_encrypted_pdf(), "locked.pdf", 60_000)
|
|
|
|
def test_truncates_at_the_configured_limit_and_records_it(self) -> None:
|
|
long_pdf = make_pdf([[f"Line {i} of a very long resume document." for i in range(400)]])
|
|
|
|
resume = extract_resume(long_pdf, "long.pdf", 500)
|
|
|
|
assert resume.truncated is True
|
|
assert len(resume.text) <= 500
|
|
|
|
def test_does_not_flag_truncation_when_under_the_limit(self) -> None:
|
|
resume = extract_resume(resume_pdf("Ada"), "ada.pdf", 60_000)
|
|
assert resume.truncated is False
|
|
|
|
def test_normalises_whitespace_without_destroying_line_breaks(self) -> None:
|
|
data = make_pdf([["Header line for the resume", "Body line for the resume"]])
|
|
|
|
resume = extract_resume(data, "spaced.pdf", 60_000)
|
|
|
|
assert "\n" in resume.text
|
|
assert " " not in resume.text
|
|
assert "\x00" not in resume.text
|
|
|
|
def test_tolerates_junk_bytes_before_the_signature(self) -> None:
|
|
data = b"\n\n" + resume_pdf("Ada")
|
|
resume = extract_resume(data, "ada.pdf", 60_000)
|
|
assert "Candidate Ada" in resume.text
|