Coverage for src/paperap/tests/testcase.py: 78%
134 statements
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-15 03:55 -0400
« prev ^ index » next coverage.py v7.6.12, created at 2025-03-15 03:55 -0400
1"""
5 ----------------------------------------------------------------------------
7 METADATA:
9 File: testcase.py
10 Project: paperap
11 Created: 2025-03-04
12 Version: 0.0.7
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
26from abc import ABC, abstractmethod
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 pydantic import ValidationError
35from typing_extensions import TypeAlias, TypeVar
37from paperap.client import PaperlessClient
38from paperap.models import (BaseQuerySet, Correspondent, CorrespondentQuerySet,
39 CustomField, CustomFieldQuerySet,
40 Document, DocumentQuerySet, DocumentType,
41 DocumentTypeQuerySet, Group, GroupQuerySet,
42 Profile, ProfileQuerySet, SavedView,
43 SavedViewQuerySet, ShareLinks, ShareLinksQuerySet,
44 StandardModel, StandardQuerySet, StoragePath,
45 StoragePathQuerySet, Tag, TagQuerySet, Task,
46 TaskQuerySet, UISettings, UISettingsQuerySet, User,
47 UserQuerySet, Workflow, WorkflowAction,
48 WorkflowActionQuerySet, WorkflowQuerySet,
49 WorkflowTrigger, WorkflowTriggerQuerySet)
50from paperap.resources import (BaseResource, CorrespondentResource,
51 CustomFieldResource,
52 DocumentResource, DocumentTypeResource,
53 GroupResource, ProfileResource,
54 SavedViewResource, ShareLinksResource,
55 StandardResource, StoragePathResource,
56 TagResource, TaskResource, UISettingsResource,
57 UserResource, WorkflowActionResource,
58 WorkflowResource, WorkflowTriggerResource)
59from paperap.tests.utils import load_sample_data
60from paperap.tests.factories import (CorrespondentFactory, DocumentFactory,
61 DocumentTypeFactory, GroupFactory,
62 ProfileFactory, PydanticFactory,
63 SavedViewFactory, ShareLinksFactory,
64 StoragePathFactory, TagFactory,
65 TaskFactory, UISettingsFactory,
66 UserFactory, WorkflowActionFactory,
67 WorkflowFactory, WorkflowTriggerFactory)
69logger = logging.getLogger(__name__)
71_StandardModel = TypeVar("_StandardModel", bound="StandardModel", default="StandardModel")
72_StandardResource = TypeVar("_StandardResource", bound="StandardResource", default="StandardResource")
73_StandardQuerySet = TypeVar("_StandardQuerySet", bound="StandardQuerySet", default="StandardQuerySet")
75class TestMixin(ABC, Generic[_StandardModel, _StandardResource, _StandardQuerySet]):
76 """
77 A base test case class for testing Paperless NGX resources.
79 Attributes:
80 client: The PaperlessClient instance.
81 mock_env: Whether to mock the environment variables.
82 env_data: The environment data to use when mocking.
83 resource: The resource being tested.
84 resource_class: The class of the resource being tested.
85 factory: The factory class for creating model instances.
86 model_data_parsed: The data for creating a model instance.
87 list_data: The data for creating a list of model instances.
88 """
89 # Patching stuff
90 mock_env : bool = True
91 env_data : dict[str, Any] = {'PAPERLESS_BASE_URL': 'http://example.com', 'PAPERLESS_TOKEN': '40characterslong40characterslong40charac', 'PAPERLESS_SAVE_ON_WRITE': 'False'}
93 # Data for the test
94 sample_data_filename : str | None = None
95 model_data_unparsed : dict[str, Any]
96 model_data_parsed : dict[str, Any]
97 list_data : dict[str, Any]
99 # Instances
100 client : "PaperlessClient"
101 resource : _StandardResource
102 model : _StandardModel
104 # Types (TODO only one of these should be needed)
105 factory : type[PydanticFactory[_StandardModel]]
106 resource_class : type[_StandardResource]
107 model_type : type[_StandardModel] | None = None
108 queryset_type : type[_StandardQuerySet] | None = None
110 @property
111 def _meta(self) -> StandardModel.Meta:
112 return self.model._meta # type: ignore # Allow private attribute access in tests
114 def _reset_attributes(self) -> None:
115 """
116 Set up the test case by initializing the client, resource, and model data.
117 """
118 self.setup_references()
119 self.setup_client()
120 self.setup_resource()
121 self.setup_model_data()
122 self.setup_model()
124 @abstractmethod
125 def setup_client(self, **kwargs) -> None:
126 raise NotImplementedError("Method must be implemented in subclasses.")
128 @abstractmethod
129 def validate_field(self, field_name : str, test_cases : list[tuple[Any, Any]]):
130 """
131 Validate that a field is parsed correctly with various types of data.
133 Args:
134 field_name: The name of the field to test.
135 test_cases: A list of tuples with input values and expected results.
137 Examples:
138 test_cases = [
139 (42, 42),
140 ("42", 42),
141 (None, None),
142 (0, ValidationError),
143 (Decimal('42.5'), ValidationError),
144 ]
145 self.validate_field("age", test_cases)
146 """
147 raise NotImplementedError("Method must be implemented in subclasses.")
149 def setup_references(self) -> None:
150 # Check if we have each attrib, and set all the others we can
151 if hasattr(self, "modal_type"):
152 self.resource = getattr(self, "resource", self.model_type._meta.resource) # type: ignore
153 self.resource_class = getattr(self, "resource_class", self.resource.__class__) # type: ignore
154 self.queryset_type = getattr(self, "queryset_type", self.model_type._meta.queryset) # type: ignore
155 if hasattr(self, "model"):
156 self.model_type = getattr(self, "model_type", self.model.__class__) # type: ignore
157 self.resource = getattr(self, "resource", self._meta.resource) # type: ignore
158 self.resource_class = getattr(self, "resource_class", self.resource.__class__) # type: ignore
159 self.queryset_type = getattr(self, "queryset_type", self._meta.queryset) # type: ignore
160 '''
161 if hasattr(self, "factory"):
162 self.model_type = getattr(self, "model_type", self.factory._meta.model) # type: ignore
163 self.resource = getattr(self, "resource", self.model_type._meta.resource) # type: ignore
164 self.resource_class = getattr(self, "resource_class", self.resource.__class__) # type: ignore
165 self.queryset_type = getattr(self, "queryset_type", self.model_type._meta.queryset) # type: ignore
166 '''
167 if hasattr(self, "resource"):
168 self.resource_class = getattr(self, "resource_class", self.resource.__class__) # type: ignore
169 self.model_type = getattr(self, "model_type", self.resource.model_class) # type: ignore
170 self.queryset_type = getattr(self, "queryset_type", self.model_type._meta.queryset) # type: ignore
172 def setup_resource(self) -> None:
173 """
174 Set up the resource instance using the resource class.
175 """
176 if not getattr(self, "resource", None) and (resource_class := getattr(self, 'resource_class', None)):
177 self.resource = resource_class(client=self.client) # pylint: disable=not-callable
179 def setup_model_data(self) -> None:
180 """
181 Load model data if the resource is set.
182 """
183 if getattr(self, "resource", None):
184 if unparsed := getattr(self, 'model_data_unparsed', None):
185 self.model_data_parsed = self.model_data_parsed or self.resource.transform_data_output(**unparsed)
186 else:
187 self.load_model_data()
189 if not self.model_data_unparsed and (parsed := getattr(self, "model_data_parsed")):
190 self.model_data_unparsed = self.resource.transform_data_input(**parsed)
192 def setup_model(self) -> None:
193 """
194 Set up the model instance using the factory and model data.
195 """
196 if getattr(self, "resource", None) and getattr(self, "model_data_unparsed", None):
197 self.model = self.resource.parse_to_model(self.model_data_unparsed)
199 def bake_model(self, *args, **kwargs : Any) -> _StandardModel:
200 """
201 Create a model instance using the factory.
203 Args:
204 *args: Positional arguments for the factory.
205 **kwargs: Keyword arguments for the factory.
207 Returns:
208 A new model instance.
209 """
210 return self.factory.create(*args, **kwargs)
212 def create_list(self, count : int, *args, **kwargs : Any) -> list[_StandardModel]:
213 """
214 Create a list of model instances using the factory.
216 Args:
217 count: The number of instances to create.
218 *args: Positional arguments for the factory.
219 **kwargs: Keyword arguments for the factory.
221 Returns:
222 A list of new model instances.
223 """
224 return [self.bake_model(*args, **kwargs) for _ in range(count)]
226 def load_model(self, resource_name : str | None = None) -> _StandardModel:
227 """
228 Load a model instance from sample data.
230 Args:
231 resource_name: The name of the resource to load data for.
233 Returns:
234 A new model instance created from the sample data.
235 """
236 sample_data = self.load_model_data(resource_name)
237 return self.resource.parse_to_model(sample_data)
239 def load_list(self, resource_name : str | None = None) -> list[_StandardModel]:
240 """
241 Load a list of model instances from sample data.
243 Args:
244 resource_name: The name of the resource to load data for.
246 Returns:
247 A list of new model instances created from the sample data.
248 """
249 sample_data = self.load_list_data(resource_name)
250 return [self.resource.parse_to_model(item) for item in sample_data["results"]]
252 def _call_list_resource(self, resource : type[StandardResource[_StandardModel]] | StandardResource[_StandardModel] | None = None, **kwargs : Any) -> BaseQuerySet[_StandardModel]:
253 """
254 Call the list method on a resource.
256 Args:
257 resource: The resource or resource class to call.
258 **kwargs: Additional filter parameters.
260 Returns:
261 A BaseQuerySet of model instances.
262 """
263 if not resource:
264 if not (resource := getattr(self,"resource", None)):
265 raise ValueError("Resource not provided")
267 # If resource is a type, instantiate it
268 if isinstance(resource, type):
269 return resource(client=self.client).filter(**kwargs)
270 return resource.filter(**kwargs)
272 def _call_get_resource(self, resource : type[StandardResource[_StandardModel]] | StandardResource[_StandardModel], pk : int) -> _StandardModel:
273 """
274 Call the get method on a resource.
276 Args:
277 resource: The resource or resource class to call.
278 pk: The primary key of the model instance to retrieve.
280 Returns:
281 The model instance with the specified primary key.
282 """
283 # If resource is a type, instantiate it
284 if isinstance(resource, type):
285 return resource(client=self.client).get(pk)
287 return resource.get(pk)
289 def list_resource(self, resource : type[StandardResource[_StandardModel]] | StandardResource[_StandardModel] | None = None, **kwargs : Any) -> BaseQuerySet[_StandardModel]:
290 """
291 List resources using sample data or by calling the resource.
293 Args:
294 resource: The resource or resource class to list.
295 **kwargs: Additional filter parameters.
297 Returns:
298 A BaseQuerySet of model instances.
299 """
300 if not resource:
301 if not (resource := getattr(self, "resource", None)):
302 raise ValueError("Resource not provided")
304 try:
305 sample_data = self.load_list_data(resource.name)
306 with patch("paperap.client.PaperlessClient.request") as request:
307 request.return_value = sample_data
308 qs = self._call_list_resource(resource, **kwargs)
309 for _ in qs:
310 pass
311 return qs
313 except FileNotFoundError:
314 return self._call_list_resource(resource, **kwargs)
316 def get_resource(self, resource : type[StandardResource[_StandardModel]] | StandardResource[_StandardModel], pk : int) -> _StandardModel:
317 """
318 Get a resource using sample data or by calling the resource.
320 Args:
321 resource: The resource or resource class to get.
322 pk: The primary key of the model instance to retrieve.
324 Returns:
325 The model instance with the specified primary key.
326 """
327 try:
328 sample_data = self.load_model_data()
329 with patch("paperap.client.PaperlessClient.request") as request:
330 request.return_value = sample_data
331 return self._call_get_resource(resource, pk)
332 except FileNotFoundError:
333 return self._call_get_resource(resource, pk)
335 def load_model_data(self, resource_name : str | None = None) -> dict[str, Any]:
336 """
337 Load model data from a sample data file.
339 Args:
340 resource_name: The name of the resource to load data for.
342 Returns:
343 A dictionary containing the model data.
344 """
345 if not getattr(self, "model_data_parsed", None):
346 if self.sample_data_filename:
347 self.model_data_unparsed = load_sample_data(self.sample_data_filename)
348 else:
349 resource_name = resource_name or self.resource.name
350 filename = f"{resource_name}_item.json"
351 self.model_data_unparsed = load_sample_data(filename)
353 self.model_data_parsed = self.resource.transform_data_output(**self.model_data_unparsed)
354 return self.model_data_parsed
356 def load_list_data(self, resource_name : str | None = None) -> dict[str, Any]:
357 """
358 Load list data from a sample data file.
360 Args:
361 resource_name: The name of the resource to load data for.
363 Returns:
364 A dictionary containing the list data.
365 """
366 if not getattr(self, "list_data", None):
367 resource_name = resource_name or self.resource.name
368 filename = f"{resource_name}_list.json"
369 self.list_data = load_sample_data(filename)
370 return self.list_data