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

132 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-12 23:40 -0400

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

24from __future__ import annotations 

25 

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 

33 

34from pydantic import ValidationError 

35from typing_extensions import TypeAlias, TypeVar 

36 

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) 

68 

69logger = 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 

75class 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