diff --git a/src/documents/tests/factories.py b/src/documents/tests/factories.py index 7a1bdcc68..a92791c77 100644 --- a/src/documents/tests/factories.py +++ b/src/documents/tests/factories.py @@ -5,6 +5,7 @@ Factory-boy factories for documents app models. from __future__ import annotations import factory +from django.contrib.auth import get_user_model from factory.django import DjangoModelFactory from documents.models import Correspondent @@ -15,6 +16,8 @@ from documents.models import PaperlessTask from documents.models import StoragePath from documents.models import Tag +UserModelT = get_user_model() + class CorrespondentFactory(DjangoModelFactory[Correspondent]): class Meta: @@ -68,6 +71,20 @@ class DocumentFactory(DjangoModelFactory[Document]): storage_path = None +class UserFactory(DjangoModelFactory[UserModelT]): + class Meta: + model = UserModelT + + username = factory.Sequence(lambda n: f"user{n}") + is_staff = False + is_superuser = False + password = factory.django.Password("test") + + class Params: + superuser = factory.Trait(is_staff=True, is_superuser=True) + staff = factory.Trait(is_staff=True) + + class PaperlessTaskFactory(DjangoModelFactory[PaperlessTask]): class Meta: model = PaperlessTask diff --git a/src/documents/tests/test_admin.py b/src/documents/tests/test_admin.py index b5d205d14..22899d2ae 100644 --- a/src/documents/tests/test_admin.py +++ b/src/documents/tests/test_admin.py @@ -1,12 +1,13 @@ import types -from unittest.mock import patch +import pytest import tantivy from django.contrib.admin.sites import AdminSite from django.contrib.auth.models import Permission from django.contrib.auth.models import User +from django.test import Client from django.test import TestCase -from django.utils import timezone +from pytest_mock import MockerFixture from rest_framework import status from documents.admin import DocumentAdmin @@ -15,10 +16,28 @@ from documents.models import Document from documents.models import Tag from documents.search import get_backend from documents.search import reset_backend +from documents.tests.factories import DocumentFactory +from documents.tests.factories import TagFactory +from documents.tests.factories import UserFactory from documents.tests.utils import DirectoriesMixin from paperless.admin import PaperlessUserAdmin +@pytest.fixture +def tag_admin() -> TagAdmin: + return TagAdmin(model=Tag, admin_site=AdminSite()) + + +@pytest.fixture +def user_admin() -> PaperlessUserAdmin: + return PaperlessUserAdmin(model=User, admin_site=AdminSite()) + + +@pytest.fixture +def staff_user(db) -> User: + return UserFactory.create(username="staff", staff=True) + + class TestDocumentAdmin(DirectoriesMixin, TestCase): def get_document_from_index(self, doc): backend = get_backend() @@ -41,160 +60,176 @@ class TestDocumentAdmin(DirectoriesMixin, TestCase): super().tearDown() def test_save_model(self) -> None: - doc = Document.objects.create(title="test") + doc = DocumentFactory.create(title="test") doc.title = "new title" self.doc_admin.save_model(None, doc, None, None) - self.assertEqual(Document.objects.get(id=doc.id).title, "new title") + self.assertEqual(self.get_document_from_index(doc)["id"], [doc.id]) def test_delete_model(self) -> None: - doc = Document.objects.create(title="test") + doc = DocumentFactory.create(title="test") get_backend().add_or_update(doc) self.assertIsNotNone(self.get_document_from_index(doc)) self.doc_admin.delete_model(None, doc) - self.assertRaises(Document.DoesNotExist, Document.objects.get, id=doc.id) self.assertIsNone(self.get_document_from_index(doc)) def test_delete_queryset(self) -> None: - docs = [] - for i in range(42): - doc = Document.objects.create( - title="Many documents with the same title", - checksum=f"{i:02}", - ) - docs.append(doc) - get_backend().add_or_update(doc) - - self.assertEqual(Document.objects.count(), 42) - + docs = DocumentFactory.create_batch( + 2, + title="Many documents with the same title", + ) for doc in docs: + get_backend().add_or_update(doc) self.assertIsNotNone(self.get_document_from_index(doc)) self.doc_admin.delete_queryset(None, Document.objects.all()) - self.assertEqual(Document.objects.count(), 0) - for doc in docs: self.assertIsNone(self.get_document_from_index(doc)) - def test_created(self) -> None: - doc = Document.objects.create( - title="test", - created=timezone.make_aware(timezone.datetime(2020, 4, 12)), + +@pytest.mark.django_db +class TestTagAdmin: + def test_parent_tags_get_added( + self, + tag_admin: TagAdmin, + mocker: MockerFixture, + ) -> None: + mock_bulk_update = mocker.patch( + "documents.tasks.bulk_update_documents.apply_async", ) - self.assertEqual(self.doc_admin.created_(doc), "2020-04-12") - - -class TestTagAdmin(DirectoriesMixin, TestCase): - def setUp(self) -> None: - super().setUp() - self.tag_admin = TagAdmin(model=Tag, admin_site=AdminSite()) - - @patch("documents.tasks.bulk_update_documents") - def test_parent_tags_get_added(self, mock_bulk_update) -> None: - document = Document.objects.create(title="test") - parent = Tag.objects.create(name="parent") - child = Tag.objects.create(name="child") + document = DocumentFactory.create(title="test") + parent = TagFactory.create(name="parent") + child = TagFactory.create(name="child") document.tags.add(child) child.tn_parent = parent - self.tag_admin.save_model(None, child, None, change=True) + tag_admin.save_model(None, child, None, change=True) + document.refresh_from_db() - self.assertIn(parent, document.tags.all()) - - -class TestPaperlessAdmin(DirectoriesMixin, TestCase): - def setUp(self) -> None: - super().setUp() - self.user_admin = PaperlessUserAdmin(model=User, admin_site=AdminSite()) - - def test_request_is_passed_to_form(self) -> None: - user = User.objects.create(username="test", is_superuser=False) - non_superuser = User.objects.create(username="requestuser") - request = types.SimpleNamespace(user=non_superuser) - formType = self.user_admin.get_form(request) - form = formType(data={}, instance=user) - self.assertEqual(form.request, request) - - def test_only_superuser_can_change_superuser(self) -> None: - superuser = User.objects.create_superuser(username="superuser", password="test") - non_superuser = User.objects.create(username="requestuser") - user = User.objects.create(username="test", is_superuser=False) - - data = { - "username": "test", - "is_superuser": True, + assert parent in document.tags.all() + mock_bulk_update.assert_called_once() + assert mock_bulk_update.call_args.kwargs["kwargs"] == { + "document_ids": [document.id], } - form = self.user_admin.form(data, instance=user) + + +@pytest.mark.django_db +class TestPaperlessAdmin: + def test_request_is_passed_to_form( + self, + user_admin: PaperlessUserAdmin, + ) -> None: + user = UserFactory.create() + non_superuser = UserFactory.create() + request = types.SimpleNamespace(user=non_superuser) + form_type = user_admin.get_form(request) + form = form_type(data={}, instance=user) + assert form.request == request + + def test_non_superuser_cannot_change_superuser_status( + self, + user_admin: PaperlessUserAdmin, + ) -> None: + non_superuser = UserFactory.create() + user = UserFactory.create() + + form = user_admin.form( + {"username": user.username, "is_superuser": True}, + instance=user, + ) form.request = types.SimpleNamespace(user=non_superuser) - self.assertFalse(form.is_valid()) - self.assertEqual( - form.errors.get("__all__"), - ["Superuser status can only be changed by a superuser"], + + assert not form.is_valid() + assert form.errors.get("__all__") == [ + "Superuser status can only be changed by a superuser", + ] + + def test_superuser_can_change_superuser_status( + self, + user_admin: PaperlessUserAdmin, + admin_user: User, + ) -> None: + user = UserFactory.create() + + form = user_admin.form( + {"username": user.username, "is_superuser": True}, + instance=user, ) + form.request = types.SimpleNamespace(user=admin_user) - form = self.user_admin.form(data, instance=user) - form.request = types.SimpleNamespace(user=superuser) - self.assertTrue(form.is_valid()) - self.assertEqual({}, form.errors) + assert form.is_valid() + assert form.errors == {} - def test_superuser_can_only_be_modified_by_superuser(self) -> None: - superuser = User.objects.create_superuser(username="superuser", password="test") - user = User.objects.create( - username="test", - is_superuser=False, - is_staff=True, + @pytest.mark.parametrize( + ("method", "perm_codename", "expected_message"), + [ + pytest.param( + "patch", + "change_user", + "Superusers can only be modified by other superusers", + id="modify", + ), + pytest.param( + "delete", + "delete_user", + "Superusers can only be deleted by other superusers", + id="delete", + ), + ], + ) + def test_non_superuser_cannot_mutate_superuser( + self, + client: Client, + admin_user: User, + staff_user: User, + method: str, + perm_codename: str, + expected_message: str, + ) -> None: + staff_user.user_permissions.add( + Permission.objects.get(codename=perm_codename), ) - change_user_perm = Permission.objects.get(codename="change_user") - user.user_permissions.add(change_user_perm) + client.force_login(staff_user) - self.client.force_login(user) - response = self.client.patch( - f"/api/users/{superuser.pk}/", + response = getattr(client, method)( + f"/api/users/{admin_user.pk}/", {"first_name": "Updated"}, content_type="application/json", ) - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual( - response.content.decode(), - "Superusers can only be modified by other superusers", - ) - self.client.logout() - self.client.force_login(superuser) - response = self.client.patch( - f"/api/users/{superuser.pk}/", + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.content.decode() == expected_message + assert User.objects.filter(pk=admin_user.pk).exists() + + def test_superuser_can_modify_superuser( + self, + client: Client, + admin_user: User, + ) -> None: + client.force_login(admin_user) + response = client.patch( + f"/api/users/{admin_user.pk}/", {"first_name": "Updated"}, content_type="application/json", ) - self.assertEqual(response.status_code, status.HTTP_200_OK) - superuser.refresh_from_db() - self.assertEqual(superuser.first_name, "Updated") - def test_superuser_can_only_be_deleted_by_superuser(self): - superuser = User.objects.create_superuser(username="superuser", password="test") - user = User.objects.create( - username="test", - is_superuser=False, - is_staff=True, - ) - delete_user_perm = Permission.objects.get(codename="delete_user") - user.user_permissions.add(delete_user_perm) + assert response.status_code == status.HTTP_200_OK + admin_user.refresh_from_db() + assert admin_user.first_name == "Updated" - self.client.force_login(user) - response = self.client.delete(f"/api/users/{superuser.pk}/") - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - self.assertEqual( - response.content.decode(), - "Superusers can only be deleted by other superusers", - ) - self.assertTrue(User.objects.filter(pk=superuser.pk).exists()) + def test_superuser_can_delete_superuser( + self, + client: Client, + admin_user: User, + ) -> None: + target = UserFactory.create(superuser=True) + client.force_login(admin_user) - self.client.logout() - self.client.force_login(superuser) - response = self.client.delete(f"/api/users/{superuser.pk}/") - self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) - self.assertFalse(User.objects.filter(pk=superuser.pk).exists()) + response = client.delete(f"/api/users/{target.pk}/") + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert not User.objects.filter(pk=target.pk).exists() diff --git a/src/documents/tests/test_checks.py b/src/documents/tests/test_checks.py index 51d9cdddc..6915b299f 100644 --- a/src/documents/tests/test_checks.py +++ b/src/documents/tests/test_checks.py @@ -1,43 +1,61 @@ -from unittest import mock - +import pytest from django.core.checks import Error from django.core.checks import Warning -from django.test import TestCase -from django.test import override_settings +from pytest_django.fixtures import SettingsWrapper +from pytest_mock import MockerFixture from documents.checks import filename_format_check from documents.checks import parser_check -class TestDocumentChecks(TestCase): - def test_parser_check(self) -> None: - self.assertEqual(parser_check(None), []) +class TestParserCheck: + def test_returns_empty_when_parsers_present(self) -> None: + assert parser_check(None) == [] - with mock.patch("documents.checks.get_parser_registry") as mock_registry_fn: - mock_registry = mock.MagicMock() - mock_registry.all_parsers.return_value = [] - mock_registry_fn.return_value = mock_registry + def test_returns_error_when_no_parsers(self, mocker: MockerFixture) -> None: + mock_registry = mocker.patch( + "documents.checks.get_parser_registry", + ).return_value + mock_registry.all_parsers.return_value = [] - self.assertEqual( - parser_check(None), - [ - Error( - "No parsers found. This is a bug. The consumer won't be " - "able to consume any documents without parsers.", - ), - ], - ) + assert parser_check(None) == [ + Error( + "No parsers found. This is a bug. The consumer won't be " + "able to consume any documents without parsers.", + ), + ] - def test_filename_format_check(self) -> None: - self.assertEqual(filename_format_check(None), []) - with override_settings(FILENAME_FORMAT="{created}/{title}"): - self.assertEqual( - filename_format_check(None), - [ - Warning( - "Filename format {created}/{title} is using the old style, please update to use double curly brackets", - hint="{{ created }}/{{ title }}", - ), - ], - ) +class TestFilenameFormatCheck: + def test_returns_empty_when_unset(self) -> None: + assert filename_format_check(None) == [] + + @pytest.mark.parametrize( + ("filename_format", "expected_hint"), + [ + pytest.param( + "{created}/{title}", + "{{ created }}/{{ title }}", + id="created-and-title", + ), + pytest.param( + "{correspondent}", + "{{ correspondent }}", + id="correspondent", + ), + ], + ) + def test_warns_on_old_style_format( + self, + settings: SettingsWrapper, + filename_format: str, + expected_hint: str, + ) -> None: + settings.FILENAME_FORMAT = filename_format + + assert filename_format_check(None) == [ + Warning( + f"Filename format {filename_format} is using the old style, please update to use double curly brackets", + hint=expected_hint, + ), + ] diff --git a/src/documents/tests/test_matchables.py b/src/documents/tests/test_matchables.py index e13d3827a..9ac406787 100644 --- a/src/documents/tests/test_matchables.py +++ b/src/documents/tests/test_matchables.py @@ -1,238 +1,324 @@ -import shutil -import tempfile from collections.abc import Iterable -from pathlib import Path -from random import randint -from django.contrib.auth.models import User -from django.test import TestCase -from django.test import override_settings +import pytest +from factory.django import DjangoModelFactory from documents import matching -from documents.models import Correspondent from documents.models import Document -from documents.models import DocumentType -from documents.models import Tag +from documents.models import MatchingModel from documents.signals import document_consumption_finished +from documents.tests.factories import CorrespondentFactory +from documents.tests.factories import DocumentFactory +from documents.tests.factories import DocumentTypeFactory +from documents.tests.factories import TagFactory -class _TestMatchingBase(TestCase): - def _test_matching( - self, - match_text: str, - match_algorithm: str, - should_match: Iterable[str], - no_match: Iterable[str], - *, - case_sensitive: bool = False, - ) -> None: - for klass in (Tag, Correspondent, DocumentType): - instance = klass.objects.create( - name=str(randint(10000, 99999)), - match=match_text, - matching_algorithm=getattr(klass, match_algorithm), - is_insensitive=not case_sensitive, - ) - for string in should_match: - doc = Document(content=string) - self.assertTrue( - matching.matches(instance, doc), - f'"{match_text}" should match "{string}" but it does not', - ) - for string in no_match: - doc = Document(content=string) - self.assertFalse( - matching.matches(instance, doc), - f'"{match_text}" should not match "{string}" but it does', - ) +@pytest.fixture( + params=[TagFactory, CorrespondentFactory, DocumentTypeFactory], + ids=["tag", "correspondent", "document_type"], +) +def matchable_factory(request: pytest.FixtureRequest) -> type[DjangoModelFactory]: + """ + Parametrized fixture yielding each factory whose model participates in + content matching: ``TagFactory``, ``CorrespondentFactory``, and + ``DocumentTypeFactory``. + + Tests that consume this fixture run once per factory, so a single test + body verifies the matching behavior across all three ``MatchingModel`` + subclasses. The parametrize IDs (``tag`` / ``correspondent`` / + ``document_type``) appear in test names so a failure points directly at + the offending model. + """ + return request.param -class TestMatching(_TestMatchingBase): - def test_matches_uses_latest_version_content_for_root_documents(self) -> None: - root = Document.objects.create( +@pytest.fixture() +def doc_with_keyword() -> Document: + return DocumentFactory.create( + content="I contain the keyword.", + mime_type="application/pdf", + ) + + +def _assert_matches( + factory_cls: type[DjangoModelFactory], + match_text: str, + match_algorithm: int, + should_match: Iterable[str], + no_match: Iterable[str], + *, + case_sensitive: bool = False, +) -> None: + """ + Build one matchable instance from ``factory_cls`` configured with the + given ``match_text``, ``match_algorithm``, and case sensitivity, then + assert that ``matching.matches`` returns ``True`` for every string in + ``should_match`` and ``False`` for every string in ``no_match``. + + Both the matchable and each candidate ``Document`` are constructed + unsaved: ``matching.matches`` only reads ``match`` / ``matching_algorithm`` + / ``is_insensitive`` off the matchable, and an unsaved ``Document`` + short-circuits ``get_effective_content`` via the ``pk is None`` branch. + Skipping the DB keeps the parametrized matrix cheap. ``case_sensitive`` + is inverted into ``is_insensitive`` to match the model field. Assertion + failures include the pattern and the offending string so a parametrized + failure is self-describing. + """ + instance = factory_cls.build( + match=match_text, + matching_algorithm=match_algorithm, + is_insensitive=not case_sensitive, + ) + for content in should_match: + doc = Document(content=content) + assert matching.matches(instance, doc), ( + f'"{match_text}" should match "{content}" but it does not' + ) + for content in no_match: + doc = Document(content=content) + assert not matching.matches(instance, doc), ( + f'"{match_text}" should not match "{content}" but it does' + ) + + +class TestMatching: + @pytest.mark.django_db + def test_root_uses_latest_version_content(self) -> None: + root = DocumentFactory.create( title="root", checksum="root", mime_type="application/pdf", content="root content without token", ) - Document.objects.create( + DocumentFactory.create( title="v1", checksum="v1", mime_type="application/pdf", root_document=root, content="latest version contains keyword", ) - tag = Tag.objects.create( - name="tag", + tag = TagFactory.create( match="keyword", - matching_algorithm=Tag.MATCH_ANY, + matching_algorithm=MatchingModel.MATCH_ANY, ) - self.assertTrue(matching.matches(tag, root)) + assert matching.matches(tag, root) - def test_matches_does_not_fall_back_to_root_content_when_version_exists( - self, - ) -> None: - root = Document.objects.create( + @pytest.mark.django_db + def test_root_does_not_fall_back_when_version_exists(self) -> None: + root = DocumentFactory.create( title="root", checksum="root", mime_type="application/pdf", content="root contains keyword", ) - Document.objects.create( + DocumentFactory.create( title="v1", checksum="v1", mime_type="application/pdf", root_document=root, content="latest version without token", ) - tag = Tag.objects.create( - name="tag", + tag = TagFactory.create( match="keyword", - matching_algorithm=Tag.MATCH_ANY, + matching_algorithm=MatchingModel.MATCH_ANY, ) - self.assertFalse(matching.matches(tag, root)) + assert not matching.matches(tag, root) - def test_match_none(self) -> None: - self._test_matching( + def test_match_none( + self, + matchable_factory: type[DjangoModelFactory], + ) -> None: + _assert_matches( + matchable_factory, "", - "MATCH_NONE", + MatchingModel.MATCH_NONE, (), - ( - "no", - "match", - ), + ("no", "match"), ) - def test_match_all(self) -> None: - self._test_matching( - "alpha charlie gamma", - "MATCH_ALL", - ("I have alpha, charlie, and gamma in me",), - ( - "I have alpha in me", - "I have charlie in me", - "I have gamma in me", - "I have alpha and charlie in me", - "I have alphas, charlie, and gamma in me", - "I have alphas in me", - "I have bravo in me", + @pytest.mark.parametrize( + ("match_text", "should_match", "no_match"), + [ + pytest.param( + "alpha charlie gamma", + ("I have alpha, charlie, and gamma in me",), + ( + "I have alpha in me", + "I have charlie in me", + "I have gamma in me", + "I have alpha and charlie in me", + "I have alphas, charlie, and gamma in me", + "I have alphas in me", + "I have bravo in me", + ), + id="words", ), + pytest.param( + "12 34 56", + ("I have 12 34, and 56 in me",), + ( + "I have 12 in me", + "I have 34 in me", + "I have 56 in me", + "I have 12 and 34 in me", + "I have 120, 34, and 56 in me", + "I have 123456 in me", + "I have 01234567 in me", + ), + id="numbers", + ), + pytest.param( + 'brown fox "lazy dogs"', + ( + "the quick brown fox jumped over the lazy dogs", + "the quick brown fox jumped over the lazy dogs", + ), + ( + "the quick fox jumped over the lazy dogs", + "the quick brown wolf jumped over the lazy dogs", + "the quick brown fox jumped over the fat dogs", + "the quick brown fox jumped over the lazy... dogs", + ), + id="quoted-phrase", + ), + ], + ) + def test_match_all( + self, + matchable_factory: type[DjangoModelFactory], + match_text: str, + should_match: tuple[str, ...], + no_match: tuple[str, ...], + ) -> None: + _assert_matches( + matchable_factory, + match_text, + MatchingModel.MATCH_ALL, + should_match, + no_match, ) - self._test_matching( - "12 34 56", - "MATCH_ALL", - ("I have 12 34, and 56 in me",), - ( - "I have 12 in me", - "I have 34 in me", - "I have 56 in me", - "I have 12 and 34 in me", - "I have 120, 34, and 56 in me", - "I have 123456 in me", - "I have 01234567 in me", + @pytest.mark.parametrize( + ("match_text", "should_match", "no_match"), + [ + pytest.param( + "alpha charlie gamma", + ( + "I have alpha in me", + "I have charlie in me", + "I have gamma in me", + "I have alpha, charlie, and gamma in me", + "I have alpha and charlie in me", + ), + ( + "I have alphas in me", + "I have bravo in me", + ), + id="words", ), + pytest.param( + "12 34 56", + ( + "I have 12 in me", + "I have 34 in me", + "I have 56 in me", + "I have 12 and 34 in me", + "I have 12, 34, and 56 in me", + "I have 120, 34, and 56 in me", + ), + ( + "I have 123456 in me", + "I have 01234567 in me", + ), + id="numbers", + ), + pytest.param( + '"brown fox" " lazy dogs "', + ( + "the quick brown fox", + "jumped over the lazy dogs.", + ), + ("the lazy fox jumped over the brown dogs",), + id="quoted-phrases", + ), + ], + ) + def test_match_any( + self, + matchable_factory: type[DjangoModelFactory], + match_text: str, + should_match: tuple[str, ...], + no_match: tuple[str, ...], + ) -> None: + _assert_matches( + matchable_factory, + match_text, + MatchingModel.MATCH_ANY, + should_match, + no_match, ) - self._test_matching( - 'brown fox "lazy dogs"', - "MATCH_ALL", - ( - "the quick brown fox jumped over the lazy dogs", - "the quick brown fox jumped over the lazy dogs", + @pytest.mark.parametrize( + ("match_text", "should_match", "no_match"), + [ + pytest.param( + "alpha charlie gamma", + ("I have 'alpha charlie gamma' in me",), + ( + "I have alpha in me", + "I have charlie in me", + "I have gamma in me", + "I have alpha and charlie in me", + "I have alpha, charlie, and gamma in me", + "I have alphas, charlie, and gamma in me", + "I have alphas in me", + "I have bravo in me", + ), + id="words", ), - ( - "the quick fox jumped over the lazy dogs", - "the quick brown wolf jumped over the lazy dogs", - "the quick brown fox jumped over the fat dogs", - "the quick brown fox jumped over the lazy... dogs", + pytest.param( + "12 34 56", + ("I have 12 34 56 in me",), + ( + "I have 12 in me", + "I have 34 in me", + "I have 56 in me", + "I have 12 and 34 in me", + "I have 12 34, and 56 in me", + "I have 120, 34, and 560 in me", + "I have 120, 340, and 560 in me", + "I have 123456 in me", + "I have 01234567 in me", + ), + id="numbers", ), + ], + ) + def test_match_literal( + self, + matchable_factory: type[DjangoModelFactory], + match_text: str, + should_match: tuple[str, ...], + no_match: tuple[str, ...], + ) -> None: + _assert_matches( + matchable_factory, + match_text, + MatchingModel.MATCH_LITERAL, + should_match, + no_match, ) - def test_match_any(self) -> None: - self._test_matching( - "alpha charlie gamma", - "MATCH_ANY", - ( - "I have alpha in me", - "I have charlie in me", - "I have gamma in me", - "I have alpha, charlie, and gamma in me", - "I have alpha and charlie in me", - ), - ( - "I have alphas in me", - "I have bravo in me", - ), - ) - - self._test_matching( - "12 34 56", - "MATCH_ANY", - ( - "I have 12 in me", - "I have 34 in me", - "I have 56 in me", - "I have 12 and 34 in me", - "I have 12, 34, and 56 in me", - "I have 120, 34, and 56 in me", - ), - ( - "I have 123456 in me", - "I have 01234567 in me", - ), - ) - - self._test_matching( - '"brown fox" " lazy dogs "', - "MATCH_ANY", - ( - "the quick brown fox", - "jumped over the lazy dogs.", - ), - ("the lazy fox jumped over the brown dogs",), - ) - - def test_match_literal(self) -> None: - self._test_matching( - "alpha charlie gamma", - "MATCH_LITERAL", - ("I have 'alpha charlie gamma' in me",), - ( - "I have alpha in me", - "I have charlie in me", - "I have gamma in me", - "I have alpha and charlie in me", - "I have alpha, charlie, and gamma in me", - "I have alphas, charlie, and gamma in me", - "I have alphas in me", - "I have bravo in me", - ), - ) - - self._test_matching( - "12 34 56", - "MATCH_LITERAL", - ("I have 12 34 56 in me",), - ( - "I have 12 in me", - "I have 34 in me", - "I have 56 in me", - "I have 12 and 34 in me", - "I have 12 34, and 56 in me", - "I have 120, 34, and 560 in me", - "I have 120, 340, and 560 in me", - "I have 123456 in me", - "I have 01234567 in me", - ), - ) - - def test_match_regex(self) -> None: - self._test_matching( + def test_match_regex( + self, + matchable_factory: type[DjangoModelFactory], + ) -> None: + _assert_matches( + matchable_factory, r"alpha\w+gamma", - "MATCH_REGEX", + MatchingModel.MATCH_REGEX, ( "I have alpha_and_gamma in me", "I have alphas_and_gamma in me", @@ -249,29 +335,41 @@ class TestMatching(_TestMatchingBase): ), ) - def test_tach_invalid_regex(self) -> None: - self._test_matching("[", "MATCH_REGEX", [], ["Don't match this"]) + def test_invalid_regex( + self, + matchable_factory: type[DjangoModelFactory], + ) -> None: + _assert_matches( + matchable_factory, + "[", + MatchingModel.MATCH_REGEX, + (), + ("Don't match this",), + ) - def test_match_regex_timeout_returns_false(self) -> None: - tag = Tag.objects.create( - name="slow", + def test_match_regex_timeout_returns_false( + self, + caplog: pytest.LogCaptureFixture, + ) -> None: + tag = TagFactory.build( match=r"(a+)+$", - matching_algorithm=Tag.MATCH_REGEX, + matching_algorithm=MatchingModel.MATCH_REGEX, ) document = Document(content=("a" * 5000) + "X") - with self.assertLogs("paperless.regex", level="WARNING") as cm: - self.assertFalse(matching.matches(tag, document)) + with caplog.at_level("WARNING", logger="paperless.regex"): + assert not matching.matches(tag, document) - self.assertTrue( - any("timed out" in message for message in cm.output), - f"Expected timeout log, got {cm.output}", - ) + assert "timed out" in caplog.text - def test_match_fuzzy(self) -> None: - self._test_matching( + def test_match_fuzzy( + self, + matchable_factory: type[DjangoModelFactory], + ) -> None: + _assert_matches( + matchable_factory, "Springfield, Miss.", - "MATCH_FUZZY", + MatchingModel.MATCH_FUZZY, ( "1220 Main Street, Springf eld, Miss.", "1220 Main Street, Spring field, Miss.", @@ -282,240 +380,283 @@ class TestMatching(_TestMatchingBase): ) -class TestCaseSensitiveMatching(_TestMatchingBase): - def test_match_all(self) -> None: - self._test_matching( - "alpha charlie gamma", - "MATCH_ALL", - ( - "I have alpha, charlie, and gamma in me", - "I have gamma, charlie, and alpha in me", +class TestCaseSensitiveMatching: + @pytest.mark.parametrize( + ("match_text", "should_match", "no_match"), + [ + pytest.param( + "alpha charlie gamma", + ( + "I have alpha, charlie, and gamma in me", + "I have gamma, charlie, and alpha in me", + ), + ( + "I have Alpha, charlie, and gamma in me", + "I have gamma, Charlie, and alpha in me", + "I have alpha, charlie, and Gamma in me", + "I have gamma, charlie, and ALPHA in me", + ), + id="lowercase-pattern", ), - ( - "I have Alpha, charlie, and gamma in me", - "I have gamma, Charlie, and alpha in me", - "I have alpha, charlie, and Gamma in me", - "I have gamma, charlie, and ALPHA in me", + pytest.param( + "Alpha charlie Gamma", + ( + "I have Alpha, charlie, and Gamma in me", + "I have Gamma, charlie, and Alpha in me", + ), + ( + "I have Alpha, charlie, and gamma in me", + "I have gamma, charlie, and alpha in me", + "I have alpha, charlie, and Gamma in me", + "I have Gamma, Charlie, and ALPHA in me", + ), + id="mixed-case-pattern", ), + pytest.param( + 'brown fox "lazy dogs"', + ( + "the quick brown fox jumped over the lazy dogs", + "the quick brown fox jumped over the lazy dogs", + ), + ( + "the quick Brown fox jumped over the lazy dogs", + "the quick brown Fox jumped over the lazy dogs", + "the quick brown fox jumped over the Lazy dogs", + "the quick brown fox jumped over the lazy Dogs", + ), + id="quoted-phrase", + ), + ], + ) + def test_match_all( + self, + matchable_factory: type[DjangoModelFactory], + match_text: str, + should_match: tuple[str, ...], + no_match: tuple[str, ...], + ) -> None: + _assert_matches( + matchable_factory, + match_text, + MatchingModel.MATCH_ALL, + should_match, + no_match, case_sensitive=True, ) - self._test_matching( - "Alpha charlie Gamma", - "MATCH_ALL", - ( - "I have Alpha, charlie, and Gamma in me", - "I have Gamma, charlie, and Alpha in me", + @pytest.mark.parametrize( + ("match_text", "should_match", "no_match"), + [ + pytest.param( + "alpha charlie gamma", + ( + "I have alpha in me", + "I have charlie in me", + "I have gamma in me", + "I have alpha, charlie, and gamma in me", + "I have alpha and charlie in me", + ), + ( + "I have Alpha in me", + "I have chaRLie in me", + "I have gamMA in me", + "I have aLPha, cHArlie, and gAMma in me", + "I have AlphA and CharlIe in me", + ), + id="lowercase-pattern", ), - ( - "I have Alpha, charlie, and gamma in me", - "I have gamma, charlie, and alpha in me", - "I have alpha, charlie, and Gamma in me", - "I have Gamma, Charlie, and ALPHA in me", + pytest.param( + "Alpha Charlie Gamma", + ( + "I have Alpha in me", + "I have Charlie in me", + "I have Gamma in me", + "I have Alpha, Charlie, and Gamma in me", + "I have Alpha and Charlie in me", + ), + ( + "I have alpha in me", + "I have ChaRLie in me", + "I have GamMA in me", + "I have ALPha, CHArlie, and GAMma in me", + "I have AlphA and CharlIe in me", + ), + id="capitalized-pattern", ), + pytest.param( + '"brown fox" " lazy dogs "', + ( + "the quick brown fox", + "jumped over the lazy dogs.", + ), + ( + "the quick Brown fox", + "jumped over the lazy Dogs.", + ), + id="quoted-phrases", + ), + ], + ) + def test_match_any( + self, + matchable_factory: type[DjangoModelFactory], + match_text: str, + should_match: tuple[str, ...], + no_match: tuple[str, ...], + ) -> None: + _assert_matches( + matchable_factory, + match_text, + MatchingModel.MATCH_ANY, + should_match, + no_match, case_sensitive=True, ) - self._test_matching( - 'brown fox "lazy dogs"', - "MATCH_ALL", - ( - "the quick brown fox jumped over the lazy dogs", - "the quick brown fox jumped over the lazy dogs", + @pytest.mark.parametrize( + ("match_text", "should_match", "no_match"), + [ + pytest.param( + "alpha charlie gamma", + ("I have 'alpha charlie gamma' in me",), + ( + "I have 'Alpha charlie gamma' in me", + "I have 'alpha Charlie gamma' in me", + "I have 'alpha charlie Gamma' in me", + "I have 'Alpha Charlie Gamma' in me", + ), + id="lowercase-pattern", ), - ( - "the quick Brown fox jumped over the lazy dogs", - "the quick brown Fox jumped over the lazy dogs", - "the quick brown fox jumped over the Lazy dogs", - "the quick brown fox jumped over the lazy Dogs", + pytest.param( + "Alpha Charlie Gamma", + ("I have 'Alpha Charlie Gamma' in me",), + ( + "I have 'Alpha charlie gamma' in me", + "I have 'alpha Charlie gamma' in me", + "I have 'alpha charlie Gamma' in me", + "I have 'alpha charlie gamma' in me", + ), + id="capitalized-pattern", ), + ], + ) + def test_match_literal( + self, + matchable_factory: type[DjangoModelFactory], + match_text: str, + should_match: tuple[str, ...], + no_match: tuple[str, ...], + ) -> None: + _assert_matches( + matchable_factory, + match_text, + MatchingModel.MATCH_LITERAL, + should_match, + no_match, case_sensitive=True, ) - def test_match_any(self) -> None: - self._test_matching( - "alpha charlie gamma", - "MATCH_ANY", - ( - "I have alpha in me", - "I have charlie in me", - "I have gamma in me", - "I have alpha, charlie, and gamma in me", - "I have alpha and charlie in me", + @pytest.mark.parametrize( + ("match_text", "should_match", "no_match"), + [ + pytest.param( + r"alpha\w+gamma", + ( + "I have alpha_and_gamma in me", + "I have alphas_and_gamma in me", + ), + ( + "I have Alpha_and_Gamma in me", + "I have alpHAs_and_gaMMa in me", + ), + id="lowercase-pattern", ), - ( - "I have Alpha in me", - "I have chaRLie in me", - "I have gamMA in me", - "I have aLPha, cHArlie, and gAMma in me", - "I have AlphA and CharlIe in me", - ), - case_sensitive=True, - ) - - self._test_matching( - "Alpha Charlie Gamma", - "MATCH_ANY", - ( - "I have Alpha in me", - "I have Charlie in me", - "I have Gamma in me", - "I have Alpha, Charlie, and Gamma in me", - "I have Alpha and Charlie in me", - ), - ( - "I have alpha in me", - "I have ChaRLie in me", - "I have GamMA in me", - "I have ALPha, CHArlie, and GAMma in me", - "I have AlphA and CharlIe in me", - ), - case_sensitive=True, - ) - - self._test_matching( - '"brown fox" " lazy dogs "', - "MATCH_ANY", - ( - "the quick brown fox", - "jumped over the lazy dogs.", - ), - ( - "the quick Brown fox", - "jumped over the lazy Dogs.", - ), - case_sensitive=True, - ) - - def test_match_literal(self) -> None: - self._test_matching( - "alpha charlie gamma", - "MATCH_LITERAL", - ("I have 'alpha charlie gamma' in me",), - ( - "I have 'Alpha charlie gamma' in me", - "I have 'alpha Charlie gamma' in me", - "I have 'alpha charlie Gamma' in me", - "I have 'Alpha Charlie Gamma' in me", - ), - case_sensitive=True, - ) - - self._test_matching( - "Alpha Charlie Gamma", - "MATCH_LITERAL", - ("I have 'Alpha Charlie Gamma' in me",), - ( - "I have 'Alpha charlie gamma' in me", - "I have 'alpha Charlie gamma' in me", - "I have 'alpha charlie Gamma' in me", - "I have 'alpha charlie gamma' in me", - ), - case_sensitive=True, - ) - - def test_match_regex(self) -> None: - self._test_matching( - r"alpha\w+gamma", - "MATCH_REGEX", - ( - "I have alpha_and_gamma in me", - "I have alphas_and_gamma in me", - ), - ( - "I have Alpha_and_Gamma in me", - "I have alpHAs_and_gaMMa in me", - ), - case_sensitive=True, - ) - - self._test_matching( - r"Alpha\w+gamma", - "MATCH_REGEX", - ( - "I have Alpha_and_gamma in me", - "I have Alphas_and_gamma in me", - ), - ( - "I have Alpha_and_Gamma in me", - "I have alphas_and_gamma in me", + pytest.param( + r"Alpha\w+gamma", + ( + "I have Alpha_and_gamma in me", + "I have Alphas_and_gamma in me", + ), + ( + "I have Alpha_and_Gamma in me", + "I have alphas_and_gamma in me", + ), + id="capitalized-pattern", ), + ], + ) + def test_match_regex( + self, + matchable_factory: type[DjangoModelFactory], + match_text: str, + should_match: tuple[str, ...], + no_match: tuple[str, ...], + ) -> None: + _assert_matches( + matchable_factory, + match_text, + MatchingModel.MATCH_REGEX, + should_match, + no_match, case_sensitive=True, ) -@override_settings(POST_CONSUME_SCRIPT=None) -class TestDocumentConsumptionFinishedSignal(TestCase): +@pytest.mark.django_db +@pytest.mark.usefixtures("_search_index") +class TestDocumentConsumptionFinishedSignal: """ - We make use of document_consumption_finished, so we should test that it's - doing what we expect wrt to tag & correspondent matching. + document_consumption_finished should drive tag & correspondent matching. """ - def setUp(self) -> None: - from documents.search import reset_backend - - TestCase.setUp(self) - reset_backend() - User.objects.create_user(username="test_consumer", password="12345") - self.doc_contains = Document.objects.create( - content="I contain the keyword.", - mime_type="application/pdf", - ) - - self.index_dir = Path(tempfile.mkdtemp()) - # TODO: we should not need the index here. - override_settings(INDEX_DIR=self.index_dir).enable() - - def tearDown(self) -> None: - from documents.search import reset_backend - - reset_backend() - shutil.rmtree(self.index_dir, ignore_errors=True) - - def test_tag_applied_any(self) -> None: - t1 = Tag.objects.create( - name="test", + def test_tag_applied_any(self, doc_with_keyword: Document) -> None: + tag = TagFactory.create( match="keyword", - matching_algorithm=Tag.MATCH_ANY, + matching_algorithm=MatchingModel.MATCH_ANY, ) + document_consumption_finished.send( sender=self.__class__, - document=self.doc_contains, + document=doc_with_keyword, ) - self.assertTrue(list(self.doc_contains.tags.all()) == [t1]) - def test_tag_not_applied(self) -> None: - Tag.objects.create( - name="test", + assert list(doc_with_keyword.tags.all()) == [tag] + + def test_tag_not_applied(self, doc_with_keyword: Document) -> None: + TagFactory.create( match="no-match", - matching_algorithm=Tag.MATCH_ANY, + matching_algorithm=MatchingModel.MATCH_ANY, ) + document_consumption_finished.send( sender=self.__class__, - document=self.doc_contains, + document=doc_with_keyword, ) - self.assertTrue(list(self.doc_contains.tags.all()) == []) - def test_correspondent_applied(self) -> None: - correspondent = Correspondent.objects.create( - name="test", + assert list(doc_with_keyword.tags.all()) == [] + + def test_correspondent_applied(self, doc_with_keyword: Document) -> None: + correspondent = CorrespondentFactory.create( match="keyword", - matching_algorithm=Correspondent.MATCH_ANY, + matching_algorithm=MatchingModel.MATCH_ANY, ) - document_consumption_finished.send( - sender=self.__class__, - document=self.doc_contains, - ) - self.assertTrue(self.doc_contains.correspondent == correspondent) - def test_correspondent_not_applied(self) -> None: - Tag.objects.create( - name="test", - match="no-match", - matching_algorithm=Correspondent.MATCH_ANY, - ) document_consumption_finished.send( sender=self.__class__, - document=self.doc_contains, + document=doc_with_keyword, ) - self.assertEqual(self.doc_contains.correspondent, None) + + assert doc_with_keyword.correspondent == correspondent + + def test_correspondent_not_applied(self, doc_with_keyword: Document) -> None: + TagFactory.create( + match="no-match", + matching_algorithm=MatchingModel.MATCH_ANY, + ) + + document_consumption_finished.send( + sender=self.__class__, + document=doc_with_keyword, + ) + + assert doc_with_keyword.correspondent is None diff --git a/src/documents/tests/test_models.py b/src/documents/tests/test_models.py index 160aa77f9..4aa28cfdc 100644 --- a/src/documents/tests/test_models.py +++ b/src/documents/tests/test_models.py @@ -1,4 +1,4 @@ -from django.test import TestCase +import pytest from documents.models import Correspondent from documents.models import Document @@ -6,25 +6,19 @@ from documents.tests.factories import CorrespondentFactory from documents.tests.factories import DocumentFactory -class CorrespondentTestCase(TestCase): - def test___str__(self) -> None: - for s in ("test", "oχi", "test with fun_charÅc'\"terß"): - correspondent = CorrespondentFactory.create(name=s) - self.assertEqual(str(correspondent), s) - - -class DocumentTestCase(TestCase): +@pytest.mark.django_db +class TestDocument: def test_correspondent_deletion_does_not_cascade(self) -> None: - self.assertEqual(Correspondent.objects.all().count(), 0) + assert Correspondent.objects.count() == 0 correspondent = CorrespondentFactory.create() - self.assertEqual(Correspondent.objects.all().count(), 1) + assert Correspondent.objects.count() == 1 - self.assertEqual(Document.objects.all().count(), 0) + assert Document.objects.count() == 0 DocumentFactory.create(correspondent=correspondent) - self.assertEqual(Document.objects.all().count(), 1) - self.assertIsNotNone(Document.objects.all().first().correspondent) + assert Document.objects.count() == 1 + assert Document.objects.first().correspondent is not None correspondent.delete() - self.assertEqual(Correspondent.objects.all().count(), 0) - self.assertEqual(Document.objects.all().count(), 1) - self.assertIsNone(Document.objects.all().first().correspondent) + assert Correspondent.objects.count() == 0 + assert Document.objects.count() == 1 + assert Document.objects.first().correspondent is None diff --git a/src/documents/tests/test_parsers.py b/src/documents/tests/test_parsers.py index 30963df70..fc7e84a71 100644 --- a/src/documents/tests/test_parsers.py +++ b/src/documents/tests/test_parsers.py @@ -1,5 +1,7 @@ -from django.test import TestCase -from django.test import override_settings +from collections.abc import Generator + +import pytest +from pytest_django.fixtures import SettingsWrapper from documents.parsers import get_default_file_extension from documents.parsers import get_supported_file_extensions @@ -11,103 +13,115 @@ from paperless.parsers.text import TextDocumentParser from paperless.parsers.tika import TikaDocumentParser -class TestParserAvailability(TestCase): - def test_tesseract_parser(self) -> None: +@pytest.fixture() +def _tika_registry(settings: SettingsWrapper) -> Generator[None, None, None]: + """ + Rebuild the parser registry with Tika enabled for the duration of the + test, then reset on exit so other tests see the default (Tika-disabled) + registry. + """ + settings.TIKA_ENABLED = True + reset_parser_registry() + yield + reset_parser_registry() + + +@pytest.mark.django_db +class TestParserAvailability: + @pytest.mark.parametrize( + ("mime_type", "ext"), + [ + pytest.param("application/pdf", ".pdf", id="pdf"), + pytest.param("image/png", ".png", id="png"), + pytest.param("image/jpeg", ".jpg", id="jpeg"), + pytest.param("image/tiff", ".tif", id="tiff"), + pytest.param("image/webp", ".webp", id="webp"), + ], + ) + def test_tesseract_parser(self, mime_type: str, ext: str) -> None: """ GIVEN: - Various mime types WHEN: - The parser class is instantiated THEN: - - The Tesseract based parser is return + - The Tesseract based parser is returned """ - supported_mimes_and_exts = [ - ("application/pdf", ".pdf"), - ("image/png", ".png"), - ("image/jpeg", ".jpg"), - ("image/tiff", ".tif"), - ("image/webp", ".webp"), - ] + assert ext in get_supported_file_extensions() + assert get_default_file_extension(mime_type) == ext + assert isinstance( + get_parser_registry().get_parser_for_file(mime_type, "")(), + RasterisedDocumentParser, + ) - supported_exts = get_supported_file_extensions() - - for mime_type, ext in supported_mimes_and_exts: - self.assertIn(ext, supported_exts) - self.assertEqual(get_default_file_extension(mime_type), ext) - self.assertIsInstance( - get_parser_registry().get_parser_for_file(mime_type, "")(), - RasterisedDocumentParser, - ) - - def test_text_parser(self) -> None: + @pytest.mark.parametrize( + ("mime_type", "ext"), + [ + pytest.param("text/plain", ".txt", id="plain"), + pytest.param("text/csv", ".csv", id="csv"), + ], + ) + def test_text_parser(self, mime_type: str, ext: str) -> None: """ GIVEN: - Various mime types of a text form WHEN: - The parser class is instantiated THEN: - - The text based parser is return + - The text based parser is returned """ - supported_mimes_and_exts = [ - ("text/plain", ".txt"), - ("text/csv", ".csv"), - ] + assert ext in get_supported_file_extensions() + assert get_default_file_extension(mime_type) == ext + assert isinstance( + get_parser_registry().get_parser_for_file(mime_type, "")(), + TextDocumentParser, + ) - supported_exts = get_supported_file_extensions() - - for mime_type, ext in supported_mimes_and_exts: - self.assertIn(ext, supported_exts) - self.assertEqual(get_default_file_extension(mime_type), ext) - self.assertIsInstance( - get_parser_registry().get_parser_for_file(mime_type, "")(), - TextDocumentParser, - ) - - def test_tika_parser(self) -> None: + @pytest.mark.usefixtures("_tika_registry") + @pytest.mark.parametrize( + ("mime_type", "ext"), + [ + pytest.param( + "application/vnd.oasis.opendocument.text", + ".odt", + id="odt", + ), + pytest.param("text/rtf", ".rtf", id="rtf"), + pytest.param("application/msword", ".doc", id="doc"), + pytest.param( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".docx", + id="docx", + ), + ], + ) + def test_tika_parser(self, mime_type: str, ext: str) -> None: """ GIVEN: - - Various mime types of a office document form + - Various mime types of an office document form WHEN: - The parser class is instantiated THEN: - - The Tika/Gotenberg based parser is return + - The Tika/Gotenberg based parser is returned """ - supported_mimes_and_exts = [ - ("application/vnd.oasis.opendocument.text", ".odt"), - ("text/rtf", ".rtf"), - ("application/msword", ".doc"), - ( - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".docx", - ), - ] - - self.addCleanup(reset_parser_registry) - - # Reset and rebuild the registry with Tika enabled. - with override_settings(TIKA_ENABLED=True): - reset_parser_registry() - supported_exts = get_supported_file_extensions() - - for mime_type, ext in supported_mimes_and_exts: - self.assertIn(ext, supported_exts) - self.assertEqual(get_default_file_extension(mime_type), ext) - self.assertIsInstance( - get_parser_registry().get_parser_for_file(mime_type, "")(), - TikaDocumentParser, - ) + assert ext in get_supported_file_extensions() + assert get_default_file_extension(mime_type) == ext + assert isinstance( + get_parser_registry().get_parser_for_file(mime_type, "")(), + TikaDocumentParser, + ) def test_no_parser_for_mime(self) -> None: - self.assertIsNone(get_parser_registry().get_parser_for_file("text/sdgsdf", "")) + assert get_parser_registry().get_parser_for_file("text/sdgsdf", "") is None def test_default_extension(self) -> None: - # Test no parser declared still returns a an extension - self.assertEqual(get_default_file_extension("application/zip"), ".zip") + # Test no parser declared still returns an extension + assert get_default_file_extension("application/zip") == ".zip" # Test invalid mimetype returns no extension - self.assertEqual(get_default_file_extension("aasdasd/dgfgf"), "") + assert get_default_file_extension("aasdasd/dgfgf") == "" def test_file_extension_support(self) -> None: - self.assertTrue(is_file_ext_supported(".pdf")) - self.assertFalse(is_file_ext_supported(".hsdfh")) - self.assertFalse(is_file_ext_supported("")) + assert is_file_ext_supported(".pdf") + assert not is_file_ext_supported(".hsdfh") + assert not is_file_ext_supported("")