Coverage for src/paperap/tests/testcase.py: 78%

1"""

2

3

4

5 ----------------------------------------------------------------------------

6

7 METADATA:

8

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

16

17 ----------------------------------------------------------------------------

18

19 LAST MODIFIED:

20

21 2025-03-04 By Jess Mann

22

23"""

24 from __future__ import annotations

25

26 from abc import ABC, abstractmethod

27 import logging

28 import os

29 import unittest

30 from pathlib import Path

31 from typing import TYPE_CHECKING, Any, Callable, Generic, Iterator, override

32 from unittest.mock import MagicMock, patch

33

34 from pydantic import ValidationError

35 from typing_extensions import TypeAlias, TypeVar

36

37 from paperap.client import PaperlessClient

38 from 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)

50 from 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)

59 from paperap.tests.utils import load_sample_data

60 from 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)

68

69 logger = logging.getLogger(__name__)

70

71 _StandardModel = TypeVar("_StandardModel", bound="StandardModel", default="StandardModel")

72 _StandardResource = TypeVar("_StandardResource", bound="StandardResource", default="StandardResource")

73 _StandardQuerySet = TypeVar("_StandardQuerySet", bound="StandardQuerySet", default="StandardQuerySet")

74

75 class TestMixin(ABC, Generic[_StandardModel, _StandardResource, _StandardQuerySet]):

76 """

77 A base test case class for testing Paperless NGX resources.

78

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://localhost:8000', 'PAPERLESS_TOKEN': 'abc123', 'PAPERLESS_SAVE_ON_WRITE': 'False'}

92

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]

98

99 # Instances

100 client : "PaperlessClient"

101 resource : _StandardResource

102 model : _StandardModel

103

104 # Types (TODO only one of these should be needed)

105 factory : type[PydanticFactory]

106 resource_class : type[_StandardResource]

107 model_type : type[_StandardModel] | None = None

108 queryset_type : type[_StandardQuerySet] | None = None

109

110 @property

111 def _meta(self) -> StandardModel.Meta:

112 return self.model._meta # type: ignore # Allow private attribute access in tests

113

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()

123

124 @abstractmethod

125 def setup_client(self, **kwargs) -> None:

126 raise NotImplementedError("Method must be implemented in subclasses.")

127

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.

132

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.

136

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.")

148

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

171

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

178

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()

188

189 def setup_model(self) -> None:

190 """

191 Set up the model instance using the factory and model data.

192 """

193 if getattr(self, "resource", None) and getattr(self, "model_data_unparsed", None):

194 self.model = self.resource.parse_to_model(self.model_data_unparsed)

195

196 def bake_model(self, *args, **kwargs : Any) -> _StandardModel:

197 """

198 Create a model instance using the factory.

199

200 Args:

201 *args: Positional arguments for the factory.

202 **kwargs: Keyword arguments for the factory.

203

204 Returns:

205 A new model instance.

206 """

207 return self.factory.build(*args, **kwargs)

208

209 def create_list(self, count : int, *args, **kwargs : Any) -> list[_StandardModel]:

210 """

211 Create a list of model instances using the factory.

212

213 Args:

214 count: The number of instances to create.

215 *args: Positional arguments for the factory.

216 **kwargs: Keyword arguments for the factory.

217

218 Returns:

219 A list of new model instances.

220 """

221 return [self.bake_model(*args, **kwargs) for _ in range(count)]

222

223 def load_model(self, resource_name : str | None = None) -> _StandardModel:

224 """

225 Load a model instance from sample data.

226

227 Args:

228 resource_name: The name of the resource to load data for.

229

230 Returns:

231 A new model instance created from the sample data.

232 """

233 sample_data = self.load_model_data(resource_name)

234 return self.resource.parse_to_model(sample_data)

235

236 def load_list(self, resource_name : str | None = None) -> list[_StandardModel]:

237 """

238 Load a list of model instances from sample data.

239

240 Args:

241 resource_name: The name of the resource to load data for.

242

243 Returns:

244 A list of new model instances created from the sample data.

