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

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 

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 

33 

34from typing_extensions import TypeAlias, TypeVar 

35 

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) 

64 

65logger = logging.getLogger(__name__) 

66 

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

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

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

70 

71def load_sample_data(filename : str) -> dict[str, Any]: 

72 """ 

73 Load sample data from a JSON file. 

74 

75 Args: 

76 filename: The name of the file to load. 

77 

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 

87 

88class TestCase(unittest.TestCase, Generic[_StandardModel, _StandardResource, _StandardQuerySet]): 

89 """ 

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

91 

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'} 

105 

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] 

110 

111 # Instances 

112 client : "PaperlessClient" 

113 resource : _StandardResource 

114 model : _StandardModel 

115 

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 

121 

122 @property 

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

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

125 

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

136 

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

147 

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 

170 

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 

177 

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

184 

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) 

191 

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

193 """ 

194 Create a model instance using the factory. 

195 

196 Args: 

197 *args: Positional arguments for the factory. 

198 **kwargs: Keyword arguments for the factory. 

199 

200 Returns: 

201 A new model instance. 

202 """ 

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

204 

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

206 """ 

207 Create a list of model instances using the factory. 

208 

209 Args: 

210 count: The number of instances to create. 

211 *args: Positional arguments for the factory. 

212 **kwargs: Keyword arguments for the factory. 

213 

214 Returns: 

215 A list of new model instances. 

216 """ 

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

218 

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

220 """ 

221 Load a model instance from sample data. 

222 

223 Args: 

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

225 

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) 

231 

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

233 """ 

234 Load a list of model instances from sample data. 

235 

236 Args: 

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

238 

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

244 

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. 

248 

249 Args: 

250 resource: The resource or resource class to call. 

251 **kwargs: Additional filter parameters. 

252 

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

259 

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) 

264 

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

266 """ 

267 Call the get method on a resource. 

268 

269 Args: 

270 resource: The resource or resource class to call. 

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

272 

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) 

279 

280 return resource.get(pk) 

281 

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. 

285 

286 Args: 

287 resource: The resource or resource class to list. 

288 **kwargs: Additional filter parameters. 

289 

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

296 

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 

305 

306 except FileNotFoundError: 

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

308 

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. 

312 

313 Args: 

314 resource: The resource or resource class to get. 

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

316 

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) 

327 

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

329 """ 

330 Load model data from a sample data file. 

331 

332 Args: 

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

334 

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 

344 

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

346 """ 

347 Load list data from a sample data file. 

348 

349 Args: 

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

351 

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 

360 

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. 

370 

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) 

378 

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) 

386 

387 if callback: 

388 self.assertTrue(callback(model), f"Condition failed for {model}") 

389 

390 # Check multiple results, but avoid paging 

391 if count > 5: 

392 break 

393 

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

397 

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. 

408 

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']) 

418 

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) 

429 

430 self.assertEqual(qs.count(), expected_count) 

431 

432 self.assert_queryset_callback( 

433 queryset = qs, 

434 expected_count = expected_count, 

435 callback = callback 

436 ) 

437 

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 

446 

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 

455 

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 

464 

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 

473 

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 

482 

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 

491 

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 

500 

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 

509 

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 

518 

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 

527 

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 

536 

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 

545 

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 

554 

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 

563 

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