mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-20 23:08:14 +00:00
584 lines
19 KiB
Python
584 lines
19 KiB
Python
"""Tests for the zone-based OCR extraction engine."""
|
|
|
|
import tempfile
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
from unittest.mock import patch
|
|
|
|
from django.contrib.auth.models import Group
|
|
from django.contrib.auth.models import User
|
|
from django.test import TestCase
|
|
|
|
from documents.models import CustomField
|
|
from documents.models import CustomFieldInstance
|
|
from documents.models import Document
|
|
from documents.models import DocumentType
|
|
from documents.models import OcrTemplate
|
|
from documents.models import OcrTemplateZone
|
|
from documents.signals.handlers import run_zone_ocr_on_type_change
|
|
from documents.zone_ocr import _apply_transform
|
|
from documents.zone_ocr import _convert_value
|
|
from documents.zone_ocr import _detect_mime
|
|
from documents.zone_ocr import _resolve_doc_path
|
|
from documents.zone_ocr import get_templates_for_document
|
|
from documents.zone_ocr import run_zone_extraction
|
|
from paperless_testing.dirs import DirectoriesMixin
|
|
from paperless_testing.factories import DocumentFactory
|
|
from paperless_testing.permissions import grant_object
|
|
|
|
|
|
class TestTemplateVisibility(DirectoriesMixin, TestCase):
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.owner = User.objects.create_user(username="document_owner")
|
|
self.other = User.objects.create_user(username="other_owner")
|
|
self.doc_type = DocumentType.objects.create(name="Invoice")
|
|
self.document = DocumentFactory(owner=self.owner, document_type=self.doc_type)
|
|
self.mine = self.template("Mine", owner=self.owner)
|
|
self.unowned = self.template("Unowned")
|
|
self.private = self.template("Private", owner=self.other)
|
|
|
|
def template(self, name, **kwargs):
|
|
return OcrTemplate.objects.create(
|
|
name=name,
|
|
document_type=self.doc_type,
|
|
source_width=100,
|
|
source_height=100,
|
|
**kwargs,
|
|
)
|
|
|
|
def test_only_owned_unowned_and_shared_templates_apply(self):
|
|
shared = self.template("Shared", owner=self.other)
|
|
grant_object(self.owner, shared, "view_ocrtemplate")
|
|
self.template("Disabled", owner=self.owner, enabled=False)
|
|
other_type = DocumentType.objects.create(name="Other")
|
|
OcrTemplate.objects.create(
|
|
name="Wrong type",
|
|
owner=self.owner,
|
|
document_type=other_type,
|
|
source_width=100,
|
|
source_height=100,
|
|
)
|
|
self.assertCountEqual(
|
|
get_templates_for_document(self.document),
|
|
[self.mine, self.unowned, shared],
|
|
)
|
|
|
|
def test_group_sharing_applies(self):
|
|
group = Group.objects.create(name="OCR users")
|
|
self.owner.groups.add(group)
|
|
grant_object(group, self.private, "view_ocrtemplate")
|
|
self.assertIn(self.private, get_templates_for_document(self.document))
|
|
|
|
def test_unowned_documents_only_use_unowned_templates(self):
|
|
self.document.owner = None
|
|
self.assertQuerySetEqual(
|
|
get_templates_for_document(self.document),
|
|
[self.unowned],
|
|
)
|
|
|
|
def test_versions_use_the_root_documents_owner(self):
|
|
version = DocumentFactory(
|
|
root_document=self.document,
|
|
owner=self.other,
|
|
document_type=self.doc_type,
|
|
)
|
|
self.assertCountEqual(
|
|
get_templates_for_document(version),
|
|
[self.mine, self.unowned],
|
|
)
|
|
|
|
def test_superuser_owner_can_use_all_visible_templates(self):
|
|
self.owner.is_superuser = True
|
|
self.assertCountEqual(
|
|
get_templates_for_document(self.document),
|
|
[self.mine, self.unowned, self.private],
|
|
)
|
|
|
|
def test_inactive_owner_has_no_templates(self):
|
|
self.owner.is_active = False
|
|
self.assertFalse(get_templates_for_document(self.document).exists())
|
|
|
|
@patch("documents.zone_ocr._process_template")
|
|
def test_extraction_never_processes_private_templates(self, process):
|
|
for template in (self.mine, self.unowned, self.private):
|
|
OcrTemplateZone.objects.create(
|
|
template=template,
|
|
name="Title",
|
|
target="title",
|
|
x=0,
|
|
y=0,
|
|
width=20,
|
|
height=20,
|
|
)
|
|
self.document.source_path.write_bytes(b"test PDF")
|
|
|
|
run_zone_extraction(self.document, None)
|
|
|
|
self.assertCountEqual(
|
|
[call.args[2] for call in process.call_args_list],
|
|
[self.mine, self.unowned],
|
|
)
|
|
|
|
@patch("documents.zone_ocr.run_zone_extraction")
|
|
def test_type_change_skips_documents_with_only_private_templates(self, extract):
|
|
self.mine.delete()
|
|
self.unowned.delete()
|
|
self.document.source_path.write_bytes(b"test PDF")
|
|
self.document._old_document_type_id = None
|
|
|
|
run_zone_ocr_on_type_change(Document, self.document)
|
|
|
|
extract.assert_not_called()
|
|
|
|
@patch("documents.zone_ocr.run_zone_extraction")
|
|
def test_type_change_runs_when_a_template_is_visible(self, extract):
|
|
self.document.source_path.write_bytes(b"test PDF")
|
|
self.document._old_document_type_id = None
|
|
|
|
run_zone_ocr_on_type_change(Document, self.document)
|
|
|
|
extract.assert_called_once_with(self.document, None)
|
|
|
|
|
|
class TestApplyTransform(TestCase):
|
|
"""Tests for the _apply_transform function."""
|
|
|
|
def test_strip(self):
|
|
self.assertEqual(_apply_transform(" hello ", "strip"), "hello")
|
|
|
|
def test_none_transform(self):
|
|
self.assertEqual(_apply_transform(" hello ", "none"), "hello")
|
|
|
|
def test_uppercase(self):
|
|
self.assertEqual(_apply_transform("hello world", "uppercase"), "HELLO WORLD")
|
|
|
|
def test_lowercase(self):
|
|
self.assertEqual(_apply_transform("HELLO WORLD", "lowercase"), "hello world")
|
|
|
|
def test_numeric_basic(self):
|
|
self.assertEqual(_apply_transform("Invoice 2026-001", "numeric"), "2026-001")
|
|
|
|
def test_numeric_with_currency(self):
|
|
self.assertEqual(_apply_transform("€1,234.56", "numeric"), "1,234.56")
|
|
|
|
def test_numeric_empty_result_falls_back(self):
|
|
self.assertEqual(_apply_transform("abc", "numeric"), "abc")
|
|
|
|
def test_date_with_format(self):
|
|
self.assertEqual(
|
|
_apply_transform("13.04.2026", "date", "%d.%m.%Y"),
|
|
"2026-04-13",
|
|
)
|
|
self.assertEqual(
|
|
_apply_transform("01/12/2025", "date", "%d/%m/%Y"),
|
|
"2025-12-01",
|
|
)
|
|
|
|
def test_date_two_digit_year(self):
|
|
self.assertEqual(
|
|
_apply_transform("13.04.26", "date", "%d.%m.%y"),
|
|
"2026-04-13",
|
|
)
|
|
|
|
def test_date_auto_detect(self):
|
|
self.assertEqual(_apply_transform("2026-04-13", "date"), "2026-04-13")
|
|
self.assertEqual(_apply_transform("2026/04/13", "date"), "2026-04-13")
|
|
self.assertEqual(_apply_transform("13 April 2026", "date"), "2026-04-13")
|
|
|
|
def test_date_format_mismatch_falls_back_to_auto_detect(self):
|
|
self.assertEqual(
|
|
_apply_transform("2026-04-13", "date", "%d.%m.%Y"),
|
|
"2026-04-13",
|
|
)
|
|
|
|
def test_date_invalid_returns_text(self):
|
|
self.assertEqual(
|
|
_apply_transform("32.13.2026", "date", "%d.%m.%Y"),
|
|
"32.13.2026",
|
|
)
|
|
self.assertEqual(_apply_transform("2026-13-32", "date"), "2026-13-32")
|
|
self.assertEqual(_apply_transform("not a date", "date"), "not a date")
|
|
|
|
def test_empty_string(self):
|
|
self.assertEqual(_apply_transform("", "strip"), "")
|
|
|
|
def test_whitespace_only(self):
|
|
self.assertEqual(_apply_transform(" ", "strip"), "")
|
|
|
|
def test_unknown_transform_strips(self):
|
|
self.assertEqual(_apply_transform(" hello ", "unknown"), "hello")
|
|
|
|
|
|
class TestConvertValue(TestCase):
|
|
"""Tests for the _convert_value function."""
|
|
|
|
def test_string(self):
|
|
self.assertEqual(
|
|
_convert_value("Hello", CustomField.FieldDataType.STRING),
|
|
"Hello",
|
|
)
|
|
|
|
def test_string_truncation(self):
|
|
result = _convert_value("x" * 200, CustomField.FieldDataType.STRING)
|
|
self.assertEqual(len(result), 128)
|
|
|
|
def test_url(self):
|
|
self.assertEqual(
|
|
_convert_value("https://example.com", CustomField.FieldDataType.URL),
|
|
"https://example.com",
|
|
)
|
|
|
|
def test_long_text(self):
|
|
long = "x" * 500
|
|
self.assertEqual(
|
|
_convert_value(long, CustomField.FieldDataType.LONG_TEXT),
|
|
long,
|
|
)
|
|
|
|
def test_int_simple(self):
|
|
self.assertEqual(_convert_value("42", CustomField.FieldDataType.INT), 42)
|
|
|
|
def test_int_with_noise(self):
|
|
self.assertEqual(_convert_value("INV-123", CustomField.FieldDataType.INT), 123)
|
|
|
|
def test_int_negative(self):
|
|
self.assertEqual(_convert_value("-42", CustomField.FieldDataType.INT), -42)
|
|
|
|
def test_int_empty_returns_none(self):
|
|
self.assertIsNone(_convert_value("abc", CustomField.FieldDataType.INT))
|
|
|
|
def test_int_only_dash_returns_none(self):
|
|
self.assertIsNone(_convert_value("-", CustomField.FieldDataType.INT))
|
|
|
|
def test_float_simple(self):
|
|
self.assertAlmostEqual(
|
|
_convert_value("1234.56", CustomField.FieldDataType.FLOAT),
|
|
1234.56,
|
|
)
|
|
|
|
def test_float_european_format(self):
|
|
self.assertAlmostEqual(
|
|
_convert_value("1.234,56", CustomField.FieldDataType.FLOAT),
|
|
1234.56,
|
|
)
|
|
|
|
def test_float_us_format(self):
|
|
self.assertAlmostEqual(
|
|
_convert_value("1,234.56", CustomField.FieldDataType.FLOAT),
|
|
1234.56,
|
|
)
|
|
|
|
def test_float_comma_only(self):
|
|
self.assertAlmostEqual(
|
|
_convert_value("1234,56", CustomField.FieldDataType.FLOAT),
|
|
1234.56,
|
|
)
|
|
|
|
def test_float_empty_returns_none(self):
|
|
self.assertIsNone(_convert_value("abc", CustomField.FieldDataType.FLOAT))
|
|
|
|
def test_float_only_separator_returns_none(self):
|
|
self.assertIsNone(_convert_value(",", CustomField.FieldDataType.FLOAT))
|
|
|
|
def test_date_iso(self):
|
|
self.assertEqual(
|
|
_convert_value("2026-04-13", CustomField.FieldDataType.DATE),
|
|
"2026-04-13",
|
|
)
|
|
|
|
def test_date_invalid_returns_none(self):
|
|
self.assertIsNone(_convert_value("not a date", CustomField.FieldDataType.DATE))
|
|
|
|
def test_date_invalid_values_returns_none(self):
|
|
self.assertIsNone(_convert_value("2026-13-32", CustomField.FieldDataType.DATE))
|
|
|
|
def test_monetary_simple(self):
|
|
self.assertEqual(
|
|
_convert_value("123.45", CustomField.FieldDataType.MONETARY),
|
|
"123.45",
|
|
)
|
|
|
|
def test_monetary_european(self):
|
|
self.assertEqual(
|
|
_convert_value("1.234,56", CustomField.FieldDataType.MONETARY),
|
|
"1234.56",
|
|
)
|
|
|
|
def test_monetary_with_currency_symbol(self):
|
|
self.assertEqual(
|
|
_convert_value("€1,234.56", CustomField.FieldDataType.MONETARY),
|
|
"1234.56",
|
|
)
|
|
|
|
def test_monetary_empty_returns_none(self):
|
|
self.assertIsNone(_convert_value("CHF", CustomField.FieldDataType.MONETARY))
|
|
|
|
def test_bool_true(self):
|
|
for val in ("true", "True", "yes", "1", "ja", "x", "X"):
|
|
self.assertTrue(
|
|
_convert_value(val, CustomField.FieldDataType.BOOL),
|
|
f"Expected True for {val!r}",
|
|
)
|
|
|
|
def test_bool_false(self):
|
|
for val in ("false", "False", "no", "0", "nein"):
|
|
self.assertFalse(
|
|
_convert_value(val, CustomField.FieldDataType.BOOL),
|
|
f"Expected False for {val!r}",
|
|
)
|
|
|
|
def test_bool_unknown_returns_none(self):
|
|
self.assertIsNone(_convert_value("maybe", CustomField.FieldDataType.BOOL))
|
|
|
|
def test_unsupported_type_returns_none(self):
|
|
self.assertIsNone(
|
|
_convert_value("test", CustomField.FieldDataType.DOCUMENTLINK),
|
|
)
|
|
self.assertIsNone(
|
|
_convert_value("test", CustomField.FieldDataType.SELECT),
|
|
)
|
|
|
|
def test_empty_string_returns_none(self):
|
|
self.assertIsNone(_convert_value("", CustomField.FieldDataType.STRING))
|
|
|
|
|
|
class TestDetectMime(TestCase):
|
|
"""Tests for _detect_mime."""
|
|
|
|
def test_pdf_extension(self):
|
|
self.assertEqual(_detect_mime(Path("test.pdf")), "application/pdf")
|
|
|
|
def test_png_extension(self):
|
|
self.assertEqual(_detect_mime(Path("test.png")), "image/png")
|
|
|
|
def test_jpg_extension(self):
|
|
self.assertEqual(_detect_mime(Path("test.jpg")), "image/jpeg")
|
|
|
|
def test_unknown_extension(self):
|
|
self.assertIsNone(_detect_mime(Path("test.xyz")))
|
|
|
|
def test_webp_extension(self):
|
|
self.assertEqual(_detect_mime(Path("test.webp")), "image/webp")
|
|
|
|
|
|
class TestResolveDocPath(TestCase):
|
|
"""Tests for _resolve_doc_path."""
|
|
|
|
def test_returns_none_when_no_files_exist(self):
|
|
doc = MagicMock()
|
|
doc.has_archive_version = False
|
|
doc.source_path = Path("/nonexistent/source.pdf")
|
|
result = _resolve_doc_path(doc, None)
|
|
self.assertIsNone(result)
|
|
|
|
def test_returns_original_file_as_fallback(self):
|
|
doc = MagicMock()
|
|
doc.has_archive_version = False
|
|
doc.source_path = Path("/nonexistent/source.pdf")
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
|
result = _resolve_doc_path(doc, Path(f.name))
|
|
self.assertEqual(result, Path(f.name))
|
|
|
|
def test_returns_none_for_none_original_file(self):
|
|
doc = MagicMock()
|
|
doc.has_archive_version = False
|
|
doc.source_path = Path("/nonexistent/source.pdf")
|
|
result = _resolve_doc_path(doc, None)
|
|
self.assertIsNone(result)
|
|
|
|
|
|
class TestRunZoneExtraction(TestCase):
|
|
"""Tests for the full extraction pipeline."""
|
|
|
|
def setUp(self):
|
|
self.doc_type = DocumentType.objects.create(name="Invoice")
|
|
self.custom_field = CustomField.objects.create(
|
|
name="Invoice Number",
|
|
data_type=CustomField.FieldDataType.STRING,
|
|
)
|
|
|
|
def test_skips_document_without_type(self):
|
|
doc = Document.objects.create(
|
|
title="No Type",
|
|
content="test",
|
|
mime_type="application/pdf",
|
|
)
|
|
run_zone_extraction(doc, Path("/nonexistent"))
|
|
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
|
|
|
def test_skips_document_without_matching_template(self):
|
|
other_type = DocumentType.objects.create(name="Other")
|
|
doc = Document.objects.create(
|
|
title="No Template",
|
|
content="test",
|
|
mime_type="application/pdf",
|
|
document_type=other_type,
|
|
)
|
|
run_zone_extraction(doc, Path("/nonexistent"))
|
|
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
|
|
|
def test_skips_disabled_template(self):
|
|
template = OcrTemplate.objects.create(
|
|
name="Disabled",
|
|
document_type=self.doc_type,
|
|
source_width=2480,
|
|
source_height=3508,
|
|
enabled=False,
|
|
)
|
|
OcrTemplateZone.objects.create(
|
|
template=template,
|
|
name="Zone",
|
|
custom_field=self.custom_field,
|
|
x=0,
|
|
y=0,
|
|
width=100,
|
|
height=50,
|
|
)
|
|
|
|
doc = Document.objects.create(
|
|
title="Test",
|
|
content="test",
|
|
mime_type="application/pdf",
|
|
document_type=self.doc_type,
|
|
)
|
|
run_zone_extraction(doc, Path("/nonexistent"))
|
|
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
|
|
|
def test_skips_template_with_no_zones(self):
|
|
OcrTemplate.objects.create(
|
|
name="Empty",
|
|
document_type=self.doc_type,
|
|
source_width=2480,
|
|
source_height=3508,
|
|
enabled=True,
|
|
)
|
|
|
|
doc = Document.objects.create(
|
|
title="Test",
|
|
content="test",
|
|
mime_type="application/pdf",
|
|
document_type=self.doc_type,
|
|
)
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
|
f.write(b"%PDF-1.4 fake")
|
|
f.flush()
|
|
run_zone_extraction(doc, Path(f.name))
|
|
self.assertEqual(CustomFieldInstance.objects.count(), 0)
|
|
|
|
@patch("documents.zone_ocr._process_template")
|
|
def test_calls_process_for_enabled_template(self, mock_process):
|
|
template = OcrTemplate.objects.create(
|
|
name="Active",
|
|
document_type=self.doc_type,
|
|
source_width=2480,
|
|
source_height=3508,
|
|
enabled=True,
|
|
)
|
|
OcrTemplateZone.objects.create(
|
|
template=template,
|
|
name="Zone",
|
|
custom_field=self.custom_field,
|
|
x=0,
|
|
y=0,
|
|
width=100,
|
|
height=50,
|
|
)
|
|
|
|
doc = Document.objects.create(
|
|
title="Test",
|
|
content="test",
|
|
mime_type="application/pdf",
|
|
document_type=self.doc_type,
|
|
)
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
|
f.write(b"%PDF-1.4 fake")
|
|
f.flush()
|
|
run_zone_extraction(doc, Path(f.name))
|
|
|
|
self.assertTrue(mock_process.called)
|
|
|
|
@patch("documents.zone_ocr._process_template")
|
|
def test_handles_process_exception_gracefully(self, mock_process):
|
|
"""A failing template should not prevent other templates from running."""
|
|
mock_process.side_effect = RuntimeError("test error")
|
|
|
|
template = OcrTemplate.objects.create(
|
|
name="Failing",
|
|
document_type=self.doc_type,
|
|
source_width=2480,
|
|
source_height=3508,
|
|
enabled=True,
|
|
)
|
|
OcrTemplateZone.objects.create(
|
|
template=template,
|
|
name="Zone",
|
|
custom_field=self.custom_field,
|
|
x=0,
|
|
y=0,
|
|
width=100,
|
|
height=50,
|
|
)
|
|
|
|
doc = Document.objects.create(
|
|
title="Test",
|
|
content="test",
|
|
mime_type="application/pdf",
|
|
document_type=self.doc_type,
|
|
)
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
|
f.write(b"%PDF-1.4 fake")
|
|
f.flush()
|
|
# Should not raise
|
|
run_zone_extraction(doc, Path(f.name))
|
|
|
|
def test_handles_none_original_file(self):
|
|
"""Should not crash when original_file is None."""
|
|
doc = Document.objects.create(
|
|
title="Test",
|
|
content="test",
|
|
mime_type="application/pdf",
|
|
document_type=self.doc_type,
|
|
)
|
|
# No template, so it exits early — but shouldn't crash on None
|
|
run_zone_extraction(doc, None)
|
|
|
|
@patch("documents.zone_ocr._process_template")
|
|
def test_multiple_templates_all_process(self, mock_process):
|
|
"""Multiple enabled templates for the same type should all run."""
|
|
for i in range(3):
|
|
template = OcrTemplate.objects.create(
|
|
name=f"Template {i}",
|
|
document_type=self.doc_type,
|
|
source_width=2480,
|
|
source_height=3508,
|
|
enabled=True,
|
|
)
|
|
OcrTemplateZone.objects.create(
|
|
template=template,
|
|
name=f"Zone {i}",
|
|
custom_field=self.custom_field,
|
|
x=0,
|
|
y=0,
|
|
width=100,
|
|
height=50,
|
|
)
|
|
|
|
doc = Document.objects.create(
|
|
title="Test",
|
|
content="test",
|
|
mime_type="application/pdf",
|
|
document_type=self.doc_type,
|
|
)
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".pdf") as f:
|
|
f.write(b"%PDF-1.4 fake")
|
|
f.flush()
|
|
run_zone_extraction(doc, Path(f.name))
|
|
|
|
self.assertEqual(mock_process.call_count, 3)
|