Coverage for src/paperap/tests/testcase.py: 88%
236 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-11 21:37 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-11 21:37 -0400
1"""
5 ----------------------------------------------------------------------------
7 METADATA:
9 File: testcase.py
10 Project: paperap
11 Created: 2025-03-04
12 Version: 0.0.5
13 Author: Jess Mann
14 Email: jess@jmann.me
15 Copyright (c) 2025 Jess Mann
17 ----------------------------------------------------------------------------
19 LAST MODIFIED:
21 2025-03-04 By Jess Mann
23"""
24from __future__ import annotations
26import json
27import logging
28import os
29import unittest
30from pathlib import Path
31from typing import TYPE_CHECKING, Any, Callable, Generic, Iterator, override
32from unittest.mock import MagicMock, patch
34from typing_extensions import TypeAlias, TypeVar
36from paperap.client import PaperlessClient
37from paperap.models import (BaseQuerySet, Correspondent, CorrespondentQuerySet,
38 Document, DocumentQuerySet, DocumentType,
39 DocumentTypeQuerySet, Group, GroupQuerySet,
40 Profile, ProfileQuerySet, SavedView,
41 SavedViewQuerySet, ShareLinks, ShareLinksQuerySet,
42 StandardModel, StandardQuerySet, StoragePath,
43 StoragePathQuerySet, Tag, TagQuerySet, Task,
44 TaskQuerySet, UISettings, UISettingsQuerySet, User,
45 UserQuerySet, Workflow, WorkflowAction,
46 WorkflowActionQuerySet, WorkflowQuerySet,
47 WorkflowTrigger, WorkflowTriggerQuerySet)
48from paperap.resources import (BaseResource, CorrespondentResource,
49 DocumentResource, DocumentTypeResource,
50 GroupResource, ProfileResource,
51 SavedViewResource, ShareLinksResource,
52 StandardResource, StoragePathResource,
53 TagResource, TaskResource, UISettingsResource,
54 UserResource, WorkflowActionResource,
55 WorkflowResource, WorkflowTriggerResource)
56from paperap.tests.factories import (CorrespondentFactory, DocumentFactory,
57 DocumentTypeFactory, GroupFactory,
58 ProfileFactory, PydanticFactory,
59 SavedViewFactory, ShareLinksFactory,
60 StoragePathFactory, TagFactory,
61 TaskFactory, UISettingsFactory,
62 UserFactory, WorkflowActionFactory,
63 WorkflowFactory, WorkflowTriggerFactory)
65logger = logging.getLogger(__name__)
67_StandardModel = TypeVar("_StandardModel", bound="StandardModel", default="StandardModel")
68_StandardResource = TypeVar("_StandardResource", bound="StandardResource", default="StandardResource")
69_StandardQuerySet = TypeVar("_StandardQuerySet", bound="StandardQuerySet", default="StandardQuerySet")
71def load_sample_data(filename : str) -> dict[str, Any]:
72 """
73 Load sample data from a JSON file.
75 Args:
76 filename: The name of the file to load.
78 Returns:
79 A dictionary containing the sample data.
80 """
81 # Load sample response from tests/sample_data/{model}_{endpoint}.json
82 sample_data_filepath = Path(__file__).parent.parent.parent.parent / "tests" / "sample_data" / filename
83 with open(sample_data_filepath, "r", encoding="utf-8") as f:
84 text = f.read()
85 sample_data = json.loads(text)
86 return sample_data
88class TestCase(unittest.TestCase, Generic[_StandardModel, _StandardResource, _StandardQuerySet]):
89 """
90 A base test case class for testing Paperless NGX resources.
92 Attributes:
93 client: The PaperlessClient instance.
94 mock_env: Whether to mock the environment variables.
95 env_data: The environment data to use when mocking.
96 resource: The resource being tested.
97 resource_class: The class of the resource being tested.
98 factory: The factory class for creating model instances.
99 model_data_parsed: The data for creating a model instance.
100 list_data: The data for creating a list of model instances.
101 """
102 # Patching stuff
103 mock_env : bool = True
104 env_data : dict[str, Any] = {'PAPERLESS_BASE_URL': 'http://localhost:8000', 'PAPERLESS_TOKEN': 'abc123', 'PAPERLESS_SAVE_ON_WRITE': 'False'}
106 # Data for the test
107 model_data_unparsed : dict[str, Any]
108 model_data_parsed : dict[str, Any]
109 list_data : dict[str, Any]
111 # Instances
112 client : "PaperlessClient"
113 resource : _StandardResource
114 model : _StandardModel
116 # Types (TODO only one of these should be needed)
117 factory : type[PydanticFactory]
118 resource_class : type[_StandardResource]
119 model_type : type[_StandardModel] | None = None
120 queryset_type : type[_StandardQuerySet] | None = None
122 @property
123 def _meta(self) -> StandardModel.Meta:
124 return self.model._meta # type: ignore # Allow private attribute access in tests
126 @override
127 def setUp(self) -> None:
128 """
129 Set up the test case by initializing the client, resource, and model data.
130 """
131 self.setup_references()
132 self.setup_client()
133 self.setup_resource()
134 self.setup_model_data()
135 self.setup_model()
137 def setup_client(self) -> None:
138 """
139 Set up the PaperlessClient instance, optionally mocking environment variables.
140 """
141 if not hasattr(self, "client") or not self.client:
142 if self.mock_env:
143 with patch.dict(os.environ, self.env_data, clear=True):
144 self.client = PaperlessClient()
145 else:
146 self.client = PaperlessClient()
148 def setup_references(self) -> None:
149 # Check if we have each attrib, and set all the others we can
150 if hasattr(self, "modal_type"):
151 self.resource = getattr(self, "resource", self.model_type._meta.resource) # type: ignore
152 self.resource_class = getattr(self, "resource_class", self.resource.__class__) # type: ignore
153 self.queryset_type = getattr(self, "queryset_type", self.model_type._meta.queryset) # type: ignore
154 if hasattr(self, "model"):
155 self.model_type = getattr(self, "model_type", self.model.__class__) # type: ignore
156 self.resource = getattr(self, "resource", self._meta.resource) # type: ignore
157 self.resource_class = getattr(self, "resource_class", self.resource.__class__) # type: ignore
158 self.queryset_type = getattr(self, "queryset_type", self._meta.queryset) # type: ignore
159 '''
160 if hasattr(self, "factory"):
161 self.model_type = getattr(self, "model_type", self.factory._meta.model) # type: ignore
162 self.resource = getattr(self, "resource", self.model_type._meta.resource) # type: ignore
163 self.resource_class = getattr(self, "resource_class", self.resource.__class__) # type: ignore
164 self.queryset_type = getattr(self, "queryset_type", self.model_type._meta.queryset) # type: ignore
165 '''
166 if hasattr(self, "resource"):
167 self.resource_class = getattr(self, "resource_class", self.resource.__class__) # type: ignore
168 self.model_type = getattr(self, "model_type", self.resource.model_class) # type: ignore
169 self.queryset_type = getattr(self, "queryset_type", self.model_type._meta.queryset) # type: ignore
171 def setup_resource(self) -> None:
172 """
173 Set up the resource instance using the resource class.
174 """
175 if not getattr(self, "resource", None) and (resource_class := getattr(self, 'resource_class', None)):
176 self.resource = resource_class(client=self.client) # pylint: disable=not-callable
178 def setup_model_data(self) -> None:
179 """
180 Load model data if the resource is set.
181 """
182 if getattr(self, "resource", None):
183 self.load_model_data()
185 def setup_model(self) -> None:
186 """
187 Set up the model instance using the factory and model data.
188 """
189 if getattr(self, "resource", None) and getattr(self, "factory", None):
190 self.model = self.resource.parse_to_model(self.model_data_parsed)
192 def bake_model(self, *args, **kwargs : Any) -> _StandardModel:
193 """
194 Create a model instance using the factory.
196 Args:
197 *args: Positional arguments for the factory.
198 **kwargs: Keyword arguments for the factory.
200 Returns:
201 A new model instance.
202 """
203 return self.factory.build(*args, **kwargs)
205 def create_list(self, count : int, *args, **kwargs : Any) -> list[_StandardModel]:
206 """
207 Create a list of model instances using the factory.
209 Args:
210 count: The number of instances to create.
211 *args: Positional arguments for the factory.
212 **kwargs: Keyword arguments for the factory.
214 Returns:
215 A list of new model instances.
216 """
217 return [self.bake_model(*args, **kwargs) for _ in range(count)]
219 def load_model(self, resource_name : str | None = None) -> _StandardModel:
220 """
221 Load a model instance from sample data.
223 Args:
224 resource_name: The name of the resource to load data for.
226 Returns:
227 A new model instance created from the sample data.
228 """
229 sample_data = self.load_model_data(resource_name)
230 return self.resource.parse_to_model(sample_data)
232 def load_list(self, resource_name : str | None = None) -> list[_StandardModel]:
233 """
234 Load a list of model instances from sample data.
236 Args:
237 resource_name: The name of the resource to load data for.
239 Returns:
240 A list of new model instances created from the sample data.
241 """
242 sample_data = self.load_list_data(resource_name)
243 return [self.resource.parse_to_model(item) for item in sample_data["results"]]
245 def _call_list_resource(self, resource : type[StandardResource[_StandardModel]] | StandardResource[_StandardModel] | None = None, **kwargs : Any) -> BaseQuerySet[_StandardModel]:
246 """
247 Call the list method on a resource.
249 Args:
250 resource: The resource or resource class to call.
251 **kwargs: Additional filter parameters.
253 Returns:
254 A BaseQuerySet of model instances.
255 """
256 if not resource:
257 if not (resource := getattr(self,"resource", None)):
258 raise ValueError("Resource not provided")
260 # If resource is a type, instantiate it
261 if isinstance(resource, type):
262 return resource(client=self.client).filter(**kwargs)
263 return resource.filter(**kwargs)
265 def _call_get_resource(self, resource : type[StandardResource[_StandardModel]] | StandardResource[_StandardModel], pk : int) -> _StandardModel:
266 """
267 Call the get method on a resource.
269 Args:
270 resource: The resource or resource class to call.
271 pk: The primary key of the model instance to retrieve.
273 Returns:
274 The model instance with the specified primary key.
275 """
276 # If resource is a type, instantiate it
277 if isinstance(resource, type):
278 return resource(client=self.client).get(pk)
280 return resource.get(pk)
282 def list_resource(self, resource : type[StandardResource[_StandardModel]] | StandardResource[_StandardModel] | None = None, **kwargs : Any) -> BaseQuerySet[_StandardModel]:
283 """
284 List resources using sample data or by calling the resource.
286 Args:
287 resource: The resource or resource class to list.
288 **kwargs: Additional filter parameters.
290 Returns:
291 A BaseQuerySet of model instances.
292 """
293 if not resource:
294 if not (resource := getattr(self, "resource", None)):
295 raise ValueError("Resource not provided")
297 try:
298 sample_data = self.load_list_data(resource.name)
299 with patch("paperap.client.PaperlessClient.request") as request:
300 request.return_value = sample_data
301 qs = self._call_list_resource(resource, **kwargs)
302 for _ in qs:
303 pass
304 return qs
306 except FileNotFoundError:
307 return self._call_list_resource(resource, **kwargs)
309 def get_resource(self, resource : type[StandardResource[_StandardModel]] | StandardResource[_StandardModel], pk : int) -> _StandardModel:
310 """
311 Get a resource using sample data or by calling the resource.
313 Args:
314 resource: The resource or resource class to get.
315 pk: The primary key of the model instance to retrieve.
317 Returns:
318 The model instance with the specified primary key.
319 """
320 try:
321 sample_data = self.load_model_data()
322 with patch("paperap.client.PaperlessClient.request") as request:
323 request.return_value = sample_data
324 return self._call_get_resource(resource, pk)
325 except FileNotFoundError:
326 return self._call_get_resource(resource, pk)
328 def load_model_data(self, resource_name : str | None = None) -> dict[str, Any]:
329 """
330 Load model data from a sample data file.
332 Args:
333 resource_name: The name of the resource to load data for.
335 Returns:
336 A dictionary containing the model data.
337 """
338 if not getattr(self, "model_data_parsed", None):
339 resource_name = resource_name or self.resource.name
340 filename = f"{resource_name}_item.json"
341 model_data_parsed = load_sample_data(filename)
342 self.model_data_parsed = self.resource.transform_data_output(**model_data_parsed)
343 return self.model_data_parsed
345 def load_list_data(self, resource_name : str | None = None) -> dict[str, Any]:
346 """
347 Load list data from a sample data file.
349 Args:
350 resource_name: The name of the resource to load data for.
352 Returns:
353 A dictionary containing the list data.
354 """
355 if not getattr(self, "list_data", None):
356 resource_name = resource_name or self.resource.name
357 filename = f"{resource_name}_list.json"
358 self.list_data = load_sample_data(filename)
359 return self.list_data
361 def assert_queryset_callback(
362 self,
363 *,
364 queryset : StandardQuerySet[_StandardModel],
365 callback : Callable[[_StandardModel], bool] | None = None,
366 expected_count : int | None = None
367 ) -> None:
368 """
369 Generic method to test queryset filtering.
371 Args:
372 queryset: The queryset to test
373 callback: A callback function to test each model instance.
374 expected_count: The expected result count of the queryset.
375 """
376 if expected_count is not None:
377 self.assertEqual(queryset.count(), expected_count)
379 count = 0
380 for model in queryset:
381 count += 1
382 if self.model_type:
383 self.assertIsInstance(model, self.model_type)
384 else:
385 self.assertIsInstance(model, StandardModel)
387 if callback:
388 self.assertTrue(callback(model), f"Condition failed for {model}")
390 # Check multiple results, but avoid paging
391 if count > 5:
392 break
394 if expected_count is not None:
395 expected_iterations = min(expected_count, 6)
396 self.assertEqual(count, expected_iterations, f"Documents iteration unexpected. Count: {expected_count} -> Expected {expected_iterations} iterations, got {count}.")
398 def assert_queryset_callback_patched(
399 self,
400 *,
401 queryset : StandardQuerySet[_StandardModel] | Callable[..., StandardQuerySet[_StandardModel]],
402 sample_data : dict[str, Any],
403 callback : Callable[[_StandardModel], bool] | None = None,
404 expected_count : int | None = None,
405 ) -> None:
406 """
407 Generic method to test queryset filtering.
409 Args:
410 queryset: The queryset to test, or a method which retrieves a queryset.
411 sample_data: The sample data to use for the queryset.
412 callback: A callback function to test each model instance.
413 expected_count: The expected result count of the queryset.
414 """
415 # Setup defaults
416 if expected_count is None:
417 expected_count = int(sample_data['count'])
419 with patch('paperap.client.PaperlessClient.request') as mock_request:
420 mock_request.return_value = sample_data
421 if not isinstance(queryset, Callable):
422 qs = queryset
423 else:
424 qs = queryset()
425 if self.queryset_type:
426 self.assertIsInstance(qs, self.queryset_type)
427 else:
428 self.assertIsInstance(qs, BaseQuerySet)
430 self.assertEqual(qs.count(), expected_count)
432 self.assert_queryset_callback(
433 queryset = qs,
434 expected_count = expected_count,
435 callback = callback
436 )
438class DocumentTest(TestCase["Document", "DocumentResource", "DocumentQuerySet"]):
439 """
440 A test case for the Document model and resource.
441 """
442 resource_class = DocumentResource
443 model_type = Document
444 queryset_type = DocumentQuerySet
445 factory = DocumentFactory
447class DocumentTypeTest(TestCase["DocumentType", "DocumentTypeResource", "DocumentTypeQuerySet"]):
448 """
449 A test case for the DocumentType model and resource.
450 """
451 resource_class = DocumentTypeResource
452 model_type = DocumentType
453 queryset_type = DocumentTypeQuerySet
454 factory = DocumentTypeFactory
456class CorrespondentTest(TestCase["Correspondent", "CorrespondentResource", "CorrespondentQuerySet"]):
457 """
458 A test case for the Correspondent model and resource.
459 """
460 resource_class = CorrespondentResource
461 model_type = Correspondent
462 queryset_type = CorrespondentQuerySet
463 factory = CorrespondentFactory
465class TagTest(TestCase["Tag", "TagResource", "TagQuerySet"]):
466 """
467 A test case for the Tag model and resource.
468 """
469 resource_class = TagResource
470 model_type = Tag
471 queryset_type = TagQuerySet
472 factory = TagFactory
474class UserTest(TestCase["User", "UserResource", "UserQuerySet"]):
475 """
476 A test case for the User model and resource.
477 """
478 resource_class = UserResource
479 model_type = User
480 queryset_type = UserQuerySet
481 factory = UserFactory
483class GroupTest(TestCase["Group", "GroupResource", "GroupQuerySet"]):
484 """
485 A test case for the Group model and resource.
486 """
487 resource_class = GroupResource
488 model_type = Group
489 queryset_type = GroupQuerySet
490 factory = GroupFactory
492class ProfileTest(TestCase["Profile", "ProfileResource", "ProfileQuerySet"]):
493 """
494 A test case for the Profile model and resource.
495 """
496 resource_class = ProfileResource
497 model_type = Profile
498 queryset_type = ProfileQuerySet
499 factory = ProfileFactory
501class TaskTest(TestCase["Task", "TaskResource", "TaskQuerySet"]):
502 """
503 A test case for the Task model and resource.
504 """
505 resource_class = TaskResource
506 model_type = Task
507 queryset_type = TaskQuerySet
508 factory = TaskFactory
510class WorkflowTest(TestCase["Workflow", "WorkflowResource", "WorkflowQuerySet"]):
511 """
512 A test case for the Workflow model and resource.
513 """
514 resource_class = WorkflowResource
515 model_type = Workflow
516 queryset_type = WorkflowQuerySet
517 factory = WorkflowFactory
519class SavedViewTest(TestCase["SavedView", "SavedViewResource", "SavedViewQuerySet"]):
520 """
521 A test case for the SavedView model and resource.
522 """
523 resource_class = SavedViewResource
524 model_type = SavedView
525 queryset_type = SavedViewQuerySet
526 factory = SavedViewFactory
528class ShareLinksTest(TestCase["ShareLinks", "ShareLinksResource", "ShareLinksQuerySet"]):
529 """
530 A test case for ShareLinks
531 """
532 resource_class = ShareLinksResource
533 model_type = ShareLinks
534 queryset_type = ShareLinksQuerySet
535 factory = ShareLinksFactory
537class UISettingsTest(TestCase["UISettings", "UISettingsResource", "UISettingsQuerySet"]):
538 """
539 A test case for the UISettings model and resource.
540 """
541 resource_class = UISettingsResource
542 model_type = UISettings
543 queryset_type = UISettingsQuerySet
544 factory = UISettingsFactory
546class StoragePathTest(TestCase["StoragePath", "StoragePathResource", "StoragePathQuerySet"]):
547 """
548 A test case for the StoragePath model and resource.
549 """
550 resource_class = StoragePathResource
551 model_type = StoragePath
552 queryset_type = StoragePathQuerySet
553 factory = StoragePathFactory
555class WorkflowActionTest(TestCase["WorkflowAction", "WorkflowActionResource", "WorkflowActionQuerySet"]):
556 """
557 A test case for the WorkflowAction model and resource.
558 """
559 resource_class = WorkflowActionResource
560 model_type = WorkflowAction
561 queryset_type = WorkflowActionQuerySet
562 factory = WorkflowActionFactory
564class WorkflowTriggerTest(TestCase["WorkflowTrigger", "WorkflowTriggerResource", "WorkflowTriggerQuerySet"]):
565 """
566 A test case for the WorkflowTrigger model and resource.
567 """
568 resource_class = WorkflowTriggerResource
569 model_type = WorkflowTrigger
570 queryset_type = WorkflowTriggerQuerySet
571 factory = WorkflowTriggerFactory