Compare commits

...

4 Commits

Author SHA1 Message Date
Trenton H
91ddda9256 Fix: Uploaded digest artifact name for Docker build (#12272) 2026-03-06 13:15:45 -08:00
Trenton H
9d5e618de8 Chore: pytest style paperless tests (#12254) 2026-03-06 13:04:23 -08:00
Trenton H
50ae49c7da Chore: Uploads the digests as just files, no zips (#12264) 2026-03-06 12:56:34 -08:00
shamoon
ba023ef332 Chore: Add anti-slop job to PR workflow (#12248) 2026-03-06 20:36:24 +00:00
6 changed files with 334 additions and 331 deletions

View File

@@ -149,15 +149,16 @@ jobs:
mkdir -p /tmp/digests mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}" digest="${{ steps.build.outputs.digest }}"
echo "digest=${digest}" echo "digest=${digest}"
touch "/tmp/digests/${digest#sha256:}" echo "${digest}" > "/tmp/digests/digest-${{ matrix.arch }}.txt"
- name: Upload digest - name: Upload digest
if: steps.check-push.outputs.should-push == 'true' if: steps.check-push.outputs.should-push == 'true'
uses: actions/upload-artifact@v7.0.0 uses: actions/upload-artifact@v7.0.0
with: with:
name: digests-${{ matrix.arch }} name: digests-${{ matrix.arch }}
path: /tmp/digests/* path: /tmp/digests/digest-${{ matrix.arch }}.txt
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
archive: false
merge-and-push: merge-and-push:
name: Merge and Push Manifest name: Merge and Push Manifest
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -171,7 +172,7 @@ jobs:
uses: actions/download-artifact@v8.0.0 uses: actions/download-artifact@v8.0.0
with: with:
path: /tmp/digests path: /tmp/digests
pattern: digests-* pattern: digest-*.txt
merge-multiple: true merge-multiple: true
- name: List digests - name: List digests
run: | run: |
@@ -217,8 +218,9 @@ jobs:
tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "${DOCKER_METADATA_OUTPUT_JSON}") tags=$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "${DOCKER_METADATA_OUTPUT_JSON}")
digests="" digests=""
for digest in *; do for digest_file in digest-*.txt; do
digests+="${{ env.REGISTRY }}/${REPOSITORY}@sha256:${digest} " digest=$(cat "${digest_file}")
digests+="${{ env.REGISTRY }}/${REPOSITORY}@${digest} "
done done
echo "Creating manifest with tags: ${tags}" echo "Creating manifest with tags: ${tags}"

View File

@@ -2,13 +2,24 @@ name: PR Bot
on: on:
pull_request_target: pull_request_target:
types: [opened] types: [opened]
permissions:
contents: read
pull-requests: write
jobs: jobs:
anti-slop:
runs-on: ubuntu-latest
permissions:
contents: read
issues: read
pull-requests: write
steps:
- uses: peakoss/anti-slop@v0.2.1
with:
max-failures: 4
failure-add-pr-labels: 'ai'
pr-bot: pr-bot:
name: Automated PR Bot name: Automated PR Bot
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps: steps:
- name: Label PR by file path or branch name - name: Label PR by file path or branch name
# see .github/labeler.yml for the labeler config # see .github/labeler.yml for the labeler config

View File

@@ -1,107 +1,100 @@
from unittest import mock import logging
import pytest
from allauth.account.adapter import get_adapter from allauth.account.adapter import get_adapter
from allauth.core import context from allauth.core import context
from allauth.socialaccount.adapter import get_adapter as get_social_adapter from allauth.socialaccount.adapter import get_adapter as get_social_adapter
from django.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.forms import ValidationError from django.forms import ValidationError
from django.http import HttpRequest from django.http import HttpRequest
from django.test import TestCase
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from pytest_django.fixtures import SettingsWrapper
from pytest_mock import MockerFixture
from rest_framework.authtoken.models import Token from rest_framework.authtoken.models import Token
from paperless.adapter import DrfTokenStrategy from paperless.adapter import DrfTokenStrategy
class TestCustomAccountAdapter(TestCase): @pytest.mark.django_db
def test_is_open_for_signup(self) -> None: class TestCustomAccountAdapter:
def test_is_open_for_signup(self, settings: SettingsWrapper) -> None:
adapter = get_adapter() adapter = get_adapter()
# With no accounts, signups should be allowed # With no accounts, signups should be allowed
self.assertTrue(adapter.is_open_for_signup(None)) assert adapter.is_open_for_signup(None)
User.objects.create_user("testuser") User.objects.create_user("testuser")
# Test when ACCOUNT_ALLOW_SIGNUPS is True
settings.ACCOUNT_ALLOW_SIGNUPS = True settings.ACCOUNT_ALLOW_SIGNUPS = True
self.assertTrue(adapter.is_open_for_signup(None)) assert adapter.is_open_for_signup(None)
# Test when ACCOUNT_ALLOW_SIGNUPS is False
settings.ACCOUNT_ALLOW_SIGNUPS = False settings.ACCOUNT_ALLOW_SIGNUPS = False
self.assertFalse(adapter.is_open_for_signup(None)) assert not adapter.is_open_for_signup(None)
def test_is_safe_url(self) -> None: def test_is_safe_url(self, settings: SettingsWrapper) -> None:
request = HttpRequest() request = HttpRequest()
request.get_host = mock.Mock(return_value="example.com") request.get_host = lambda: "example.com"
with context.request_context(request): with context.request_context(request):
adapter = get_adapter() adapter = get_adapter()
with override_settings(ALLOWED_HOSTS=["*"]):
# True because request host is same
url = "https://example.com"
self.assertTrue(adapter.is_safe_url(url))
url = "https://evil.com" settings.ALLOWED_HOSTS = ["*"]
# True because request host is same
assert adapter.is_safe_url("https://example.com")
# False despite wildcard because request host is different # False despite wildcard because request host is different
self.assertFalse(adapter.is_safe_url(url)) assert not adapter.is_safe_url("https://evil.com")
settings.ALLOWED_HOSTS = ["example.com"] settings.ALLOWED_HOSTS = ["example.com"]
url = "https://example.com"
# True because request host is same # True because request host is same
self.assertTrue(adapter.is_safe_url(url)) assert adapter.is_safe_url("https://example.com")
settings.ALLOWED_HOSTS = ["*", "example.com"] settings.ALLOWED_HOSTS = ["*", "example.com"]
url = "//evil.com"
# False because request host is not in allowed hosts # False because request host is not in allowed hosts
self.assertFalse(adapter.is_safe_url(url)) assert not adapter.is_safe_url("//evil.com")
@mock.patch("allauth.core.internal.ratelimit.consume", return_value=True) def test_pre_authenticate(
def test_pre_authenticate(self, mock_consume) -> None: self,
settings: SettingsWrapper,
mocker: MockerFixture,
) -> None:
mocker.patch("allauth.core.internal.ratelimit.consume", return_value=True)
adapter = get_adapter() adapter = get_adapter()
request = HttpRequest() request = HttpRequest()
request.get_host = mock.Mock(return_value="example.com") request.get_host = lambda: "example.com"
settings.DISABLE_REGULAR_LOGIN = False settings.DISABLE_REGULAR_LOGIN = False
adapter.pre_authenticate(request) adapter.pre_authenticate(request)
settings.DISABLE_REGULAR_LOGIN = True settings.DISABLE_REGULAR_LOGIN = True
with self.assertRaises(ValidationError): with pytest.raises(ValidationError):
adapter.pre_authenticate(request) adapter.pre_authenticate(request)
def test_get_reset_password_from_key_url(self) -> None: def test_get_reset_password_from_key_url(self, settings: SettingsWrapper) -> None:
request = HttpRequest() request = HttpRequest()
request.get_host = mock.Mock(return_value="foo.org") request.get_host = lambda: "foo.org"
with context.request_context(request): with context.request_context(request):
adapter = get_adapter() adapter = get_adapter()
# Test when PAPERLESS_URL is None settings.PAPERLESS_URL = None
with override_settings( settings.ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https"
PAPERLESS_URL=None, expected_url = f"https://foo.org{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
ACCOUNT_DEFAULT_HTTP_PROTOCOL="https", assert adapter.get_reset_password_from_key_url("UID-KEY") == expected_url
):
expected_url = f"https://foo.org{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
self.assertEqual(
adapter.get_reset_password_from_key_url("UID-KEY"),
expected_url,
)
# Test when PAPERLESS_URL is not None settings.PAPERLESS_URL = "https://bar.com"
with override_settings(PAPERLESS_URL="https://bar.com"): expected_url = f"https://bar.com{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}"
expected_url = f"https://bar.com{reverse('account_reset_password_from_key', kwargs={'uidb36': 'UID', 'key': 'KEY'})}" assert adapter.get_reset_password_from_key_url("UID-KEY") == expected_url
self.assertEqual(
adapter.get_reset_password_from_key_url("UID-KEY"),
expected_url,
)
@override_settings(ACCOUNT_DEFAULT_GROUPS=["group1", "group2"]) def test_save_user_adds_groups(
def test_save_user_adds_groups(self) -> None: self,
settings: SettingsWrapper,
mocker: MockerFixture,
) -> None:
settings.ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"]
Group.objects.create(name="group1") Group.objects.create(name="group1")
user = User.objects.create_user("testuser") user = User.objects.create_user("testuser")
adapter = get_adapter() adapter = get_adapter()
form = mock.Mock( form = mocker.MagicMock(
cleaned_data={ cleaned_data={
"username": "testuser", "username": "testuser",
"email": "user@example.com", "email": "user@example.com",
@@ -110,88 +103,81 @@ class TestCustomAccountAdapter(TestCase):
user = adapter.save_user(HttpRequest(), user, form, commit=True) user = adapter.save_user(HttpRequest(), user, form, commit=True)
self.assertEqual(user.groups.count(), 1) assert user.groups.count() == 1
self.assertTrue(user.groups.filter(name="group1").exists()) assert user.groups.filter(name="group1").exists()
self.assertFalse(user.groups.filter(name="group2").exists()) assert not user.groups.filter(name="group2").exists()
def test_fresh_install_save_creates_superuser(self) -> None: def test_fresh_install_save_creates_superuser(self, mocker: MockerFixture) -> None:
adapter = get_adapter() adapter = get_adapter()
form = mock.Mock( form = mocker.MagicMock(
cleaned_data={ cleaned_data={
"username": "testuser", "username": "testuser",
"email": "user@paperless-ngx.com", "email": "user@paperless-ngx.com",
}, },
) )
user = adapter.save_user(HttpRequest(), User(), form, commit=True) user = adapter.save_user(HttpRequest(), User(), form, commit=True)
self.assertTrue(user.is_superuser) assert user.is_superuser
# Next time, it should not create a superuser form = mocker.MagicMock(
form = mock.Mock(
cleaned_data={ cleaned_data={
"username": "testuser2", "username": "testuser2",
"email": "user2@paperless-ngx.com", "email": "user2@paperless-ngx.com",
}, },
) )
user2 = adapter.save_user(HttpRequest(), User(), form, commit=True) user2 = adapter.save_user(HttpRequest(), User(), form, commit=True)
self.assertFalse(user2.is_superuser) assert not user2.is_superuser
class TestCustomSocialAccountAdapter(TestCase): class TestCustomSocialAccountAdapter:
def test_is_open_for_signup(self) -> None: @pytest.mark.django_db
def test_is_open_for_signup(self, settings: SettingsWrapper) -> None:
adapter = get_social_adapter() adapter = get_social_adapter()
# Test when SOCIALACCOUNT_ALLOW_SIGNUPS is True
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = True settings.SOCIALACCOUNT_ALLOW_SIGNUPS = True
self.assertTrue(adapter.is_open_for_signup(None, None)) assert adapter.is_open_for_signup(None, None)
# Test when SOCIALACCOUNT_ALLOW_SIGNUPS is False
settings.SOCIALACCOUNT_ALLOW_SIGNUPS = False settings.SOCIALACCOUNT_ALLOW_SIGNUPS = False
self.assertFalse(adapter.is_open_for_signup(None, None)) assert not adapter.is_open_for_signup(None, None)
def test_get_connect_redirect_url(self) -> None: def test_get_connect_redirect_url(self) -> None:
adapter = get_social_adapter() adapter = get_social_adapter()
request = None assert adapter.get_connect_redirect_url(None, None) == reverse("base")
socialaccount = None
# Test the default URL @pytest.mark.django_db
expected_url = reverse("base") def test_save_user_adds_groups(
self.assertEqual( self,
adapter.get_connect_redirect_url(request, socialaccount), settings: SettingsWrapper,
expected_url, mocker: MockerFixture,
) ) -> None:
settings.SOCIAL_ACCOUNT_DEFAULT_GROUPS = ["group1", "group2"]
@override_settings(SOCIAL_ACCOUNT_DEFAULT_GROUPS=["group1", "group2"])
def test_save_user_adds_groups(self) -> None:
Group.objects.create(name="group1") Group.objects.create(name="group1")
adapter = get_social_adapter() adapter = get_social_adapter()
request = HttpRequest()
user = User.objects.create_user("testuser") user = User.objects.create_user("testuser")
sociallogin = mock.Mock( sociallogin = mocker.MagicMock(user=user)
user=user,
)
user = adapter.save_user(request, sociallogin, None) user = adapter.save_user(HttpRequest(), sociallogin, None)
self.assertEqual(user.groups.count(), 1) assert user.groups.count() == 1
self.assertTrue(user.groups.filter(name="group1").exists()) assert user.groups.filter(name="group1").exists()
self.assertFalse(user.groups.filter(name="group2").exists()) assert not user.groups.filter(name="group2").exists()
def test_error_logged_on_authentication_error(self) -> None: def test_error_logged_on_authentication_error(
self,
caplog: pytest.LogCaptureFixture,
) -> None:
adapter = get_social_adapter() adapter = get_social_adapter()
request = HttpRequest() with caplog.at_level(logging.INFO, logger="paperless.auth"):
with self.assertLogs("paperless.auth", level="INFO") as log_cm:
adapter.on_authentication_error( adapter.on_authentication_error(
request, HttpRequest(),
provider="test-provider", provider="test-provider",
error="Error", error="Error",
exception="Test authentication error", exception="Test authentication error",
) )
self.assertTrue( assert any("Test authentication error" in msg for msg in caplog.messages)
any("Test authentication error" in message for message in log_cm.output),
)
class TestDrfTokenStrategy(TestCase): @pytest.mark.django_db
class TestDrfTokenStrategy:
def test_create_access_token_creates_new_token(self) -> None: def test_create_access_token_creates_new_token(self) -> None:
""" """
GIVEN: GIVEN:
@@ -201,7 +187,6 @@ class TestDrfTokenStrategy(TestCase):
THEN: THEN:
- A new token is created and its key is returned - A new token is created and its key is returned
""" """
user = User.objects.create_user("testuser") user = User.objects.create_user("testuser")
request = HttpRequest() request = HttpRequest()
request.user = user request.user = user
@@ -209,13 +194,9 @@ class TestDrfTokenStrategy(TestCase):
strategy = DrfTokenStrategy() strategy = DrfTokenStrategy()
token_key = strategy.create_access_token(request) token_key = strategy.create_access_token(request)
# Verify a token was created assert token_key is not None
self.assertIsNotNone(token_key) assert Token.objects.filter(user=user).exists()
self.assertTrue(Token.objects.filter(user=user).exists()) assert token_key == Token.objects.get(user=user).key
# Verify the returned key matches the created token
token = Token.objects.get(user=user)
self.assertEqual(token_key, token.key)
def test_create_access_token_returns_existing_token(self) -> None: def test_create_access_token_returns_existing_token(self) -> None:
""" """
@@ -226,7 +207,6 @@ class TestDrfTokenStrategy(TestCase):
THEN: THEN:
- The same token key is returned (no new token created) - The same token key is returned (no new token created)
""" """
user = User.objects.create_user("testuser") user = User.objects.create_user("testuser")
existing_token = Token.objects.create(user=user) existing_token = Token.objects.create(user=user)
@@ -236,11 +216,8 @@ class TestDrfTokenStrategy(TestCase):
strategy = DrfTokenStrategy() strategy = DrfTokenStrategy()
token_key = strategy.create_access_token(request) token_key = strategy.create_access_token(request)
# Verify the existing token key is returned assert token_key == existing_token.key
self.assertEqual(token_key, existing_token.key) assert Token.objects.filter(user=user).count() == 1
# Verify only one token exists (no duplicate created)
self.assertEqual(Token.objects.filter(user=user).count(), 1)
def test_create_access_token_returns_none_for_unauthenticated_user(self) -> None: def test_create_access_token_returns_none_for_unauthenticated_user(self) -> None:
""" """
@@ -251,12 +228,11 @@ class TestDrfTokenStrategy(TestCase):
THEN: THEN:
- None is returned and no token is created - None is returned and no token is created
""" """
request = HttpRequest() request = HttpRequest()
request.user = AnonymousUser() request.user = AnonymousUser()
strategy = DrfTokenStrategy() strategy = DrfTokenStrategy()
token_key = strategy.create_access_token(request) token_key = strategy.create_access_token(request)
self.assertIsNone(token_key) assert token_key is None
self.assertEqual(Token.objects.count(), 0) assert Token.objects.count() == 0

View File

@@ -1,16 +1,15 @@
import os import os
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from unittest import mock from unittest import mock
import pytest import pytest
from django.core.checks import Error from django.core.checks import Error
from django.core.checks import Warning from django.core.checks import Warning
from django.test import TestCase from pytest_django.fixtures import SettingsWrapper
from django.test import override_settings
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from documents.tests.utils import DirectoriesMixin
from documents.tests.utils import FileSystemAssertsMixin
from paperless.checks import audit_log_check from paperless.checks import audit_log_check
from paperless.checks import binaries_check from paperless.checks import binaries_check
from paperless.checks import check_deprecated_db_settings from paperless.checks import check_deprecated_db_settings
@@ -20,54 +19,84 @@ from paperless.checks import paths_check
from paperless.checks import settings_values_check from paperless.checks import settings_values_check
class TestChecks(DirectoriesMixin, TestCase): @dataclass(frozen=True, slots=True)
def test_binaries(self) -> None: class PaperlessTestDirs:
self.assertEqual(binaries_check(None), []) data_dir: Path
media_dir: Path
consumption_dir: Path
@override_settings(CONVERT_BINARY="uuuhh")
def test_binaries_fail(self) -> None:
self.assertEqual(len(binaries_check(None)), 1)
def test_paths_check(self) -> None: # TODO: consolidate with documents/tests/conftest.py PaperlessDirs/paperless_dirs
self.assertEqual(paths_check(None), []) # once the paperless and documents test suites are ready to share fixtures.
@pytest.fixture()
def directories(tmp_path: Path, settings: SettingsWrapper) -> PaperlessTestDirs:
data_dir = tmp_path / "data"
media_dir = tmp_path / "media"
consumption_dir = tmp_path / "consumption"
@override_settings( for d in (data_dir, media_dir, consumption_dir):
MEDIA_ROOT=Path("uuh"), d.mkdir()
DATA_DIR=Path("whatever"),
CONSUMPTION_DIR=Path("idontcare"), settings.DATA_DIR = data_dir
settings.MEDIA_ROOT = media_dir
settings.CONSUMPTION_DIR = consumption_dir
return PaperlessTestDirs(
data_dir=data_dir,
media_dir=media_dir,
consumption_dir=consumption_dir,
) )
def test_paths_check_dont_exist(self) -> None:
msgs = paths_check(None)
self.assertEqual(len(msgs), 3, str(msgs))
for msg in msgs:
self.assertTrue(msg.msg.endswith("is set but doesn't exist."))
def test_paths_check_no_access(self) -> None: class TestChecks:
Path(self.dirs.data_dir).chmod(0o000) def test_binaries(self) -> None:
Path(self.dirs.media_dir).chmod(0o000) assert binaries_check(None) == []
Path(self.dirs.consumption_dir).chmod(0o000)
self.addCleanup(os.chmod, self.dirs.data_dir, 0o777) def test_binaries_fail(self, settings: SettingsWrapper) -> None:
self.addCleanup(os.chmod, self.dirs.media_dir, 0o777) settings.CONVERT_BINARY = "uuuhh"
self.addCleanup(os.chmod, self.dirs.consumption_dir, 0o777) assert len(binaries_check(None)) == 1
@pytest.mark.usefixtures("directories")
def test_paths_check(self) -> None:
assert paths_check(None) == []
def test_paths_check_dont_exist(self, settings: SettingsWrapper) -> None:
settings.MEDIA_ROOT = Path("uuh")
settings.DATA_DIR = Path("whatever")
settings.CONSUMPTION_DIR = Path("idontcare")
msgs = paths_check(None) msgs = paths_check(None)
self.assertEqual(len(msgs), 3)
assert len(msgs) == 3, str(msgs)
for msg in msgs: for msg in msgs:
self.assertTrue(msg.msg.endswith("is not writeable")) assert msg.msg.endswith("is set but doesn't exist.")
@override_settings(DEBUG=False) def test_paths_check_no_access(self, directories: PaperlessTestDirs) -> None:
def test_debug_disabled(self) -> None: directories.data_dir.chmod(0o000)
self.assertEqual(debug_mode_check(None), []) directories.media_dir.chmod(0o000)
directories.consumption_dir.chmod(0o000)
@override_settings(DEBUG=True) try:
def test_debug_enabled(self) -> None: msgs = paths_check(None)
self.assertEqual(len(debug_mode_check(None)), 1) finally:
directories.data_dir.chmod(0o777)
directories.media_dir.chmod(0o777)
directories.consumption_dir.chmod(0o777)
assert len(msgs) == 3
for msg in msgs:
assert msg.msg.endswith("is not writeable")
def test_debug_disabled(self, settings: SettingsWrapper) -> None:
settings.DEBUG = False
assert debug_mode_check(None) == []
def test_debug_enabled(self, settings: SettingsWrapper) -> None:
settings.DEBUG = True
assert len(debug_mode_check(None)) == 1
class TestSettingsChecksAgainstDefaults(DirectoriesMixin, TestCase): class TestSettingsChecksAgainstDefaults:
def test_all_valid(self) -> None: def test_all_valid(self) -> None:
""" """
GIVEN: GIVEN:
@@ -78,104 +107,71 @@ class TestSettingsChecksAgainstDefaults(DirectoriesMixin, TestCase):
- No system check errors reported - No system check errors reported
""" """
msgs = settings_values_check(None) msgs = settings_values_check(None)
self.assertEqual(len(msgs), 0) assert len(msgs) == 0
class TestOcrSettingsChecks(DirectoriesMixin, TestCase): class TestOcrSettingsChecks:
@override_settings(OCR_OUTPUT_TYPE="notapdf") @pytest.mark.parametrize(
def test_invalid_output_type(self) -> None: ("setting", "value", "expected_msg"),
[
pytest.param(
"OCR_OUTPUT_TYPE",
"notapdf",
'OCR output type "notapdf"',
id="invalid-output-type",
),
pytest.param(
"OCR_MODE",
"makeitso",
'OCR output mode "makeitso"',
id="invalid-mode",
),
pytest.param(
"OCR_MODE",
"skip_noarchive",
"deprecated",
id="deprecated-mode",
),
pytest.param(
"OCR_SKIP_ARCHIVE_FILE",
"invalid",
'OCR_SKIP_ARCHIVE_FILE setting "invalid"',
id="invalid-skip-archive-file",
),
pytest.param(
"OCR_CLEAN",
"cleanme",
'OCR clean mode "cleanme"',
id="invalid-clean",
),
],
)
def test_invalid_setting_produces_one_error(
self,
settings: SettingsWrapper,
setting: str,
value: str,
expected_msg: str,
) -> None:
""" """
GIVEN: GIVEN:
- Default settings - Default settings
- OCR output type is invalid - One OCR setting is set to an invalid value
WHEN: WHEN:
- Settings are validated - Settings are validated
THEN: THEN:
- system check error reported for OCR output type - Exactly one system check error is reported containing the expected message
""" """
setattr(settings, setting, value)
msgs = settings_values_check(None) msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0] assert len(msgs) == 1
assert expected_msg in msgs[0].msg
self.assertIn('OCR output type "notapdf"', msg.msg)
@override_settings(OCR_MODE="makeitso")
def test_invalid_ocr_type(self) -> None:
"""
GIVEN:
- Default settings
- OCR type is invalid
WHEN:
- Settings are validated
THEN:
- system check error reported for OCR type
"""
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn('OCR output mode "makeitso"', msg.msg)
@override_settings(OCR_MODE="skip_noarchive")
def test_deprecated_ocr_type(self) -> None:
"""
GIVEN:
- Default settings
- OCR type is deprecated
WHEN:
- Settings are validated
THEN:
- deprecation warning reported for OCR type
"""
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn("deprecated", msg.msg)
@override_settings(OCR_SKIP_ARCHIVE_FILE="invalid")
def test_invalid_ocr_skip_archive_file(self) -> None:
"""
GIVEN:
- Default settings
- OCR_SKIP_ARCHIVE_FILE is invalid
WHEN:
- Settings are validated
THEN:
- system check error reported for OCR_SKIP_ARCHIVE_FILE
"""
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn('OCR_SKIP_ARCHIVE_FILE setting "invalid"', msg.msg)
@override_settings(OCR_CLEAN="cleanme")
def test_invalid_ocr_clean(self) -> None:
"""
GIVEN:
- Default settings
- OCR cleaning type is invalid
WHEN:
- Settings are validated
THEN:
- system check error reported for OCR cleaning type
"""
msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0]
self.assertIn('OCR clean mode "cleanme"', msg.msg)
class TestTimezoneSettingsChecks(DirectoriesMixin, TestCase): class TestTimezoneSettingsChecks:
@override_settings(TIME_ZONE="TheMoon\\MyCrater") def test_invalid_timezone(self, settings: SettingsWrapper) -> None:
def test_invalid_timezone(self) -> None:
""" """
GIVEN: GIVEN:
- Default settings - Default settings
@@ -185,17 +181,16 @@ class TestTimezoneSettingsChecks(DirectoriesMixin, TestCase):
THEN: THEN:
- system check error reported for timezone - system check error reported for timezone
""" """
settings.TIME_ZONE = "TheMoon\\MyCrater"
msgs = settings_values_check(None) msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1)
msg = msgs[0] assert len(msgs) == 1
assert 'Timezone "TheMoon\\MyCrater"' in msgs[0].msg
self.assertIn('Timezone "TheMoon\\MyCrater"', msg.msg)
class TestEmailCertSettingsChecks(DirectoriesMixin, FileSystemAssertsMixin, TestCase): class TestEmailCertSettingsChecks:
@override_settings(EMAIL_CERTIFICATE_FILE=Path("/tmp/not_actually_here.pem")) def test_not_valid_file(self, settings: SettingsWrapper) -> None:
def test_not_valid_file(self) -> None:
""" """
GIVEN: GIVEN:
- Default settings - Default settings
@@ -205,19 +200,22 @@ class TestEmailCertSettingsChecks(DirectoriesMixin, FileSystemAssertsMixin, Test
THEN: THEN:
- system check error reported for email certificate - system check error reported for email certificate
""" """
self.assertIsNotFile("/tmp/not_actually_here.pem") cert_path = Path("/tmp/not_actually_here.pem")
assert not cert_path.is_file()
settings.EMAIL_CERTIFICATE_FILE = cert_path
msgs = settings_values_check(None) msgs = settings_values_check(None)
self.assertEqual(len(msgs), 1) assert len(msgs) == 1
assert "Email cert /tmp/not_actually_here.pem is not a file" in msgs[0].msg
msg = msgs[0]
self.assertIn("Email cert /tmp/not_actually_here.pem is not a file", msg.msg)
class TestAuditLogChecks(TestCase): class TestAuditLogChecks:
def test_was_enabled_once(self) -> None: def test_was_enabled_once(
self,
settings: SettingsWrapper,
mocker: MockerFixture,
) -> None:
""" """
GIVEN: GIVEN:
- Audit log is not enabled - Audit log is not enabled
@@ -226,23 +224,18 @@ class TestAuditLogChecks(TestCase):
THEN: THEN:
- system check error reported for disabling audit log - system check error reported for disabling audit log
""" """
introspect_mock = mock.MagicMock() settings.AUDIT_LOG_ENABLED = False
introspect_mock = mocker.MagicMock()
introspect_mock.introspection.table_names.return_value = ["auditlog_logentry"] introspect_mock.introspection.table_names.return_value = ["auditlog_logentry"]
with override_settings(AUDIT_LOG_ENABLED=False): mocker.patch.dict(
with mock.patch.dict( "paperless.checks.connections",
"paperless.checks.connections", {"default": introspect_mock},
{"default": introspect_mock}, )
):
msgs = audit_log_check(None)
self.assertEqual(len(msgs), 1) msgs = audit_log_check(None)
msg = msgs[0] assert len(msgs) == 1
assert "auditlog table was found but audit log is disabled." in msgs[0].msg
self.assertIn(
("auditlog table was found but audit log is disabled."),
msg.msg,
)
DEPRECATED_VARS: dict[str, str] = { DEPRECATED_VARS: dict[str, str] = {
@@ -271,20 +264,16 @@ class TestDeprecatedDbSettings:
@pytest.mark.parametrize( @pytest.mark.parametrize(
("env_var", "db_option_key"), ("env_var", "db_option_key"),
[ [
("PAPERLESS_DB_TIMEOUT", "timeout"), pytest.param("PAPERLESS_DB_TIMEOUT", "timeout", id="db-timeout"),
("PAPERLESS_DB_POOLSIZE", "pool.min_size / pool.max_size"), pytest.param(
("PAPERLESS_DBSSLMODE", "sslmode"), "PAPERLESS_DB_POOLSIZE",
("PAPERLESS_DBSSLROOTCERT", "sslrootcert"), "pool.min_size / pool.max_size",
("PAPERLESS_DBSSLCERT", "sslcert"), id="db-poolsize",
("PAPERLESS_DBSSLKEY", "sslkey"), ),
], pytest.param("PAPERLESS_DBSSLMODE", "sslmode", id="ssl-mode"),
ids=[ pytest.param("PAPERLESS_DBSSLROOTCERT", "sslrootcert", id="ssl-rootcert"),
"db-timeout", pytest.param("PAPERLESS_DBSSLCERT", "sslcert", id="ssl-cert"),
"db-poolsize", pytest.param("PAPERLESS_DBSSLKEY", "sslkey", id="ssl-key"),
"ssl-mode",
"ssl-rootcert",
"ssl-cert",
"ssl-key",
], ],
) )
def test_single_deprecated_var_produces_one_warning( def test_single_deprecated_var_produces_one_warning(
@@ -403,7 +392,10 @@ class TestV3MinimumUpgradeVersionCheck:
"""Test suite for check_v3_minimum_upgrade_version system check.""" """Test suite for check_v3_minimum_upgrade_version system check."""
@pytest.fixture @pytest.fixture
def build_conn_mock(self, mocker: MockerFixture): def build_conn_mock(
self,
mocker: MockerFixture,
) -> Callable[[list[str], list[str]], mock.MagicMock]:
"""Factory fixture that builds a connections['default'] mock. """Factory fixture that builds a connections['default'] mock.
Usage:: Usage::
@@ -423,7 +415,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_no_migrations_table_fresh_install( def test_no_migrations_table_fresh_install(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock, build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -442,7 +434,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_no_documents_migrations_fresh_install( def test_no_documents_migrations_fresh_install(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock, build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -461,7 +453,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_v3_state_with_0001_squashed( def test_v3_state_with_0001_squashed(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock, build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -485,7 +477,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_v3_state_with_0002_squashed_only( def test_v3_state_with_0002_squashed_only(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock, build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -504,7 +496,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_v2_20_9_state_ready_to_upgrade( def test_v2_20_9_state_ready_to_upgrade(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock, build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -531,7 +523,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_v2_20_8_raises_error( def test_v2_20_8_raises_error(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock, build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -558,7 +550,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_very_old_version_raises_error( def test_very_old_version_raises_error(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock, build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
) -> None: ) -> None:
""" """
GIVEN: GIVEN:
@@ -585,7 +577,7 @@ class TestV3MinimumUpgradeVersionCheck:
def test_error_hint_mentions_v2_20_9( def test_error_hint_mentions_v2_20_9(
self, self,
mocker: MockerFixture, mocker: MockerFixture,
build_conn_mock, build_conn_mock: Callable[[list[str], list[str]], mock.MagicMock],
) -> None: ) -> None:
""" """
GIVEN: GIVEN:

View File

@@ -9,35 +9,50 @@ from paperless.utils import ocr_to_dateparser_languages
@pytest.mark.parametrize( @pytest.mark.parametrize(
("ocr_language", "expected"), ("ocr_language", "expected"),
[ [
# One language pytest.param("eng", ["en"], id="single-language"),
("eng", ["en"]), pytest.param("fra+ita+lao", ["fr", "it", "lo"], id="multiple-languages"),
# Multiple languages pytest.param("fil", ["fil"], id="no-two-letter-equivalent"),
("fra+ita+lao", ["fr", "it", "lo"]), pytest.param(
# Languages that don't have a two-letter equivalent "aze_cyrl+srp_latn",
("fil", ["fil"]), ["az-Cyrl", "sr-Latn"],
# Languages with a script part supported by dateparser id="script-supported-by-dateparser",
("aze_cyrl+srp_latn", ["az-Cyrl", "sr-Latn"]), ),
# Languages with a script part not supported by dateparser pytest.param(
# In this case, default to the language without script "deu_frak",
("deu_frak", ["de"]), ["de"],
# Traditional and simplified chinese don't have the same name in dateparser, id="script-not-supported-falls-back-to-language",
# so they're converted to the general chinese language ),
("chi_tra+chi_sim", ["zh"]), pytest.param(
# If a language is not supported by dateparser, fallback to the supported ones "chi_tra+chi_sim",
("eng+unsupported_language+por", ["en", "pt"]), ["zh"],
# If no language is supported, fallback to default id="chinese-variants-collapse-to-general",
("unsupported1+unsupported2", []), ),
# Duplicate languages, should not duplicate in result pytest.param(
("eng+eng", ["en"]), "eng+unsupported_language+por",
# Language with script, but script is not mapped ["en", "pt"],
("ita_unknownscript", ["it"]), id="unsupported-language-skipped",
),
pytest.param(
"unsupported1+unsupported2",
[],
id="all-unsupported-returns-empty",
),
pytest.param("eng+eng", ["en"], id="duplicates-deduplicated"),
pytest.param(
"ita_unknownscript",
["it"],
id="unknown-script-falls-back-to-language",
),
], ],
) )
def test_ocr_to_dateparser_languages(ocr_language, expected): def test_ocr_to_dateparser_languages(ocr_language: str, expected: list[str]) -> None:
assert sorted(ocr_to_dateparser_languages(ocr_language)) == sorted(expected) assert sorted(ocr_to_dateparser_languages(ocr_language)) == sorted(expected)
def test_ocr_to_dateparser_languages_exception(monkeypatch, caplog): def test_ocr_to_dateparser_languages_exception(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
# Patch LocaleDataLoader.get_locale_map to raise an exception # Patch LocaleDataLoader.get_locale_map to raise an exception
class DummyLoader: class DummyLoader:
def get_locale_map(self, locales=None): def get_locale_map(self, locales=None):

View File

@@ -1,24 +1,31 @@
import tempfile
from pathlib import Path from pathlib import Path
from django.test import override_settings from django.test import Client
from pytest_django.fixtures import SettingsWrapper
def test_favicon_view(client): def test_favicon_view(
with tempfile.TemporaryDirectory() as tmpdir: client: Client,
static_dir = Path(tmpdir) tmp_path: Path,
favicon_path = static_dir / "paperless" / "img" / "favicon.ico" settings: SettingsWrapper,
favicon_path.parent.mkdir(parents=True, exist_ok=True) ) -> None:
favicon_path.write_bytes(b"FAKE ICON DATA") favicon_path = tmp_path / "paperless" / "img" / "favicon.ico"
favicon_path.parent.mkdir(parents=True)
favicon_path.write_bytes(b"FAKE ICON DATA")
with override_settings(STATIC_ROOT=static_dir): settings.STATIC_ROOT = tmp_path
response = client.get("/favicon.ico")
assert response.status_code == 200 response = client.get("/favicon.ico")
assert response["Content-Type"] == "image/x-icon" assert response.status_code == 200
assert b"".join(response.streaming_content) == b"FAKE ICON DATA" assert response["Content-Type"] == "image/x-icon"
assert b"".join(response.streaming_content) == b"FAKE ICON DATA"
def test_favicon_view_missing_file(client): def test_favicon_view_missing_file(
with override_settings(STATIC_ROOT=Path(tempfile.mkdtemp())): client: Client,
response = client.get("/favicon.ico") tmp_path: Path,
assert response.status_code == 404 settings: SettingsWrapper,
) -> None:
settings.STATIC_ROOT = tmp_path
response = client.get("/favicon.ico")
assert response.status_code == 404