|
1 | 1 | import os |
2 | 2 | import zoneinfo |
3 | 3 | from io import BytesIO |
| 4 | +from unittest.mock import patch |
4 | 5 | from zipfile import ZipFile |
5 | 6 |
|
6 | 7 | from django.conf import settings |
@@ -172,11 +173,19 @@ def assertNotInPDF(self, pdffile_or_bytes, expected_text_or_list, on_page=None): |
172 | 173 | else: |
173 | 174 | raise AssertionError(f"Text found in PDF: {expected_text_or_list}") |
174 | 175 |
|
175 | | - def assertPDFPages(self, pdffile_or_bytes, npages_expected): |
| 176 | + def assertPDFPages( |
| 177 | + self, pdffile_or_bytes, npages_expected, accept_more_pages_than_expected=False |
| 178 | + ): |
176 | 179 | pdf = PdfReader(self.get_filelike(pdffile_or_bytes)) |
177 | 180 | npages = len(pdf.pages) |
178 | | - if npages != npages_expected: |
179 | | - raise AssertionError(f"PDF has {npages} pages insted of {npages_expected}") |
| 181 | + if accept_more_pages_than_expected: |
| 182 | + if npages < npages_expected: |
| 183 | + raise AssertionError( |
| 184 | + f"PDF has {npages} pages, which is less than the minimum of {npages_expected}" |
| 185 | + ) |
| 186 | + else: |
| 187 | + if npages != npages_expected: |
| 188 | + raise AssertionError(f"PDF has {npages} pages instead of {npages_expected}") |
180 | 189 |
|
181 | 190 | def assertInZIP(self, zipfile_or_bytes, num_files=None, contents=None): |
182 | 191 | if contents is None: |
@@ -246,3 +255,226 @@ def setUpTestData(cls): |
246 | 255 |
|
247 | 256 | def setUp(self): |
248 | 257 | self.client.login(username="superuser", password="secret") |
| 258 | + |
| 259 | + |
| 260 | +def fill_template_effect(template, context, output_format="odt"): |
| 261 | + odt_path = "/tmp/mock_odtfile.odt" |
| 262 | + if not os.path.exists(odt_path): |
| 263 | + with open(odt_path, "wb") as f: |
| 264 | + f.write(b"ODT mock\n") |
| 265 | + return odt_path |
| 266 | + |
| 267 | + |
| 268 | +def odt2pdf_effect(odtfile, instance_tag="default"): |
| 269 | + pdf_path = "/tmp/mock_pdffile.pdf" |
| 270 | + if not os.path.exists(pdf_path): |
| 271 | + with open(pdf_path, "wb") as f: |
| 272 | + f.write(b"%PDF-1.1\n%mock\n") |
| 273 | + return pdf_path |
| 274 | + |
| 275 | + |
| 276 | +class DocumentCreationMockMixin: |
| 277 | + """ |
| 278 | + Mixin that patches `fill_template_pod` and `odt2pdf` and provides helper assertions. |
| 279 | + """ |
| 280 | + |
| 281 | + patch_target_fill_template = "geno.utils.fill_template_pod" |
| 282 | + patch_target_odt2pdf = "geno.utils.odt2pdf" |
| 283 | + |
| 284 | + def setUp(self): |
| 285 | + super().setUp() |
| 286 | + fill_template_patcher = patch(self.patch_target_fill_template) |
| 287 | + self.mock_fill_template = fill_template_patcher.start() |
| 288 | + self.mock_fill_template.side_effect = fill_template_effect |
| 289 | + self.addCleanup(fill_template_patcher.stop) |
| 290 | + |
| 291 | + odt2pdf_patcher = patch(self.patch_target_odt2pdf) |
| 292 | + self.mock_odt2pdf = odt2pdf_patcher.start() |
| 293 | + self.mock_odt2pdf.side_effect = odt2pdf_effect |
| 294 | + self.addCleanup(odt2pdf_patcher.stop) |
| 295 | + |
| 296 | + def reset_mocks(self): |
| 297 | + self.mock_fill_template.reset_mock() |
| 298 | + self.mock_odt2pdf.reset_mock() |
| 299 | + |
| 300 | + def assertMocksCallCount(self, expected_count): |
| 301 | + self.assertEqual(self.mock_fill_template.call_count, expected_count) |
| 302 | + self.assertEqual(self.mock_odt2pdf.call_count, expected_count) |
| 303 | + |
| 304 | + def assertFillTemplateCalledOnce(self): |
| 305 | + self.mock_fill_template.assert_called_once() |
| 306 | + |
| 307 | + def assertFillTemplateCalledWith( |
| 308 | + self, template=None, expected_context_items=None, call_index=None |
| 309 | + ): |
| 310 | + if template: |
| 311 | + self.assertFillTemplateCalledWithTemplate(template, call_index) |
| 312 | + if expected_context_items: |
| 313 | + self.assertFillTemplateContextContains(expected_context_items, call_index) |
| 314 | + if not template and not expected_context_items: |
| 315 | + raise AssertionError("assertFillTemplateCalledWith called without arguments.") |
| 316 | + |
| 317 | + def assertFillTemplateCalledWithTemplate(self, template, call_index=None): |
| 318 | + """ |
| 319 | + Assert that fill_template_pod was called with the given template. |
| 320 | +
|
| 321 | + - If call_index is None: check that *any* call used this template. |
| 322 | + - If call_index is an integer: check only that specific call. |
| 323 | + """ |
| 324 | + |
| 325 | + if not self.mock_fill_template.called: |
| 326 | + raise AssertionError("fill_template_pod was never called.") |
| 327 | + |
| 328 | + calls = self.mock_fill_template.call_args_list |
| 329 | + |
| 330 | + # Helper to extract template argument |
| 331 | + def extract_template(call): |
| 332 | + args, kwargs = call |
| 333 | + return kwargs.get("template") or args[0] |
| 334 | + |
| 335 | + # Check a specific call |
| 336 | + if call_index is not None: |
| 337 | + try: |
| 338 | + call = calls[call_index] |
| 339 | + except IndexError: |
| 340 | + raise AssertionError( |
| 341 | + f"fill_template_pod was called {len(calls)} times, " |
| 342 | + f"but call_index {call_index} was requested." |
| 343 | + ) |
| 344 | + |
| 345 | + actual_template = extract_template(call) |
| 346 | + if actual_template != template: |
| 347 | + raise AssertionError( |
| 348 | + f"In call {call_index}, expected template {template!r}, " |
| 349 | + f"but got {actual_template!r}." |
| 350 | + ) |
| 351 | + return # success |
| 352 | + |
| 353 | + # No call_index → check ANY call |
| 354 | + for call in calls: |
| 355 | + if extract_template(call) == template: |
| 356 | + return # success |
| 357 | + |
| 358 | + # If we reach here → no call matched |
| 359 | + formatted_calls = "\n".join( |
| 360 | + f"call[{i}].template = {extract_template(call)!r}" for i, call in enumerate(calls) |
| 361 | + ) |
| 362 | + raise AssertionError( |
| 363 | + f"No call to fill_template_pod used template {template!r}.\n\n" |
| 364 | + f"Templates used in calls:\n{formatted_calls}" |
| 365 | + ) |
| 366 | + |
| 367 | + def assertFillTemplateContextContains(self, expected_items: dict, call_index=None): |
| 368 | + """ |
| 369 | + Assert that the context passed to fill_template_pod contains expected_items. |
| 370 | +
|
| 371 | + - If call_index is None: check that *any* call contains the items. |
| 372 | + - If call_index is an integer: check that specific call. |
| 373 | + """ |
| 374 | + |
| 375 | + calls = self.mock_fill_template.call_args_list |
| 376 | + |
| 377 | + def extract_context(call): |
| 378 | + args, kwargs = call |
| 379 | + return kwargs.get("context") or args[1] |
| 380 | + |
| 381 | + # Check a specific call |
| 382 | + if call_index is not None: |
| 383 | + try: |
| 384 | + call = calls[call_index] |
| 385 | + except IndexError: |
| 386 | + raise AssertionError( |
| 387 | + f"fill_template_pod was called {len(calls)} times, " |
| 388 | + f"but call_index {call_index} was requested." |
| 389 | + ) |
| 390 | + |
| 391 | + context = extract_context(call) |
| 392 | + for key, value in expected_items.items(): |
| 393 | + if key not in context: |
| 394 | + if value is None: |
| 395 | + # None means that the key can or should be missing |
| 396 | + continue |
| 397 | + print(context) |
| 398 | + raise AssertionError(f"In call {call_index}, context missing key {key!r}.") |
| 399 | + if value is None: |
| 400 | + if context[key] not in (None, ""): |
| 401 | + raise AssertionError( |
| 402 | + f"In call {call_index}, expected key {key!r} to be missing, " |
| 403 | + f"but got {context[key]}." |
| 404 | + ) |
| 405 | + elif context[key] != value and str(context[key]) != str(value): |
| 406 | + raise AssertionError( |
| 407 | + f"In call {call_index}, key {key!r}: " |
| 408 | + f"expected {value!r}, got {context[key]!r}." |
| 409 | + ) |
| 410 | + return # success |
| 411 | + |
| 412 | + # No call_index → check ANY call |
| 413 | + for call in calls: |
| 414 | + context = extract_context(call) |
| 415 | + |
| 416 | + if all(key in context and context[key] == val for key, val in expected_items.items()): |
| 417 | + return # success: at least one call matched |
| 418 | + |
| 419 | + # If we reach here → no calls matched |
| 420 | + formatted_calls = "\n".join( |
| 421 | + f"call[{i}].context = {extract_context(call)}" for i, call in enumerate(calls) |
| 422 | + ) |
| 423 | + raise AssertionError( |
| 424 | + "No call to fill_template_pod had a context containing the expected items.\n\n" |
| 425 | + f"Expected items: {expected_items}\n\n" |
| 426 | + f"All contexts:\n{formatted_calls}" |
| 427 | + ) |
| 428 | + |
| 429 | + def assertOdt2PdfCalledOnce(self): |
| 430 | + self.mock_odt2pdf.assert_called_once() |
| 431 | + |
| 432 | + def assertOdt2PdfCalledWithFile(self, odtfile, call_index=None): |
| 433 | + """ |
| 434 | + Assert that odt2pdf was called with the given odtfile. |
| 435 | +
|
| 436 | + - If call_index is None: check that *any* call used this odtfile. |
| 437 | + - If call_index is an integer: check only that specific call. |
| 438 | + """ |
| 439 | + |
| 440 | + if not self.mock_odt2pdf.called: |
| 441 | + raise AssertionError("odt2pdf was never called.") |
| 442 | + |
| 443 | + calls = self.mock_odt2pdf.call_args_list |
| 444 | + |
| 445 | + # Helper to extract odtfile argument |
| 446 | + def extract_odtfile(call): |
| 447 | + args, kwargs = call |
| 448 | + return kwargs.get("odtfile") or args[0] |
| 449 | + |
| 450 | + # Check a specific call |
| 451 | + if call_index is not None: |
| 452 | + try: |
| 453 | + call = calls[call_index] |
| 454 | + except IndexError: |
| 455 | + raise AssertionError( |
| 456 | + f"odt2pdf was called {len(calls)} times, " |
| 457 | + f"but call_index {call_index} was requested." |
| 458 | + ) |
| 459 | + |
| 460 | + actual_odtfile = extract_odtfile(call) |
| 461 | + if actual_odtfile != odtfile: |
| 462 | + raise AssertionError( |
| 463 | + f"In call {call_index}, expected odtfile {odtfile!r}, " |
| 464 | + f"but got {actual_odtfile!r}." |
| 465 | + ) |
| 466 | + return # success |
| 467 | + |
| 468 | + # No call_index → check ANY call |
| 469 | + for call in calls: |
| 470 | + if extract_odtfile(call) == odtfile: |
| 471 | + return # success |
| 472 | + |
| 473 | + # If we reach here → no call matched |
| 474 | + formatted_calls = "\n".join( |
| 475 | + f"call[{i}].odtfile = {extract_odtfile(call)!r}" for i, call in enumerate(calls) |
| 476 | + ) |
| 477 | + raise AssertionError( |
| 478 | + f"No call to odt2pdf used odtfile {odtfile!r}.\n\n" |
| 479 | + f"Templates used in calls:\n{formatted_calls}" |
| 480 | + ) |
0 commit comments