Files
gh-christianlouis-docuelevate/tests/test_ocr_provider_coverage.py
copilot-swe-agent[bot] a2a4c6fc9a feat(ocr): add multi-language OCR support with per-pipeline language override
- Add OCR_LANGUAGES constant (28 languages, EN/DE/FR/ES/IT/PT/RU/ZH/JA/KO/AR/etc.)
- Add TESSERACT_TO_EASYOCR mapping for automatic code translation
- Add optional language constructor arg to TesseractOCRProvider/EasyOCRProvider
- Update get_ocr_providers() to accept and pass per-call language override
- Add language parameter to process_with_ocr Celery task
- Add _get_pipeline_ocr_language() helper to resolve OCR language from pipeline step config
- Update process_document to look up and pass pipeline OCR language to process_with_ocr
- Add ocr_language select config field (28 options) to pipeline OCR step schema
- Add language dropdown to pipeline UI (pipelines.html)
- Update docs/UserGuide.md and docs/API.md with language override documentation
- Add 27 new tests covering language constants, provider overrides, and pipeline lookup

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 21:47:12 +00:00

1298 lines
49 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Unit tests for app/utils/ocr_provider.py to boost coverage to 90%+.
Tests cover:
- embed_text_layer() all branches (file not found, no ocrmypdf, in-place mode,
timeout, non-zero returncode, success)
- OCRResult and OCRProvider base class
- AzureOCRProvider.process()
- TesseractOCRProvider.process()
- EasyOCRProvider.process()
- MistralOCRProvider.process() + _upload_pdf_and_get_document()
- GoogleDocAIOCRProvider.process()
- AWSTextractOCRProvider.process()
- get_ocr_providers() default, multiple providers, unknown name, fallback
- merge_ocr_results() empty, single, primary, longest, ai_merge, ai_merge failure
"""
import json
import subprocess
import sys
from unittest.mock import Mock, patch
import pytest
from app.utils.ocr_provider import (
KNOWN_OCR_PROVIDERS,
AWSTextractOCRProvider,
AzureOCRProvider,
EasyOCRProvider,
GoogleDocAIOCRProvider,
MistralOCRProvider,
OCRResult,
TesseractOCRProvider,
embed_text_layer,
get_ocr_providers,
merge_ocr_results,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_pdf(tmp_path, name="test.pdf") -> str:
"""Create a minimal valid file path for testing."""
p = tmp_path / name
p.write_bytes(b"%PDF-1.4 minimal")
return str(p)
# ---------------------------------------------------------------------------
# embed_text_layer
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestEmbedTextLayer:
"""Tests for the embed_text_layer helper."""
def test_file_not_found_raises(self, tmp_path):
"""Raises FileNotFoundError when input file does not exist."""
with pytest.raises(FileNotFoundError, match="input file not found"):
embed_text_layer(str(tmp_path / "nonexistent.pdf"), str(tmp_path / "out.pdf"))
def test_no_ocrmypdf_returns_false(self, tmp_path):
"""Returns False when ocrmypdf is not on PATH."""
pdf = _make_pdf(tmp_path)
with patch("shutil.which", return_value=None):
result = embed_text_layer(pdf, str(tmp_path / "out.pdf"))
assert result is False
def test_success_different_output(self, tmp_path):
"""Returns True when ocrmypdf exits 0 and output != input."""
pdf = _make_pdf(tmp_path)
out = str(tmp_path / "out.pdf")
mock_proc = Mock()
mock_proc.returncode = 0
with (
patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
patch("subprocess.run", return_value=mock_proc),
):
result = embed_text_layer(pdf, out)
assert result is True
def test_in_place_success(self, tmp_path):
"""Returns True and replaces file in-place when input == output."""
pdf = _make_pdf(tmp_path)
mock_proc = Mock()
mock_proc.returncode = 0
with (
patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
patch("subprocess.run", return_value=mock_proc),
patch("os.replace") as mock_replace,
):
result = embed_text_layer(pdf, pdf)
assert result is True
assert mock_replace.called
def test_timeout_returns_false(self, tmp_path):
"""Returns False when subprocess times out."""
pdf = _make_pdf(tmp_path)
with (
patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="ocrmypdf", timeout=600)),
):
result = embed_text_layer(pdf, str(tmp_path / "out.pdf"))
assert result is False
def test_timeout_in_place_removes_tmp(self, tmp_path):
"""Removes tmp file on timeout when in-place mode."""
pdf = _make_pdf(tmp_path)
# Create a fake tmp file that would be cleaned up
tmp_out = tmp_path / "tmp_ocr.pdf"
tmp_out.write_bytes(b"")
with (
patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="ocrmypdf", timeout=600)),
patch("tempfile.mkstemp", return_value=(99, str(tmp_out))),
patch("os.close"),
):
result = embed_text_layer(pdf, pdf)
assert result is False
def test_nonzero_returncode_returns_false(self, tmp_path):
"""Returns False when ocrmypdf exits with non-zero return code."""
pdf = _make_pdf(tmp_path)
mock_proc = Mock()
mock_proc.returncode = 1
mock_proc.stderr = "some error"
with (
patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
patch("subprocess.run", return_value=mock_proc),
):
result = embed_text_layer(pdf, str(tmp_path / "out.pdf"))
assert result is False
def test_nonzero_returncode_inplace_removes_tmp(self, tmp_path):
"""Removes tmp file on nonzero returncode when in-place mode."""
pdf = _make_pdf(tmp_path)
tmp_out = tmp_path / "tmp_ocr.pdf"
tmp_out.write_bytes(b"partial")
mock_proc = Mock()
mock_proc.returncode = 2
mock_proc.stderr = "fail"
with (
patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
patch("subprocess.run", return_value=mock_proc),
patch("tempfile.mkstemp", return_value=(99, str(tmp_out))),
patch("os.close"),
):
result = embed_text_layer(pdf, pdf)
assert result is False
def test_custom_language(self, tmp_path):
"""Passes custom language code to ocrmypdf."""
pdf = _make_pdf(tmp_path)
mock_proc = Mock()
mock_proc.returncode = 0
captured_cmd = {}
def fake_run(cmd, **kwargs):
captured_cmd["cmd"] = cmd
return mock_proc
with (
patch("shutil.which", return_value="/usr/bin/ocrmypdf"),
patch("subprocess.run", side_effect=fake_run),
):
embed_text_layer(pdf, str(tmp_path / "out.pdf"), language="deu")
assert "deu" in captured_cmd["cmd"]
# ---------------------------------------------------------------------------
# OCRResult
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestOCRResult:
"""Tests for OCRResult data class."""
def test_defaults(self):
r = OCRResult(provider="test", text="hello")
assert r.provider == "test"
assert r.text == "hello"
assert r.searchable_pdf_path is None
assert r.rotation_data == {}
assert r.metadata == {}
def test_repr(self):
r = OCRResult(provider="azure", text="hello world", searchable_pdf_path="/tmp/x.pdf")
s = repr(r)
assert "azure" in s
assert "has_pdf=True" in s
def test_with_rotation_and_metadata(self):
r = OCRResult(
provider="azure",
text="text",
rotation_data={0: 90.0},
metadata={"confidence": 0.99},
)
assert r.rotation_data == {0: 90.0}
assert r.metadata == {"confidence": 0.99}
# ---------------------------------------------------------------------------
# AzureOCRProvider
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestAzureOCRProvider:
"""Tests for AzureOCRProvider.process()."""
def test_missing_azure_key_raises(self, tmp_path):
"""Raises ValueError when AZURE_AI_KEY is not set."""
pdf = _make_pdf(tmp_path)
provider = AzureOCRProvider()
with patch("app.utils.ocr_provider.settings") as ms:
ms.azure_ai_key = None
ms.azure_endpoint = "https://example.cognitiveservices.azure.com"
with pytest.raises(ValueError, match="AZURE_AI_KEY"):
provider.process(pdf)
def test_missing_azure_endpoint_raises(self, tmp_path):
"""Raises ValueError when AZURE_ENDPOINT is not set."""
pdf = _make_pdf(tmp_path)
provider = AzureOCRProvider()
with patch("app.utils.ocr_provider.settings") as ms:
ms.azure_ai_key = "test-key"
ms.azure_endpoint = None
with pytest.raises(ValueError, match="AZURE_ENDPOINT"):
provider.process(pdf)
def test_successful_processing(self, tmp_path):
"""Returns OCRResult with extracted text and searchable PDF path."""
pdf = _make_pdf(tmp_path)
provider = AzureOCRProvider()
mock_page = Mock()
mock_page.angle = 1.5
mock_result = Mock()
mock_result.content = "extracted text"
mock_result.model_id = "prebuilt-read"
mock_result.pages = [mock_page]
mock_poller = Mock()
mock_poller.result.return_value = mock_result
mock_poller.details = {"operation_id": "op-123"}
mock_client = Mock()
mock_client.begin_analyze_document.return_value = mock_poller
mock_client.get_analyze_result_pdf.return_value = iter([b"pdf-content"])
mock_doc_intelligence = Mock()
mock_doc_intelligence.DocumentIntelligenceClient.return_value = mock_client
mock_analyze_output = Mock()
mock_analyze_output.PDF = "pdf"
mock_doc_intelligence.models.AnalyzeOutputOption = mock_analyze_output
mock_azure_cred = Mock()
with (
patch("app.utils.ocr_provider.settings") as ms,
patch.dict(
sys.modules,
{
"azure.ai.documentintelligence": mock_doc_intelligence,
"azure.ai.documentintelligence.models": Mock(AnalyzeOutputOption=mock_analyze_output),
"azure.core.credentials": Mock(AzureKeyCredential=mock_azure_cred),
},
),
):
ms.azure_ai_key = "test-key"
ms.azure_endpoint = "https://example.com"
result = provider.process(pdf)
assert isinstance(result, OCRResult)
assert result.provider == "azure"
assert result.text == "extracted text"
assert result.searchable_pdf_path == pdf
assert 0 in result.rotation_data
def test_no_rotation_data(self, tmp_path):
"""Works when pages have no rotation angle."""
pdf = _make_pdf(tmp_path)
provider = AzureOCRProvider()
mock_page = Mock()
mock_page.angle = 0
mock_result = Mock()
mock_result.content = "text"
mock_result.model_id = "prebuilt-read"
mock_result.pages = [mock_page]
mock_poller = Mock()
mock_poller.result.return_value = mock_result
mock_poller.details = {"operation_id": "op-123"}
mock_client = Mock()
mock_client.begin_analyze_document.return_value = mock_poller
mock_client.get_analyze_result_pdf.return_value = iter([b"pdf"])
mock_di = Mock()
mock_ao = Mock()
mock_ao.PDF = "pdf"
mock_di.DocumentIntelligenceClient.return_value = mock_client
with (
patch("app.utils.ocr_provider.settings") as ms,
patch.dict(
sys.modules,
{
"azure.ai.documentintelligence": mock_di,
"azure.ai.documentintelligence.models": Mock(AnalyzeOutputOption=mock_ao),
"azure.core.credentials": Mock(AzureKeyCredential=Mock()),
},
),
):
ms.azure_ai_key = "key"
ms.azure_endpoint = "https://x.com"
result = provider.process(pdf)
assert result.rotation_data == {}
def test_no_pages_attribute(self, tmp_path):
"""Works when result has no pages attribute."""
pdf = _make_pdf(tmp_path)
provider = AzureOCRProvider()
mock_result = Mock(spec=["content", "model_id"])
mock_result.content = "text"
mock_result.model_id = "prebuilt-read"
mock_poller = Mock()
mock_poller.result.return_value = mock_result
mock_poller.details = {"operation_id": "op-456"}
mock_client = Mock()
mock_client.begin_analyze_document.return_value = mock_poller
mock_client.get_analyze_result_pdf.return_value = iter([b"pdf"])
mock_di = Mock()
mock_ao = Mock()
mock_ao.PDF = "pdf"
mock_di.DocumentIntelligenceClient.return_value = mock_client
with (
patch("app.utils.ocr_provider.settings") as ms,
patch.dict(
sys.modules,
{
"azure.ai.documentintelligence": mock_di,
"azure.ai.documentintelligence.models": Mock(AnalyzeOutputOption=mock_ao),
"azure.core.credentials": Mock(AzureKeyCredential=Mock()),
},
),
):
ms.azure_ai_key = "key"
ms.azure_endpoint = "https://x.com"
result = provider.process(pdf)
assert result.rotation_data == {}
# ---------------------------------------------------------------------------
# TesseractOCRProvider
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestTesseractOCRProvider:
"""Tests for TesseractOCRProvider.process()."""
def test_import_error_raises_runtime(self, tmp_path):
"""Raises RuntimeError when pytesseract/pdf2image are not installed."""
pdf = _make_pdf(tmp_path)
provider = TesseractOCRProvider()
with patch.dict(sys.modules, {"pytesseract": None, "pdf2image": None}):
with pytest.raises(RuntimeError, match="pytesseract and pdf2image"):
provider.process(pdf)
def test_missing_language_raises_runtime(self, tmp_path):
"""Raises RuntimeError when required language data files are missing."""
pdf = _make_pdf(tmp_path)
provider = TesseractOCRProvider()
mock_pytesseract = Mock()
mock_pdf2image = Mock()
mock_pdf2image.convert_from_path.return_value = [Mock()]
with (
patch.dict(
sys.modules,
{"pytesseract": mock_pytesseract, "pdf2image": mock_pdf2image},
),
patch("app.utils.ocr_provider.settings") as ms,
patch(
"app.utils.ocr_language_manager.ensure_tesseract_languages",
return_value=["deu"],
),
):
ms.tesseract_cmd = None
ms.tesseract_language = "deu"
with pytest.raises(RuntimeError, match="deu"):
provider.process(pdf)
def test_success(self, tmp_path):
"""Returns OCRResult with extracted text on success."""
pdf = _make_pdf(tmp_path)
provider = TesseractOCRProvider()
mock_pytesseract = Mock()
mock_pytesseract.image_to_string.return_value = "page text"
mock_pytesseract.pytesseract = Mock()
mock_pdf2image = Mock()
mock_pdf2image.convert_from_path.return_value = [Mock()]
with (
patch.dict(
sys.modules,
{"pytesseract": mock_pytesseract, "pdf2image": mock_pdf2image},
),
patch("app.utils.ocr_provider.settings") as ms,
patch(
"app.utils.ocr_language_manager.ensure_tesseract_languages",
return_value=[],
),
):
ms.tesseract_cmd = None
ms.tesseract_language = "eng"
result = provider.process(pdf)
assert result.provider == "tesseract"
assert "page text" in result.text
def test_tesseract_cmd_set(self, tmp_path):
"""Sets pytesseract.tesseract_cmd when configured."""
pdf = _make_pdf(tmp_path)
provider = TesseractOCRProvider()
mock_pytesseract = Mock()
mock_pytesseract.pytesseract = Mock()
mock_pytesseract.image_to_string.return_value = ""
mock_pdf2image = Mock()
mock_pdf2image.convert_from_path.return_value = []
with (
patch.dict(
sys.modules,
{"pytesseract": mock_pytesseract, "pdf2image": mock_pdf2image},
),
patch("app.utils.ocr_provider.settings") as ms,
patch(
"app.utils.ocr_language_manager.ensure_tesseract_languages",
return_value=[],
),
):
ms.tesseract_cmd = "/usr/local/bin/tesseract"
ms.tesseract_language = "eng"
result = provider.process(pdf)
assert mock_pytesseract.pytesseract.tesseract_cmd == "/usr/local/bin/tesseract"
assert result.provider == "tesseract"
# ---------------------------------------------------------------------------
# EasyOCRProvider
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestEasyOCRProvider:
"""Tests for EasyOCRProvider.process()."""
def test_import_error_raises_runtime(self, tmp_path):
"""Raises RuntimeError when easyocr/pdf2image are not installed."""
pdf = _make_pdf(tmp_path)
provider = EasyOCRProvider()
with patch.dict(sys.modules, {"easyocr": None, "pdf2image": None}):
with pytest.raises(RuntimeError, match="easyocr and pdf2image"):
provider.process(pdf)
def test_success(self, tmp_path):
"""Returns OCRResult with extracted text on success."""
pdf = _make_pdf(tmp_path)
provider = EasyOCRProvider()
mock_reader = Mock()
mock_reader.readtext.return_value = ["line1", "line2"]
mock_easyocr = Mock()
mock_easyocr.Reader.return_value = mock_reader
mock_pdf2image = Mock()
mock_pdf2image.convert_from_path.return_value = [Mock()]
with (
patch.dict(
sys.modules,
{"easyocr": mock_easyocr, "pdf2image": mock_pdf2image},
),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.easyocr_languages = "en,fr"
ms.easyocr_gpu = False
result = provider.process(pdf)
assert result.provider == "easyocr"
assert "line1" in result.text
# ---------------------------------------------------------------------------
# MistralOCRProvider
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestMistralOCRProvider:
"""Tests for MistralOCRProvider.process() and _upload_pdf_and_get_document()."""
def test_missing_api_key_raises(self, tmp_path):
"""Raises ValueError when MISTRAL_API_KEY is not set."""
pdf = _make_pdf(tmp_path)
provider = MistralOCRProvider()
mock_requests = Mock()
with (
patch.dict(sys.modules, {"requests": mock_requests}),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.mistral_api_key = None
with pytest.raises(ValueError, match="MISTRAL_API_KEY"):
provider.process(pdf)
def test_pdf_path_success(self, tmp_path):
"""Processes PDF via upload + OCR endpoint."""
pdf = _make_pdf(tmp_path)
provider = MistralOCRProvider()
upload_resp = Mock()
upload_resp.json.return_value = {"id": "file-123"}
upload_resp.raise_for_status = Mock()
url_resp = Mock()
url_resp.json.return_value = {"url": "https://signed.url/file"}
url_resp.raise_for_status = Mock()
ocr_resp = Mock()
ocr_resp.json.return_value = {"pages": [{"markdown": "page content"}]}
ocr_resp.raise_for_status = Mock()
mock_requests = Mock()
mock_requests.post.side_effect = [upload_resp, ocr_resp]
mock_requests.get.return_value = url_resp
with (
patch.dict(sys.modules, {"requests": mock_requests}),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.mistral_api_key = "test-key"
ms.mistral_ocr_model = "mistral-ocr-latest"
result = provider.process(pdf)
assert result.provider == "mistral"
assert result.text == "page content"
def test_image_path_success(self, tmp_path):
"""Processes JPEG image via base64 encoding."""
img = tmp_path / "photo.jpg"
img.write_bytes(b"\xff\xd8\xff\xe0test image")
provider = MistralOCRProvider()
ocr_resp = Mock()
ocr_resp.json.return_value = {"pages": [{"markdown": "image text"}]}
ocr_resp.raise_for_status = Mock()
mock_requests = Mock()
mock_requests.post.return_value = ocr_resp
with (
patch.dict(sys.modules, {"requests": mock_requests}),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.mistral_api_key = "test-key"
ms.mistral_ocr_model = "mistral-ocr-latest"
result = provider.process(str(img))
assert result.provider == "mistral"
assert result.text == "image text"
def test_unknown_mime_no_magic_raises(self, tmp_path):
"""Raises ValueError for file whose MIME type is None and has non-PDF magic bytes."""
# Use a filename with no extension so mimetypes.guess_type returns None
unknown = tmp_path / "unknownfile"
unknown.write_bytes(b"\x00\x01\x02\x03\x04")
provider = MistralOCRProvider()
mock_requests = Mock()
with (
patch.dict(sys.modules, {"requests": mock_requests}),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.mistral_api_key = "test-key"
ms.mistral_ocr_model = "mistral-ocr-latest"
with pytest.raises(ValueError, match="Cannot determine file type"):
provider.process(str(unknown))
def test_unsupported_mime_raises(self, tmp_path):
"""Raises ValueError for unsupported MIME type (e.g. .csv)."""
csv_file = tmp_path / "data.csv"
csv_file.write_text("col1,col2\n1,2")
provider = MistralOCRProvider()
mock_requests = Mock()
with (
patch.dict(sys.modules, {"requests": mock_requests}),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.mistral_api_key = "test-key"
ms.mistral_ocr_model = "mistral-ocr-latest"
with pytest.raises(ValueError, match="Unsupported file type"):
provider.process(str(csv_file))
def test_pdf_detected_via_magic_bytes(self, tmp_path):
"""Detects PDF via magic bytes when extension is missing."""
no_ext = tmp_path / "document"
no_ext.write_bytes(b"%PDF-1.4 minimal content")
provider = MistralOCRProvider()
upload_resp = Mock()
upload_resp.json.return_value = {"id": "file-456"}
upload_resp.raise_for_status = Mock()
url_resp = Mock()
url_resp.json.return_value = {"url": "https://signed.url/doc"}
url_resp.raise_for_status = Mock()
ocr_resp = Mock()
ocr_resp.json.return_value = {"pages": [{"markdown": "doc text"}]}
ocr_resp.raise_for_status = Mock()
mock_requests = Mock()
mock_requests.post.side_effect = [upload_resp, ocr_resp]
mock_requests.get.return_value = url_resp
with (
patch.dict(sys.modules, {"requests": mock_requests}),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.mistral_api_key = "key"
ms.mistral_ocr_model = "mistral-ocr-latest"
result = provider.process(str(no_ext))
assert result.text == "doc text"
# ---------------------------------------------------------------------------
# GoogleDocAIOCRProvider
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGoogleDocAIOCRProvider:
"""Tests for GoogleDocAIOCRProvider.process()."""
def test_import_error_raises_runtime(self, tmp_path):
"""Raises RuntimeError when google-cloud-documentai is not installed."""
pdf = _make_pdf(tmp_path)
provider = GoogleDocAIOCRProvider()
with patch.dict(sys.modules, {"google.cloud": None, "google.cloud.documentai": None}):
with pytest.raises((RuntimeError, ImportError)):
provider.process(pdf)
def test_missing_project_id_raises(self, tmp_path):
"""Raises ValueError when project_id is missing."""
pdf = _make_pdf(tmp_path)
provider = GoogleDocAIOCRProvider()
mock_documentai = Mock()
mock_service_account = Mock()
mock_google_cloud = Mock()
mock_google_cloud.documentai = mock_documentai
with (
patch.dict(
sys.modules,
{
"google": mock_google_cloud,
"google.cloud": mock_google_cloud,
"google.cloud.documentai": mock_documentai,
"google.oauth2": Mock(),
"google.oauth2.service_account": mock_service_account,
},
),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.google_docai_project_id = None
ms.google_docai_processor_id = "proc-123"
ms.google_docai_location = "us"
ms.google_docai_credentials_json = None
ms.google_drive_credentials_json = None
with pytest.raises(ValueError, match="GOOGLE_DOCAI_PROJECT_ID"):
provider.process(pdf)
def test_success_with_credentials(self, tmp_path):
"""Processes PDF via Google Document AI with service account credentials."""
pdf = _make_pdf(tmp_path)
provider = GoogleDocAIOCRProvider()
mock_document = Mock()
mock_document.text = "google extracted text"
mock_result = Mock()
mock_result.document = mock_document
mock_client = Mock()
mock_client.process_document.return_value = mock_result
mock_client.processor_path.return_value = "projects/p/locations/us/processors/proc"
mock_documentai = Mock()
mock_documentai.DocumentProcessorServiceClient.return_value = mock_client
mock_documentai.RawDocument.return_value = Mock()
mock_documentai.ProcessRequest.return_value = Mock()
mock_creds = Mock()
mock_service_account = Mock()
mock_service_account.Credentials.from_service_account_info.return_value = mock_creds
creds_dict = {"type": "service_account", "project_id": "test"}
with (
patch.dict(
sys.modules,
{
"google.cloud.documentai": mock_documentai,
"google.oauth2.service_account": mock_service_account,
},
),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.google_docai_project_id = "my-project"
ms.google_docai_processor_id = "proc-123"
ms.google_docai_location = "us"
ms.google_docai_credentials_json = json.dumps(creds_dict)
ms.google_drive_credentials_json = None
result = provider.process(pdf)
assert result.provider == "google_docai"
assert result.text == "google extracted text"
def test_success_no_credentials_json(self, tmp_path):
"""Processes PDF via ADC when no credentials JSON is configured."""
pdf = _make_pdf(tmp_path)
provider = GoogleDocAIOCRProvider()
mock_document = Mock()
mock_document.text = "adc text"
mock_result = Mock()
mock_result.document = mock_document
mock_client = Mock()
mock_client.process_document.return_value = mock_result
mock_client.processor_path.return_value = "projects/p/locations/us/processors/proc"
mock_documentai = Mock()
mock_documentai.DocumentProcessorServiceClient.return_value = mock_client
mock_documentai.RawDocument.return_value = Mock()
mock_documentai.ProcessRequest.return_value = Mock()
mock_service_account = Mock()
with (
patch.dict(
sys.modules,
{
"google.cloud.documentai": mock_documentai,
"google.oauth2.service_account": mock_service_account,
},
),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.google_docai_project_id = "project"
ms.google_docai_processor_id = "proc"
ms.google_docai_location = "eu"
ms.google_docai_credentials_json = None
ms.google_drive_credentials_json = None
result = provider.process(pdf)
assert result.provider == "google_docai"
# ---------------------------------------------------------------------------
# AWSTextractOCRProvider
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestAWSTextractOCRProvider:
"""Tests for AWSTextractOCRProvider.process()."""
def test_import_error_raises_runtime(self, tmp_path):
"""Raises RuntimeError when boto3 is not installed."""
pdf = _make_pdf(tmp_path)
provider = AWSTextractOCRProvider()
with patch.dict(sys.modules, {"boto3": None}):
with pytest.raises(RuntimeError, match="boto3"):
provider.process(pdf)
def test_missing_credentials_raises(self, tmp_path):
"""Raises ValueError when AWS credentials are missing."""
pdf = _make_pdf(tmp_path)
provider = AWSTextractOCRProvider()
mock_boto3 = Mock()
with (
patch.dict(sys.modules, {"boto3": mock_boto3}),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.aws_access_key_id = None
ms.aws_secret_access_key = None
ms.aws_region = "us-east-1"
with pytest.raises(ValueError, match="AWS_ACCESS_KEY_ID"):
provider.process(pdf)
def test_success(self, tmp_path):
"""Returns OCRResult with extracted text on success."""
pdf = _make_pdf(tmp_path)
provider = AWSTextractOCRProvider()
mock_textract = Mock()
mock_textract.detect_document_text.return_value = {
"Blocks": [
{"BlockType": "LINE", "Text": "first line"},
{"BlockType": "PAGE", "Text": "ignored"},
{"BlockType": "LINE", "Text": "second line"},
]
}
mock_boto3 = Mock()
mock_boto3.client.return_value = mock_textract
with (
patch.dict(sys.modules, {"boto3": mock_boto3}),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.aws_access_key_id = "AKIATEST"
ms.aws_secret_access_key = "secret"
ms.aws_region = "us-west-2"
result = provider.process(pdf)
assert result.provider == "aws_textract"
assert "first line" in result.text
assert "second line" in result.text
assert "ignored" not in result.text
def test_default_region(self, tmp_path):
"""Uses us-east-1 as default region when not configured."""
pdf = _make_pdf(tmp_path)
provider = AWSTextractOCRProvider()
mock_textract = Mock()
mock_textract.detect_document_text.return_value = {"Blocks": []}
mock_boto3 = Mock()
mock_boto3.client.return_value = mock_textract
with (
patch.dict(sys.modules, {"boto3": mock_boto3}),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.aws_access_key_id = "key"
ms.aws_secret_access_key = "secret"
ms.aws_region = None
result = provider.process(pdf)
mock_boto3.client.assert_called_once()
call_kwargs = mock_boto3.client.call_args[1]
assert call_kwargs["region_name"] == "us-east-1"
# ---------------------------------------------------------------------------
# get_ocr_providers
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetOCRProviders:
"""Tests for get_ocr_providers() factory function."""
def test_returns_azure_by_default(self):
"""Returns AzureOCRProvider when no setting is configured."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = None
providers = get_ocr_providers()
assert len(providers) == 1
assert isinstance(providers[0], AzureOCRProvider)
def test_single_provider(self):
"""Returns a single configured provider."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = "tesseract"
providers = get_ocr_providers()
assert len(providers) == 1
assert isinstance(providers[0], TesseractOCRProvider)
def test_multiple_providers(self):
"""Returns multiple configured providers."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = "azure,tesseract"
providers = get_ocr_providers()
assert len(providers) == 2
assert isinstance(providers[0], AzureOCRProvider)
assert isinstance(providers[1], TesseractOCRProvider)
def test_unknown_provider_skipped(self):
"""Skips unknown provider names with a warning."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = "unknown_engine,azure"
providers = get_ocr_providers()
# unknown_engine is skipped, azure is included
assert len(providers) == 1
assert isinstance(providers[0], AzureOCRProvider)
def test_all_unknown_falls_back_to_azure(self):
"""Falls back to Azure when all providers are unknown."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = "no_such_provider"
providers = get_ocr_providers()
assert len(providers) == 1
assert isinstance(providers[0], AzureOCRProvider)
def test_known_providers_list(self):
"""All providers listed in KNOWN_OCR_PROVIDERS can be instantiated."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = ",".join(KNOWN_OCR_PROVIDERS)
providers = get_ocr_providers()
assert len(providers) == len(KNOWN_OCR_PROVIDERS)
def test_whitespace_stripped(self):
"""Strips whitespace from provider names."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = " azure , tesseract "
providers = get_ocr_providers()
assert len(providers) == 2
# ---------------------------------------------------------------------------
# merge_ocr_results
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestMergeOCRResults:
"""Tests for merge_ocr_results() orchestration function."""
def test_empty_results(self):
"""Returns empty string for empty result list."""
text, pdf, rot = merge_ocr_results([], "test.pdf")
assert text == ""
assert pdf is None
assert rot == {}
def test_single_result(self):
"""Returns single result as-is."""
r = OCRResult("azure", "hello", searchable_pdf_path="/tmp/x.pdf", rotation_data={0: 90.0})
text, pdf, rot = merge_ocr_results([r], "test.pdf")
assert text == "hello"
assert pdf == "/tmp/x.pdf"
assert rot == {0: 90.0}
def test_primary_strategy(self):
"""Returns first result's text with 'primary' strategy."""
r1 = OCRResult("azure", "first text")
r2 = OCRResult("tesseract", "second text")
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_merge_strategy = "primary"
ms.ai_model = "gpt-4"
ms.openai_model = "gpt-4"
text, pdf, rot = merge_ocr_results([r1, r2], "doc.pdf")
assert text == "first text"
def test_longest_strategy(self):
"""Returns longest text with 'longest' strategy."""
r1 = OCRResult("azure", "short")
r2 = OCRResult("tesseract", "much longer text from tesseract")
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_merge_strategy = "longest"
ms.ai_model = "gpt-4"
ms.openai_model = "gpt-4"
text, _, _ = merge_ocr_results([r1, r2], "doc.pdf")
assert text == "much longer text from tesseract"
def test_searchable_pdf_from_first_provider_with_pdf(self):
"""Picks searchable_pdf_path from first provider that has one."""
r1 = OCRResult("tesseract", "t1", searchable_pdf_path=None)
r2 = OCRResult("azure", "t2", searchable_pdf_path="/tmp/azure.pdf")
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_merge_strategy = "primary"
ms.ai_model = "gpt-4"
ms.openai_model = "gpt-4"
_, pdf, _ = merge_ocr_results([r1, r2], "doc.pdf")
assert pdf == "/tmp/azure.pdf"
def test_rotation_data_from_first_provider_with_rotation(self):
"""Picks rotation_data from first provider that has it."""
r1 = OCRResult("tesseract", "t1", rotation_data={})
r2 = OCRResult("azure", "t2", rotation_data={0: 90.0})
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_merge_strategy = "longest"
ms.ai_model = "gpt-4"
ms.openai_model = "gpt-4"
_, _, rot = merge_ocr_results([r1, r2], "doc.pdf")
assert rot == {0: 90.0}
def test_ai_merge_strategy_success(self):
"""AI merge strategy calls AI provider and returns merged text."""
r1 = OCRResult("azure", "azure text")
r2 = OCRResult("tesseract", "tesseract text")
mock_provider = Mock()
mock_provider.chat_completion.return_value = "merged text"
with (
patch("app.utils.ocr_provider.settings") as ms,
patch("app.utils.ai_provider.get_ai_provider", return_value=mock_provider),
):
ms.ocr_merge_strategy = "ai_merge"
ms.ai_model = "gpt-4o"
ms.openai_model = "gpt-4o"
text, _, _ = merge_ocr_results([r1, r2], "doc.pdf")
assert text == "merged text"
def test_ai_merge_is_default_strategy(self):
"""ai_merge is used when no strategy is configured."""
r1 = OCRResult("azure", "text one")
r2 = OCRResult("tesseract", "text two which is longer")
mock_provider = Mock()
mock_provider.chat_completion.return_value = "ai result"
with (
patch("app.utils.ocr_provider.settings") as ms,
patch("app.utils.ai_provider.get_ai_provider", return_value=mock_provider),
):
ms.ocr_merge_strategy = None
ms.ai_model = "gpt-4"
ms.openai_model = "gpt-4"
text, _, _ = merge_ocr_results([r1, r2], "doc.pdf")
assert text == "ai result"
def test_ai_merge_failure_falls_back_to_longest(self):
"""Falls back to longest text when AI merge raises an exception."""
r1 = OCRResult("azure", "short")
r2 = OCRResult("tesseract", "this is the longer text from tesseract engine")
with (
patch("app.utils.ocr_provider.settings") as ms,
patch("app.utils.ai_provider.get_ai_provider", side_effect=RuntimeError("AI unavailable")),
):
ms.ocr_merge_strategy = "ai_merge"
ms.ai_model = "gpt-4"
ms.openai_model = "gpt-4"
text, _, _ = merge_ocr_results([r1, r2], "doc.pdf")
assert text == "this is the longer text from tesseract engine"
# ---------------------------------------------------------------------------
# Multi-language OCR support
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestOCRLanguageConstants:
"""Tests for the OCR_LANGUAGES constant and TESSERACT_TO_EASYOCR mapping."""
def test_ocr_languages_has_20_plus_entries(self):
"""OCR_LANGUAGES contains at least 20 language options (excluding 'auto')."""
from app.utils.ocr_provider import OCR_LANGUAGES
language_entries = {k: v for k, v in OCR_LANGUAGES.items() if v != "auto"}
assert len(language_entries) >= 20, f"Expected ≥20 languages, got {len(language_entries)}"
def test_ocr_languages_includes_auto(self):
"""OCR_LANGUAGES includes 'auto' as the first option."""
from app.utils.ocr_provider import OCR_LANGUAGES
assert "auto" in OCR_LANGUAGES.values()
def test_ocr_languages_common_languages(self):
"""OCR_LANGUAGES includes the most common European and Asian languages."""
from app.utils.ocr_provider import OCR_LANGUAGES
expected_codes = {"eng", "deu", "fra", "spa", "ita", "por", "rus", "chi_sim", "jpn", "kor"}
all_codes = set(OCR_LANGUAGES.values())
missing = expected_codes - all_codes
assert not missing, f"Missing expected language codes: {missing}"
def test_tesseract_to_easyocr_mapping(self):
"""TESSERACT_TO_EASYOCR maps common Tesseract codes to EasyOCR codes."""
from app.utils.ocr_provider import TESSERACT_TO_EASYOCR
assert TESSERACT_TO_EASYOCR["eng"] == "en"
assert TESSERACT_TO_EASYOCR["deu"] == "de"
assert TESSERACT_TO_EASYOCR["fra"] == "fr"
assert TESSERACT_TO_EASYOCR["chi_sim"] == "ch_sim"
def test_tesseract_codes_to_easyocr_single(self):
"""_tesseract_codes_to_easyocr converts a single Tesseract code."""
from app.utils.ocr_provider import _tesseract_codes_to_easyocr
result = _tesseract_codes_to_easyocr("eng")
assert result == ["en"]
def test_tesseract_codes_to_easyocr_multi(self):
"""_tesseract_codes_to_easyocr splits '+'-separated Tesseract codes."""
from app.utils.ocr_provider import _tesseract_codes_to_easyocr
result = _tesseract_codes_to_easyocr("eng+deu")
assert result == ["en", "de"]
def test_tesseract_codes_to_easyocr_passthrough_unknown(self):
"""_tesseract_codes_to_easyocr passes through codes not in the mapping."""
from app.utils.ocr_provider import _tesseract_codes_to_easyocr
# EasyOCR-native codes are passed through unchanged
result = _tesseract_codes_to_easyocr("en")
assert result == ["en"]
@pytest.mark.unit
class TestTesseractLanguageOverride:
"""Tests for per-call language override in TesseractOCRProvider."""
def test_language_override_used_in_process(self, tmp_path):
"""Language override is used instead of global setting."""
pdf = _make_pdf(tmp_path)
provider = TesseractOCRProvider(language="deu")
mock_pytesseract = Mock()
mock_pytesseract.image_to_string.return_value = "Deutsches Text"
mock_pytesseract.pytesseract = Mock()
mock_pdf2image = Mock()
mock_pdf2image.convert_from_path.return_value = [Mock()]
with (
patch.dict(
sys.modules,
{"pytesseract": mock_pytesseract, "pdf2image": mock_pdf2image},
),
patch("app.utils.ocr_provider.settings") as ms,
patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
):
ms.tesseract_cmd = None
ms.tesseract_language = "eng" # global setting; should be overridden
result = provider.process(pdf)
# Ensure image_to_string was called with the override language ("deu"), not global "eng"
mock_pytesseract.image_to_string.assert_called_once()
call_kwargs = mock_pytesseract.image_to_string.call_args
assert call_kwargs[1].get("lang") == "deu" or (call_kwargs[0] and call_kwargs[0][1] == "deu")
assert result.provider == "tesseract"
def test_auto_language_falls_back_to_global(self, tmp_path):
"""'auto' language override falls back to global tesseract_language setting."""
pdf = _make_pdf(tmp_path)
provider = TesseractOCRProvider(language="auto")
mock_pytesseract = Mock()
mock_pytesseract.image_to_string.return_value = ""
mock_pytesseract.pytesseract = Mock()
mock_pdf2image = Mock()
mock_pdf2image.convert_from_path.return_value = [Mock()]
with (
patch.dict(
sys.modules,
{"pytesseract": mock_pytesseract, "pdf2image": mock_pdf2image},
),
patch("app.utils.ocr_provider.settings") as ms,
patch("app.utils.ocr_language_manager.ensure_tesseract_languages", return_value=[]),
):
ms.tesseract_cmd = None
ms.tesseract_language = "fra"
provider.process(pdf)
# Should use global setting "fra" since "auto" means no override
mock_pytesseract.image_to_string.assert_called_once()
call_kwargs = mock_pytesseract.image_to_string.call_args
lang_used = call_kwargs[1].get("lang") if call_kwargs[1] else call_kwargs[0][1]
assert lang_used == "fra"
def test_none_language_falls_back_to_global(self, tmp_path):
"""None language override falls back to global setting."""
pdf = _make_pdf(tmp_path)
provider = TesseractOCRProvider(language=None)
assert provider._language_override is None
@pytest.mark.unit
class TestEasyOCRLanguageOverride:
"""Tests for per-call language override in EasyOCRProvider."""
def test_language_override_converted_and_used(self, tmp_path):
"""Tesseract-style language override is converted to EasyOCR codes."""
pdf = _make_pdf(tmp_path)
provider = EasyOCRProvider(language="deu")
mock_reader = Mock()
mock_reader.readtext.return_value = ["Deutsches Text"]
mock_easyocr = Mock()
mock_easyocr.Reader.return_value = mock_reader
mock_pdf2image = Mock()
mock_pdf2image.convert_from_path.return_value = [Mock()]
with (
patch.dict(
sys.modules,
{"easyocr": mock_easyocr, "pdf2image": mock_pdf2image},
),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.easyocr_languages = "en" # global; should be overridden
ms.easyocr_gpu = False
provider.process(pdf)
# Should call Reader with ["de"] (converted from "deu"), not global ["en"]
mock_easyocr.Reader.assert_called_once()
langs_arg = mock_easyocr.Reader.call_args[0][0]
assert langs_arg == ["de"]
def test_auto_language_uses_global_setting(self, tmp_path):
"""'auto' language override falls back to global easyocr_languages setting."""
pdf = _make_pdf(tmp_path)
provider = EasyOCRProvider(language="auto")
mock_reader = Mock()
mock_reader.readtext.return_value = []
mock_easyocr = Mock()
mock_easyocr.Reader.return_value = mock_reader
mock_pdf2image = Mock()
mock_pdf2image.convert_from_path.return_value = [Mock()]
with (
patch.dict(
sys.modules,
{"easyocr": mock_easyocr, "pdf2image": mock_pdf2image},
),
patch("app.utils.ocr_provider.settings") as ms,
):
ms.easyocr_languages = "fr,es"
ms.easyocr_gpu = False
provider.process(pdf)
langs_arg = mock_easyocr.Reader.call_args[0][0]
assert langs_arg == ["fr", "es"]
@pytest.mark.unit
class TestGetOCRProvidersWithLanguage:
"""Tests for get_ocr_providers(language=...) factory."""
def test_language_passed_to_tesseract_provider(self):
"""Language override is passed to TesseractOCRProvider."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = "tesseract"
providers = get_ocr_providers(language="deu")
assert len(providers) == 1
assert isinstance(providers[0], TesseractOCRProvider)
assert providers[0]._language_override == "deu"
def test_language_passed_to_easyocr_provider(self):
"""Language override is passed to EasyOCRProvider."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = "easyocr"
providers = get_ocr_providers(language="fra")
assert len(providers) == 1
assert isinstance(providers[0], EasyOCRProvider)
assert providers[0]._language_override == "fra"
def test_language_not_passed_to_azure(self):
"""Language override is NOT passed to AzureOCRProvider (it auto-detects)."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = "azure"
providers = get_ocr_providers(language="deu")
assert len(providers) == 1
assert isinstance(providers[0], AzureOCRProvider)
# AzureOCRProvider has no _language_override attribute
assert not hasattr(providers[0], "_language_override")
def test_auto_language_not_passed_as_override(self):
"""'auto' language is treated as no override for Tesseract."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = "tesseract"
providers = get_ocr_providers(language="auto")
assert providers[0]._language_override is None
def test_none_language_no_override(self):
"""None language results in no override."""
with patch("app.utils.ocr_provider.settings") as ms:
ms.ocr_providers = "tesseract"
providers = get_ocr_providers(language=None)
assert providers[0]._language_override is None