mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-06 00:00:32 +00:00
Chore: Move the cross-app test helpers into the shared layer and add a progress fixture (#14221)
Test modules in paperless, paperless_mail and documents imported filesystem assertions, the migration test base, the retry helper and the streaming-response reader out of documents/tests/utils.py, which kept each app's tests coupled to another app's test package. They now live in paperless_testing, and the progress manager fake is renamed FakeProgressManager and now subclasses the real ProgressManager, overriding only the transport, so the payload it records is built by the production code. The twenty places that patched documents.tasks.ProgressManager by hand now use a fake_progress_manager fixture.
This commit is contained in:
@@ -1,126 +1,16 @@
|
||||
import time
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Generator
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from django.apps import apps
|
||||
from django.db import connection
|
||||
from django.db.migrations.executor import MigrationExecutor
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.test import TransactionTestCase
|
||||
|
||||
from documents.consumer import AsnCheckPlugin
|
||||
from documents.consumer import ConsumerPlugin
|
||||
from documents.consumer import ConsumerPreflightPlugin
|
||||
from documents.data_models import ConsumableDocument
|
||||
from documents.data_models import DocumentMetadataOverrides
|
||||
from documents.data_models import DocumentSource
|
||||
from documents.parsers import ParseError
|
||||
from documents.plugins.helpers import ProgressStatusOptions
|
||||
|
||||
|
||||
def util_call_with_backoff(
|
||||
method_or_callable: Callable,
|
||||
args: list | tuple,
|
||||
*,
|
||||
skip_on_50x_err=True,
|
||||
) -> tuple[bool, Any]:
|
||||
"""
|
||||
For whatever reason, the images started during the test pipeline like to
|
||||
segfault sometimes, crash and otherwise fail randomly, when run with the
|
||||
exact files that usually pass.
|
||||
|
||||
So, this function will retry the given method/function up to 3 times, with larger backoff
|
||||
periods between each attempt, in hopes the issue resolves itself during
|
||||
one attempt to parse.
|
||||
|
||||
This will wait the following:
|
||||
- Attempt 1 - 20s following failure
|
||||
- Attempt 2 - 40s following failure
|
||||
- Attempt 3 - 80s following failure
|
||||
|
||||
"""
|
||||
result = None
|
||||
succeeded = False
|
||||
retry_time = 20.0
|
||||
retry_count = 0
|
||||
status_codes = []
|
||||
max_retry_count = 3
|
||||
|
||||
while retry_count < max_retry_count and not succeeded:
|
||||
try:
|
||||
result = method_or_callable(*args)
|
||||
|
||||
succeeded = True
|
||||
except ParseError as e: # pragma: no cover
|
||||
cause_exec = e.__cause__
|
||||
if cause_exec is not None and isinstance(cause_exec, httpx.HTTPStatusError):
|
||||
status_codes.append(cause_exec.response.status_code)
|
||||
warnings.warn(
|
||||
f"HTTP Exception for {cause_exec.request.url} - {cause_exec}",
|
||||
)
|
||||
else:
|
||||
warnings.warn(f"Unexpected error: {e}")
|
||||
except Exception as e: # pragma: no cover
|
||||
warnings.warn(f"Unexpected error: {e}")
|
||||
|
||||
retry_count = retry_count + 1
|
||||
|
||||
time.sleep(retry_time)
|
||||
retry_time = retry_time * 2.0
|
||||
|
||||
if (
|
||||
not succeeded
|
||||
and status_codes
|
||||
and skip_on_50x_err
|
||||
and all(httpx.codes.is_server_error(code) for code in status_codes)
|
||||
):
|
||||
pytest.skip("Repeated HTTP 50x for service") # pragma: no cover
|
||||
|
||||
return succeeded, result
|
||||
|
||||
|
||||
def read_streaming_response(response: StreamingHttpResponse) -> bytes:
|
||||
"""Consume a StreamingHttpResponse/FileResponse and close it."""
|
||||
content = b"".join(response.streaming_content)
|
||||
response.close()
|
||||
return content
|
||||
|
||||
|
||||
class FileSystemAssertsMixin:
|
||||
"""
|
||||
Utilities for checks various state information of the file system
|
||||
"""
|
||||
|
||||
def assertIsFile(self, path: PathLike[str] | str) -> None:
|
||||
self.assertTrue(Path(path).resolve().is_file(), f"File does not exist: {path}")
|
||||
|
||||
def assertIsNotFile(self, path: PathLike[str] | str) -> None:
|
||||
self.assertFalse(Path(path).resolve().is_file(), f"File does exist: {path}")
|
||||
|
||||
def assertIsDir(self, path: PathLike[str] | str) -> None:
|
||||
self.assertTrue(Path(path).resolve().is_dir(), f"Dir does not exist: {path}")
|
||||
|
||||
def assertIsNotDir(self, path: PathLike[str] | str) -> None:
|
||||
self.assertFalse(Path(path).resolve().is_dir(), f"Dir does exist: {path}")
|
||||
|
||||
def assertFileCountInDir(self, path: PathLike[str] | str, count: int) -> None:
|
||||
path = Path(path).resolve()
|
||||
self.assertTrue(path.is_dir(), f"Path {path} is not a directory")
|
||||
files = [x for x in path.iterdir() if x.is_file()]
|
||||
self.assertEqual(
|
||||
len(files),
|
||||
count,
|
||||
f"Path {path} contains {len(files)} files instead of {count} files",
|
||||
)
|
||||
from paperless_testing.fakes.progress import FakeProgressManager
|
||||
|
||||
|
||||
class ConsumeTaskMixin:
|
||||
@@ -158,59 +48,6 @@ class ConsumeTaskMixin:
|
||||
yield (task_kwargs["input_doc"], task_kwargs["overrides"])
|
||||
|
||||
|
||||
class TestMigrations(TransactionTestCase):
|
||||
@property
|
||||
def app(self):
|
||||
return apps.get_containing_app_config(type(self).__module__).name
|
||||
|
||||
migrate_from = None
|
||||
dependencies = None
|
||||
migrate_to = None
|
||||
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
|
||||
assert self.migrate_from and self.migrate_to, (
|
||||
f"TestCase '{type(self).__name__}' must define migrate_from and migrate_to properties"
|
||||
)
|
||||
self.migrate_from = [(self.app, self.migrate_from)]
|
||||
if self.dependencies is not None:
|
||||
self.migrate_from.extend(self.dependencies)
|
||||
self.migrate_to = [(self.app, self.migrate_to)]
|
||||
executor = MigrationExecutor(connection)
|
||||
old_apps = executor.loader.project_state(self.migrate_from).apps
|
||||
|
||||
# Reverse to the original migration
|
||||
executor.migrate(self.migrate_from)
|
||||
|
||||
self.setUpBeforeMigration(old_apps)
|
||||
|
||||
self.apps = old_apps
|
||||
|
||||
# Run the migration to test
|
||||
executor = MigrationExecutor(connection)
|
||||
executor.loader.build_graph() # reload.
|
||||
executor.migrate(self.migrate_to)
|
||||
|
||||
self.apps = executor.loader.project_state(self.migrate_to).apps
|
||||
|
||||
def setUpBeforeMigration(self, apps) -> None:
|
||||
pass
|
||||
|
||||
def tearDown(self) -> None:
|
||||
"""
|
||||
Ensure the database schema is restored to the latest migration after
|
||||
each migration test, so subsequent tests run against HEAD.
|
||||
"""
|
||||
try:
|
||||
executor = MigrationExecutor(connection)
|
||||
executor.loader.build_graph()
|
||||
targets = executor.loader.graph.leaf_nodes()
|
||||
executor.migrate(targets)
|
||||
finally:
|
||||
super().tearDown()
|
||||
|
||||
|
||||
class SampleDirMixin:
|
||||
SAMPLE_DIR = Path(__file__).parent / "samples"
|
||||
|
||||
@@ -227,7 +64,7 @@ class GetConsumerMixin:
|
||||
mailrule_id: int | None = None,
|
||||
) -> Generator[ConsumerPlugin, None, None]:
|
||||
# Store this for verification
|
||||
self.status = DummyProgressManager(filepath.name, None)
|
||||
self.status = FakeProgressManager(filepath.name, None)
|
||||
doc = ConsumableDocument(
|
||||
source,
|
||||
original_file=filepath,
|
||||
@@ -263,63 +100,3 @@ class GetConsumerMixin:
|
||||
yield reader
|
||||
finally:
|
||||
reader.cleanup()
|
||||
|
||||
|
||||
class DummyProgressManager:
|
||||
"""
|
||||
A dummy handler for progress management that doesn't actually try to
|
||||
connect to Redis. Payloads are stored for test assertions if needed.
|
||||
|
||||
Use it with
|
||||
mock.patch("documents.tasks.ProgressManager", DummyProgressManager)
|
||||
"""
|
||||
|
||||
def __init__(self, filename: str, task_id: str | None = None) -> None:
|
||||
self.filename = filename
|
||||
self.task_id = task_id
|
||||
self.payloads = []
|
||||
|
||||
def __enter__(self):
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
self.close()
|
||||
|
||||
def open(self) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def send_progress(
|
||||
self,
|
||||
status: ProgressStatusOptions,
|
||||
message: str,
|
||||
current_progress: int,
|
||||
max_progress: int,
|
||||
*,
|
||||
document_id: int | None = None,
|
||||
owner_id: int | None = None,
|
||||
users_can_view: list[int] | None = None,
|
||||
groups_can_view: list[int] | None = None,
|
||||
) -> None:
|
||||
# Ensure the layer is open
|
||||
self.open()
|
||||
|
||||
payload = {
|
||||
"type": "status_update",
|
||||
"data": {
|
||||
"filename": self.filename,
|
||||
"task_id": self.task_id,
|
||||
"current_progress": current_progress,
|
||||
"max_progress": max_progress,
|
||||
"status": status,
|
||||
"message": message,
|
||||
"document_id": document_id,
|
||||
"owner_id": owner_id,
|
||||
"users_can_view": users_can_view or [],
|
||||
"groups_can_view": groups_can_view or [],
|
||||
},
|
||||
}
|
||||
self.payloads.append(payload)
|
||||
|
||||
Reference in New Issue
Block a user