245 """

246 sample_data = self.load_list_data(resource_name)

247 return [self.resource.parse_to_model(item) for item in sample_data["results"]]

248

249 def _call_list_resource(self, resource : type[StandardResource[_StandardModel]] | StandardResource[_StandardModel] | None = None, **kwargs : Any) -> BaseQuerySet[_StandardModel]:

250 """

251 Call the list method on a resource.

252

253 Args:

254 resource: The resource or resource class to call.

255 **kwargs: Additional filter parameters.

256

257 Returns:

258 A BaseQuerySet of model instances.

259 """

260 if not resource:

261 if not (resource := getattr(self,"resource", None)):

262 raise ValueError("Resource not provided")

263

264 # If resource is a type, instantiate it

265 if isinstance(resource, type):

266 return resource(client=self.client).filter(**kwargs)

267 return resource.filter(**kwargs)

268

269 def _call_get_resource(self, resource : type[StandardResource[_StandardModel]] | StandardResource[_StandardModel], pk : int) -> _StandardModel:

270 """

271 Call the get method on a resource.

272

273 Args:

274 resource: The resource or resource class to call.

275 pk: The primary key of the model instance to retrieve.

276

277 Returns:

278 The model instance with the specified primary key.

279 """

280 # If resource is a type, instantiate it

281 if isinstance(resource, type):

282 return resource(client=self.client).get(pk)

283

284 return resource.get(pk)

285

286 def list_resource(self, resource : type[StandardResource[_StandardModel]] | StandardResource[_StandardModel] | None = None, **kwargs : Any) -> BaseQuerySet[_StandardModel]:

287 """

288 List resources using sample data or by calling the resource.

289

290 Args:

291 resource: The resource or resource class to list.

292 **kwargs: Additional filter parameters.

293

294 Returns:

295 A BaseQuerySet of model instances.

296 """

297 if not resource:

298 if not (resource := getattr(self, "resource", None)):

299 raise ValueError("Resource not provided")

300

301 try:

302 sample_data = self.load_list_data(resource.name)

303 with patch("paperap.client.PaperlessClient.request") as request:

304 request.return_value = sample_data

305 qs = self._call_list_resource(resource, **kwargs)

306 for _ in qs:

307 pass

308 return qs

309

310 except FileNotFoundError:

311 return self._call_list_resource(resource, **kwargs)

312

313 def get_resource(self, resource : type[StandardResource[_StandardModel]] | StandardResource[_StandardModel], pk : int) -> _StandardModel:

314 """

315 Get a resource using sample data or by calling the resource.

316

317 Args:

318 resource: The resource or resource class to get.

319 pk: The primary key of the model instance to retrieve.

320

321 Returns:

322 The model instance with the specified primary key.

323 """

324 try:

325 sample_data = self.load_model_data()

326 with patch("paperap.client.PaperlessClient.request") as request:

327 request.return_value = sample_data

328 return self._call_get_resource(resource, pk)

329 except FileNotFoundError:

330 return self._call_get_resource(resource, pk)

331

332 def load_model_data(self, resource_name : str | None = None) -> dict[str, Any]:

333 """

334 Load model data from a sample data file.

335

336 Args:

337 resource_name: The name of the resource to load data for.

338

339 Returns:

340 A dictionary containing the model data.

341 """

342 if not getattr(self, "model_data_parsed", None):

343 if self.sample_data_filename:

344 self.model_data_unparsed = load_sample_data(self.sample_data_filename)

345 else:

346 resource_name = resource_name or self.resource.name

347 filename = f"{resource_name}_item.json"

348 self.model_data_unparsed = load_sample_data(filename)

349

350 self.model_data_parsed = self.resource.transform_data_output(**self.model_data_unparsed)

351 return self.model_data_parsed

352

353 def load_list_data(self, resource_name : str | None = None) -> dict[str, Any]:

354 """

355 Load list data from a sample data file.

356

357 Args:

358 resource_name: The name of the resource to load data for.

359

360 Returns:

361 A dictionary containing the list data.

362 """

363 if not getattr(self, "list_data", None):

364 resource_name = resource_name or self.resource.name

365 filename = f"{resource_name}_list.json"

366 self.list_data = load_sample_data(filename)

367 return self.list_data