|
| 1 | +""" |
| 2 | +Workflow-level failure handling in on_workflow_failed. |
| 3 | +
|
| 4 | +Fixes two defects without fabricating a synthetic task: |
| 5 | + 1. it could fire twice (link_error + chord on_error), double-sending failure |
| 6 | + emails -> idempotency guard; |
| 7 | + 2. it dumped raw celery args/kwargs into status_reason -> readable reason instead. |
| 8 | +
|
| 9 | +The failed request keeps zero tasks by design: the failure is explained on the |
| 10 | +request itself rather than by inventing a task that never ran. |
| 11 | +""" |
| 12 | + |
| 13 | +from unittest.mock import patch |
| 14 | + |
| 15 | +from django.test import TestCase |
| 16 | +from django.contrib.auth.models import User |
| 17 | + |
| 18 | +from apps.ifc_validation_models.models import ( |
| 19 | + set_user_context, |
| 20 | + ValidationRequest, |
| 21 | + ValidationTask, |
| 22 | +) |
| 23 | + |
| 24 | +from apps.ifc_validation.tasks.task_runner import ( |
| 25 | + on_workflow_failed, |
| 26 | + on_workflow_completed, |
| 27 | +) |
| 28 | + |
| 29 | + |
| 30 | +class OrphanFailedRequestElegantTestCase(TestCase): |
| 31 | + |
| 32 | + @staticmethod |
| 33 | + def _set_user_context(): |
| 34 | + user, _ = User.objects.get_or_create( |
| 35 | + id=1, defaults={'username': 'SYSTEM', 'is_active': True}, |
| 36 | + ) |
| 37 | + set_user_context(user) |
| 38 | + return user |
| 39 | + |
| 40 | + def _make_request(self): |
| 41 | + user = self._set_user_context() |
| 42 | + request = ValidationRequest.objects.create( |
| 43 | + file_name='orphan_file.ifc', file='orphan_file.ifc', size=123, |
| 44 | + ) |
| 45 | + request.mark_as_initiated() |
| 46 | + if request.created_by_id is None: |
| 47 | + request.created_by = user |
| 48 | + request.save() |
| 49 | + return request |
| 50 | + |
| 51 | + # --- the failure is now FAILED with a clean reason, and no raw arg dump --- |
| 52 | + @patch('apps.ifc_validation.tasks.task_runner.send_failure_admin_email_task') |
| 53 | + @patch('apps.ifc_validation.tasks.task_runner.send_failure_email_task') |
| 54 | + def test_orphan_failure_sets_clean_reason(self, mock_user_email, mock_admin_email): |
| 55 | + request = self._make_request() |
| 56 | + |
| 57 | + result = on_workflow_failed.apply(args=[None, request.id], kwargs={}) |
| 58 | + if result.failed(): |
| 59 | + self.fail(f"on_workflow_failed raised: {result.traceback}") |
| 60 | + |
| 61 | + request.refresh_from_db() |
| 62 | + |
| 63 | + self.assertEqual(request.status, ValidationRequest.Status.FAILED) |
| 64 | + self.assertTrue( |
| 65 | + request.status_reason.startswith("Validation failed before any check could run"), |
| 66 | + f"reason should be human-readable, got: {request.status_reason!r}", |
| 67 | + ) |
| 68 | + # No raw celery dump leaked into the user-visible reason. |
| 69 | + self.assertNotIn("args=", request.status_reason) |
| 70 | + self.assertNotIn("kwargs=", request.status_reason) |
| 71 | + # Option B intentionally does NOT create a synthetic task. |
| 72 | + self.assertEqual(request.tasks.count(), 0) |
| 73 | + mock_user_email.delay.assert_called_once() |
| 74 | + mock_admin_email.delay.assert_called_once() |
| 75 | + |
| 76 | + # --- firing twice no longer double-sends emails --- |
| 77 | + @patch('apps.ifc_validation.tasks.task_runner.send_failure_admin_email_task') |
| 78 | + @patch('apps.ifc_validation.tasks.task_runner.send_failure_email_task') |
| 79 | + def test_double_fire_is_idempotent(self, mock_user_email, mock_admin_email): |
| 80 | + request = self._make_request() |
| 81 | + |
| 82 | + first = on_workflow_failed.apply(args=[None, request.id], kwargs={}) |
| 83 | + second = on_workflow_failed.apply(args=[None, request.id], kwargs={}) |
| 84 | + for r in (first, second): |
| 85 | + if r.failed(): |
| 86 | + self.fail(f"on_workflow_failed raised: {r.traceback}") |
| 87 | + |
| 88 | + request.refresh_from_db() |
| 89 | + self.assertEqual(request.status, ValidationRequest.Status.FAILED) |
| 90 | + self.assertEqual(mock_user_email.delay.call_count, 1, |
| 91 | + "failure email must be sent exactly once across both firings") |
| 92 | + self.assertEqual(mock_admin_email.delay.call_count, 1, |
| 93 | + "admin failure email must be sent exactly once across both firings") |
| 94 | + |
| 95 | + # --- when an exception is available it is surfaced in the reason --- |
| 96 | + @patch('apps.ifc_validation.tasks.task_runner.send_failure_admin_email_task') |
| 97 | + @patch('apps.ifc_validation.tasks.task_runner.send_failure_email_task') |
| 98 | + def test_reason_includes_exception_when_present(self, mock_user_email, mock_admin_email): |
| 99 | + request = self._make_request() |
| 100 | + |
| 101 | + # error callbacks may carry the original exception; id is still args[1]. |
| 102 | + result = on_workflow_failed.apply(args=[ValueError("boom in syntax"), request.id], kwargs={}) |
| 103 | + if result.failed(): |
| 104 | + self.fail(f"on_workflow_failed raised: {result.traceback}") |
| 105 | + |
| 106 | + request.refresh_from_db() |
| 107 | + self.assertIn("boom in syntax", request.status_reason) |
| 108 | + |
| 109 | + # --- regression: the in-workflow failure path still has its relating task --- |
| 110 | + @patch('apps.ifc_validation.tasks.task_runner.send_failure_admin_email_task') |
| 111 | + @patch('apps.ifc_validation.tasks.task_runner.send_failure_email_task') |
| 112 | + def test_completed_failure_path_has_relating_task(self, mock_user_email, mock_admin_email): |
| 113 | + request = self._make_request() |
| 114 | + ValidationTask.objects.create( |
| 115 | + request=request, |
| 116 | + type=ValidationTask.Type.SYNTAX, |
| 117 | + status=ValidationTask.Status.FAILED, |
| 118 | + ) |
| 119 | + |
| 120 | + result = on_workflow_completed.apply(args=[None], kwargs={'id': request.id}) |
| 121 | + if result.failed(): |
| 122 | + self.fail(f"on_workflow_completed raised: {result.traceback}") |
| 123 | + |
| 124 | + request.refresh_from_db() |
| 125 | + self.assertEqual(request.status, ValidationRequest.Status.FAILED) |
| 126 | + self.assertEqual(request.tasks.count(), 1) |
| 127 | + mock_user_email.delay.assert_called_once() |
| 128 | + mock_admin_email.delay.assert_called_once() |
0 commit comments