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