homelab-codex-ws/jobs/documents-ingest/tests/test_extractor.py

418 lines
17 KiB
Python
Raw Normal View History

"""Unit tests for the documents-ingest PDF extractor — no DB, no external services."""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from email import encoders
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import pytest
from documents_ingest.extractor import (
Candidate,
build_consume_name,
find_pdf_parts,
load_registry,
pdf_candidates_from_entities,
process_candidate,
run,
sanitize_filename,
save_registry,
)
def _build_eml(parts: list[tuple[str, str, bytes]]) -> bytes:
"""parts: list of (filename, content_type, bytes). Builds a multipart .eml."""
msg = MIMEMultipart()
msg["From"] = "alice@example.com"
msg["To"] = "bob@example.com"
msg["Subject"] = "Invoice attached"
msg["Message-ID"] = "<test@example.com>"
msg["Date"] = "Tue, 10 Jun 2025 12:00:00 +0000"
msg.attach(MIMEText("See attached", "plain"))
for filename, content_type, data in parts:
maintype, subtype = content_type.split("/", 1)
att = MIMEApplication(data, _subtype=subtype) if maintype == "application" else None
if att is None:
from email.mime.base import MIMEBase
att = MIMEBase(maintype, subtype)
att.set_payload(data)
encoders.encode_base64(att)
att.add_header("Content-Disposition", "attachment", filename=filename)
msg.attach(att)
return msg.as_bytes()
PDF_BYTES = b"%PDF-1.4 fake pdf content for testing"
PDF_SHA256 = hashlib.sha256(PDF_BYTES).hexdigest()
class TestSanitizeFilename:
def test_strips_path_components(self):
assert sanitize_filename("../../etc/passwd.pdf") == "passwd.pdf"
def test_replaces_unsafe_chars(self):
assert sanitize_filename("faktura #123 (final).pdf") == "faktura_123_final_.pdf"
def test_appends_pdf_extension_if_missing(self):
assert sanitize_filename("report").endswith(".pdf")
def test_empty_name_falls_back(self):
assert sanitize_filename("") == "attachment.pdf"
def test_only_unsafe_chars_falls_back(self):
assert sanitize_filename("///...") == "attachment.pdf"
def test_truncates_long_names(self):
long_name = ("a" * 300) + ".pdf"
result = sanitize_filename(long_name)
assert len(result) <= 150
assert result.endswith(".pdf")
def test_preserves_reasonable_name(self):
assert sanitize_filename("faktura_2026-06.pdf") == "faktura_2026-06.pdf"
class TestBuildConsumeName:
def test_basic_name(self):
ts = datetime(2026, 6, 1, tzinfo=timezone.utc)
used: set[str] = set()
name = build_consume_name(ts, "invoice.pdf", "abc123", used)
assert name == "2026-06-01_invoice.pdf"
def test_collision_gets_hash_suffix(self):
ts = datetime(2026, 6, 1, tzinfo=timezone.utc)
used: set[str] = set()
first = build_consume_name(ts, "invoice.pdf", "aaaa1111", used)
second = build_consume_name(ts, "invoice.pdf", "bbbb2222", used)
assert first != second
assert second == "2026-06-01_invoice_bbbb2222.pdf"
def test_names_are_registered_in_used_set(self):
ts = datetime(2026, 6, 1, tzinfo=timezone.utc)
used: set[str] = set()
name = build_consume_name(ts, "invoice.pdf", "abc123", used)
assert name in used
class TestPdfCandidatesFromEntities:
ts = datetime(2026, 6, 1, tzinfo=timezone.utc)
def test_filters_by_content_type_and_size(self):
entities = [
{"type": "attachment", "content_type": "application/pdf", "size": 60000,
"filename": "big.pdf", "sha256": "x"},
{"type": "attachment", "content_type": "application/pdf", "size": 1000,
"filename": "small.pdf", "sha256": "y"},
{"type": "attachment", "content_type": "image/png", "size": 100000,
"filename": "photo.png", "sha256": "z"},
]
candidates = pdf_candidates_from_entities("env1", self.ts, entities, min_size=50000)
assert len(candidates) == 1
assert candidates[0].filename == "big.pdf"
def test_skips_entries_without_sha256(self):
entities = [
{"type": "attachment", "content_type": "application/pdf", "size": 60000,
"filename": "big.pdf"},
]
assert pdf_candidates_from_entities("env1", self.ts, entities, min_size=50000) == []
def test_ignores_non_attachment_entries(self):
entities = [{"type": "inline", "content_type": "application/pdf", "size": 60000}]
assert pdf_candidates_from_entities("env1", self.ts, entities, min_size=50000) == []
class TestFindPdfParts:
def test_finds_pdf_part(self):
raw = _build_eml([("report.pdf", "application/pdf", PDF_BYTES)])
parts = find_pdf_parts(raw)
assert parts == [("report.pdf", PDF_BYTES)]
def test_no_pdf_parts_returns_empty(self):
raw = _build_eml([("report.png", "image/png", b"not a pdf")])
assert find_pdf_parts(raw) == []
def test_multiple_pdf_attachments_all_returned(self):
raw = _build_eml([
("a.pdf", "application/pdf", PDF_BYTES),
("b.pdf", "application/pdf", b"other pdf"),
("photo.png", "image/png", b"png bytes"),
])
parts = find_pdf_parts(raw)
assert ("a.pdf", PDF_BYTES) in parts
assert ("b.pdf", b"other pdf") in parts
assert len(parts) == 2
def test_decodes_rfc2047_encoded_filenames(self):
# Regression: the manifest (built by a different parser at import time)
# stores the RAW encoded-word filename; email.policy.default decodes it
# to real unicode here. process_candidate() must not require these to
# match byte-for-byte — sha256 is the authority (see TestProcessCandidate).
raw = _build_eml([
("=?UTF-8?b?ZmFrdHVyYV/FunJvZGxvLnBkZg==?=", "application/pdf", PDF_BYTES),
])
parts = find_pdf_parts(raw)
assert len(parts) == 1
filename, payload = parts[0]
assert filename == "faktura_źrodlo.pdf"
assert payload == PDF_BYTES
class TestProcessCandidate:
ts = datetime(2026, 6, 1, tzinfo=timezone.utc)
def _candidate(self, sha256=PDF_SHA256, filename="report.pdf"):
return Candidate(envelope_id="env1", ts=self.ts, filename=filename,
size=len(PDF_BYTES), sha256=sha256)
def test_extracted_on_sha_match(self):
raw = _build_eml([("report.pdf", "application/pdf", PDF_BYTES)])
status, example = process_candidate(raw, self._candidate(), {}, set(), apply=False)
assert status == "extracted"
assert example["target_name"] == "2026-06-01_report.pdf"
assert example["sha256"] == PDF_SHA256
def test_duplicate_when_sha_in_registry(self):
raw = _build_eml([("report.pdf", "application/pdf", PDF_BYTES)])
registry = {PDF_SHA256: {"envelope_id": "env0"}}
status, example = process_candidate(raw, self._candidate(), registry, set(), apply=False)
assert status == "duplicate"
assert example is None
def test_sha_mismatch_reported_and_skipped(self):
raw = _build_eml([("report.pdf", "application/pdf", PDF_BYTES)])
candidate = self._candidate(sha256="0" * 64)
status, example = process_candidate(raw, candidate, {}, set(), apply=False)
assert status == "sha_mismatch"
assert example is None
def test_parse_error_when_attachment_missing_from_mime(self):
raw = _build_eml([("other.pdf", "application/pdf", b"unrelated pdf bytes")])
status, example = process_candidate(raw, self._candidate(), {}, set(), apply=False)
assert status == "parse_error"
assert example is None
def test_sha_match_wins_over_filename_mismatch(self):
# Regression: manifest filename is the raw RFC 2047 encoded-word form,
# but the freshly re-parsed MIME part decodes to real unicode text.
# sha256 is the proof of identity — this must still extract.
raw = _build_eml([
("=?UTF-8?b?ZmFrdHVyYV/FunJvZGxvLnBkZg==?=", "application/pdf", PDF_BYTES),
])
candidate = self._candidate(filename="=?UTF-8?b?ZmFrdHVyYV/FunJvZGxvLnBkZg==?=")
status, example = process_candidate(raw, candidate, {}, set(), apply=False)
assert status == "extracted"
assert example["sha256"] == PDF_SHA256
# Consume/ filename is built from the decoded name, not the raw encoded-word.
assert example["filename"] == "faktura_źrodlo.pdf"
assert "=?UTF-8?" not in example["target_name"]
def test_apply_mode_includes_payload_for_writing(self):
raw = _build_eml([("report.pdf", "application/pdf", PDF_BYTES)])
status, example = process_candidate(raw, self._candidate(), {}, set(), apply=True)
assert status == "extracted"
assert example["payload"] == PDF_BYTES
def test_dry_run_mode_has_no_payload(self):
raw = _build_eml([("report.pdf", "application/pdf", PDF_BYTES)])
status, example = process_candidate(raw, self._candidate(), {}, set(), apply=False)
assert example["payload"] is None
class TestRegistry:
def test_missing_file_returns_empty_dict(self, tmp_path):
assert load_registry(tmp_path / "nope.json") == {}
def test_round_trip(self, tmp_path):
path = tmp_path / "sub" / "registry.json"
registry = {"abc123": {"envelope_id": "env1", "consume_name": "x.pdf"}}
save_registry(path, registry)
assert load_registry(path) == registry
def test_save_creates_parent_dirs(self, tmp_path):
path = tmp_path / "a" / "b" / "registry.json"
save_registry(path, {})
assert path.is_file()
class _FakeConn:
def __init__(self, rows):
self._rows = rows
async def fetch(self, query, *params):
return self._rows
async def close(self):
pass
def _row(envelope_id, ts, raw_ref, entities):
return {"id": envelope_id, "raw_ref": raw_ref, "ts": ts, "entities": json.dumps(entities)}
def _pdf_entity(filename="report.pdf", size=60000, sha256=PDF_SHA256):
return {"type": "attachment", "content_type": "application/pdf", "size": size,
"filename": filename, "sha256": sha256}
class TestRun:
ts = datetime(2026, 6, 1, tzinfo=timezone.utc)
def _setup_archive(self, tmp_path, raw_ref, raw_bytes):
archive_root = tmp_path / "archive"
eml_path = archive_root / raw_ref
eml_path.parent.mkdir(parents=True, exist_ok=True)
eml_path.write_bytes(raw_bytes)
return archive_root
def _patch_connect(self, monkeypatch, rows):
async def _fake_connect(dsn):
return _FakeConn(rows)
monkeypatch.setattr("documents_ingest.extractor.asyncpg.connect", _fake_connect)
async def test_dry_run_does_not_write_files_or_registry(self, tmp_path, monkeypatch):
raw = _build_eml([("report.pdf", "application/pdf", PDF_BYTES)])
archive_root = self._setup_archive(tmp_path, "gmail/2026/06/m1.eml", raw)
consume_dir = tmp_path / "consume"
consume_dir.mkdir()
registry_path = tmp_path / "registry.json"
rows = [_row("env1", self.ts, "gmail/2026/06/m1.eml", [_pdf_entity()])]
self._patch_connect(monkeypatch, rows)
stats, examples = await run(
dsn="postgresql://fake", archive_root=archive_root, consume_dir=consume_dir,
registry_path=registry_path, apply=False,
)
assert stats["extracted"] == 1
assert len(examples) == 1
assert list(consume_dir.iterdir()) == []
assert not registry_path.exists()
async def test_apply_writes_file_and_registry(self, tmp_path, monkeypatch):
raw = _build_eml([("report.pdf", "application/pdf", PDF_BYTES)])
archive_root = self._setup_archive(tmp_path, "gmail/2026/06/m1.eml", raw)
consume_dir = tmp_path / "consume"
consume_dir.mkdir()
registry_path = tmp_path / "registry.json"
rows = [_row("env1", self.ts, "gmail/2026/06/m1.eml", [_pdf_entity()])]
self._patch_connect(monkeypatch, rows)
stats, examples = await run(
dsn="postgresql://fake", archive_root=archive_root, consume_dir=consume_dir,
registry_path=registry_path, apply=True,
)
assert stats["extracted"] == 1
written = list(consume_dir.iterdir())
assert len(written) == 1
assert written[0].read_bytes() == PDF_BYTES
registry = load_registry(registry_path)
assert PDF_SHA256 in registry
async def test_idempotent_second_apply_run_skips_duplicate(self, tmp_path, monkeypatch):
raw = _build_eml([("report.pdf", "application/pdf", PDF_BYTES)])
archive_root = self._setup_archive(tmp_path, "gmail/2026/06/m1.eml", raw)
consume_dir = tmp_path / "consume"
consume_dir.mkdir()
registry_path = tmp_path / "registry.json"
rows = [_row("env1", self.ts, "gmail/2026/06/m1.eml", [_pdf_entity()])]
self._patch_connect(monkeypatch, rows)
await run(dsn="postgresql://fake", archive_root=archive_root, consume_dir=consume_dir,
registry_path=registry_path, apply=True)
stats2, examples2 = await run(
dsn="postgresql://fake", archive_root=archive_root, consume_dir=consume_dir,
registry_path=registry_path, apply=True,
)
assert stats2["extracted"] == 0
assert stats2["skipped_duplicate"] == 1
assert len(list(consume_dir.iterdir())) == 1
async def test_multiple_candidates_in_one_envelope(self, tmp_path, monkeypatch):
pdf2 = b"%PDF-1.4 second document"
sha2 = hashlib.sha256(pdf2).hexdigest()
raw = _build_eml([
("a.pdf", "application/pdf", PDF_BYTES),
("b.pdf", "application/pdf", pdf2),
])
archive_root = self._setup_archive(tmp_path, "gmail/2026/06/m1.eml", raw)
consume_dir = tmp_path / "consume"
consume_dir.mkdir()
registry_path = tmp_path / "registry.json"
rows = [_row("env1", self.ts, "gmail/2026/06/m1.eml", [
_pdf_entity(filename="a.pdf", sha256=PDF_SHA256),
_pdf_entity(filename="b.pdf", sha256=sha2),
])]
self._patch_connect(monkeypatch, rows)
stats, examples = await run(
dsn="postgresql://fake", archive_root=archive_root, consume_dir=consume_dir,
registry_path=registry_path, apply=True,
)
assert stats["extracted"] == 2
assert len(list(consume_dir.iterdir())) == 2
async def test_sha_mismatch_is_not_written(self, tmp_path, monkeypatch):
raw = _build_eml([("report.pdf", "application/pdf", PDF_BYTES)])
archive_root = self._setup_archive(tmp_path, "gmail/2026/06/m1.eml", raw)
consume_dir = tmp_path / "consume"
consume_dir.mkdir()
registry_path = tmp_path / "registry.json"
rows = [_row("env1", self.ts, "gmail/2026/06/m1.eml", [
_pdf_entity(sha256="0" * 64),
])]
self._patch_connect(monkeypatch, rows)
stats, examples = await run(
dsn="postgresql://fake", archive_root=archive_root, consume_dir=consume_dir,
registry_path=registry_path, apply=True,
)
assert stats["extracted"] == 0
assert stats["skipped_sha_mismatch"] == 1
assert list(consume_dir.iterdir()) == []
async def test_missing_eml_file_counts_as_error(self, tmp_path, monkeypatch):
archive_root = tmp_path / "archive"
archive_root.mkdir()
consume_dir = tmp_path / "consume"
consume_dir.mkdir()
registry_path = tmp_path / "registry.json"
rows = [_row("env1", self.ts, "gmail/2026/06/missing.eml", [_pdf_entity()])]
self._patch_connect(monkeypatch, rows)
stats, examples = await run(
dsn="postgresql://fake", archive_root=archive_root, consume_dir=consume_dir,
registry_path=registry_path, apply=True,
)
assert stats["errors"] == 1
assert stats["extracted"] == 0
async def test_envelope_without_qualifying_entities_is_scanned_but_skipped(self, tmp_path, monkeypatch):
archive_root = tmp_path / "archive"
archive_root.mkdir()
consume_dir = tmp_path / "consume"
consume_dir.mkdir()
registry_path = tmp_path / "registry.json"
rows = [_row("env1", self.ts, "gmail/2026/06/m1.eml", [
{"type": "attachment", "content_type": "image/png", "size": 100, "filename": "x.png", "sha256": "z"},
])]
self._patch_connect(monkeypatch, rows)
stats, examples = await run(
dsn="postgresql://fake", archive_root=archive_root, consume_dir=consume_dir,
registry_path=registry_path, apply=False,
)
assert stats["envelopes_scanned"] == 1
assert stats["pdf_candidates"] == 0
assert examples == []