firefighter-incident 0.0.13__py3-none-any.whl → 0.0.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. firefighter/_version.py +16 -3
  2. firefighter/api/serializers.py +8 -8
  3. firefighter/api/urls.py +8 -1
  4. firefighter/api/views/_base.py +1 -1
  5. firefighter/api/views/components.py +5 -5
  6. firefighter/api/views/incidents.py +9 -9
  7. firefighter/firefighter/settings/components/raid.py +3 -0
  8. firefighter/incidents/admin.py +24 -24
  9. firefighter/incidents/factories.py +14 -5
  10. firefighter/incidents/forms/close_incident.py +4 -4
  11. firefighter/incidents/forms/create_incident.py +4 -4
  12. firefighter/incidents/forms/update_status.py +4 -4
  13. firefighter/incidents/menus.py +2 -2
  14. firefighter/incidents/migrations/0005_enable_from_p1_to_p5_priority.py +7 -5
  15. firefighter/incidents/migrations/0009_update_sla.py +7 -5
  16. firefighter/incidents/migrations/0020_create_incident_category_model.py +64 -0
  17. firefighter/incidents/migrations/0021_copy_component_data_to_incident_category.py +57 -0
  18. firefighter/incidents/migrations/0022_add_incident_category_fields.py +34 -0
  19. firefighter/incidents/migrations/0023_populate_incident_category_references.py +57 -0
  20. firefighter/incidents/migrations/0024_remove_component_fields_and_model.py +26 -0
  21. firefighter/incidents/migrations/0025_make_incident_category_required.py +24 -0
  22. firefighter/incidents/migrations/0026_alter_incidentcategory_options_and_more.py +39 -0
  23. firefighter/incidents/models/__init__.py +1 -1
  24. firefighter/incidents/models/group.py +1 -1
  25. firefighter/incidents/models/incident.py +15 -15
  26. firefighter/incidents/models/{component.py → incident_category.py} +30 -29
  27. firefighter/incidents/models/incident_update.py +3 -3
  28. firefighter/incidents/tables.py +9 -9
  29. firefighter/incidents/templates/layouts/partials/incident_card.html +1 -1
  30. firefighter/incidents/templates/layouts/partials/incident_timeline.html +2 -2
  31. firefighter/incidents/templates/pages/{component_detail.html → incident_category_detail.html} +13 -13
  32. firefighter/incidents/templates/pages/{component_list.html → incident_category_list.html} +2 -2
  33. firefighter/incidents/templates/pages/incident_detail.html +3 -3
  34. firefighter/incidents/urls.py +6 -6
  35. firefighter/incidents/views/components/details.py +9 -9
  36. firefighter/incidents/views/components/list.py +9 -9
  37. firefighter/incidents/views/reports.py +2 -2
  38. firefighter/incidents/views/users/details.py +2 -2
  39. firefighter/incidents/views/views.py +7 -7
  40. firefighter/jira_app/client.py +1 -1
  41. firefighter/logging/custom_json_formatter.py +2 -1
  42. firefighter/pagerduty/tasks/trigger_oncall.py +1 -1
  43. firefighter/raid/admin.py +0 -11
  44. firefighter/raid/client.py +3 -3
  45. firefighter/raid/forms.py +53 -19
  46. firefighter/raid/migrations/0003_delete_raidarea.py +16 -0
  47. firefighter/raid/models.py +2 -21
  48. firefighter/raid/serializers.py +5 -4
  49. firefighter/raid/service.py +29 -27
  50. firefighter/raid/signals/incident_created.py +4 -2
  51. firefighter/raid/utils.py +1 -1
  52. firefighter/raid/views/__init__.py +1 -1
  53. firefighter/raid/views/open_normal.py +2 -2
  54. firefighter/slack/admin.py +8 -8
  55. firefighter/slack/management/commands/switch_test_users.py +272 -0
  56. firefighter/slack/messages/slack_messages.py +5 -5
  57. firefighter/slack/migrations/0005_add_incident_categories_fields.py +33 -0
  58. firefighter/slack/migrations/0006_copy_components_to_incident_categories.py +57 -0
  59. firefighter/slack/migrations/0007_remove_components_fields.py +22 -0
  60. firefighter/slack/migrations/0008_alter_conversation_incident_categories_and_more.py +33 -0
  61. firefighter/slack/models/conversation.py +3 -3
  62. firefighter/slack/models/incident_channel.py +1 -1
  63. firefighter/slack/models/user.py +1 -1
  64. firefighter/slack/models/user_group.py +3 -3
  65. firefighter/slack/rules.py +1 -1
  66. firefighter/slack/signals/get_users.py +2 -2
  67. firefighter/slack/signals/incident_updated.py +1 -1
  68. firefighter/slack/utils.py +2 -2
  69. firefighter/slack/views/events/home.py +2 -2
  70. firefighter/slack/views/modals/base_modal/form_utils.py +15 -0
  71. firefighter/slack/views/modals/close.py +3 -3
  72. firefighter/slack/views/modals/open.py +25 -1
  73. firefighter/slack/views/modals/opening/check_current_incidents.py +2 -2
  74. firefighter/slack/views/modals/opening/details/critical.py +1 -1
  75. firefighter/slack/views/modals/opening/select_impact.py +5 -2
  76. firefighter/slack/views/modals/update_status.py +4 -4
  77. firefighter_fixtures/incidents/{components.json → incident_categories.json} +52 -52
  78. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.14.dist-info}/METADATA +2 -2
  79. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.14.dist-info}/RECORD +98 -77
  80. firefighter_tests/conftest.py +4 -5
  81. firefighter_tests/test_api/test_api_landbot.py +1 -1
  82. firefighter_tests/test_firefighter/test_sso.py +146 -0
  83. firefighter_tests/test_incidents/test_forms/test_form_utils.py +15 -15
  84. firefighter_tests/test_incidents/test_incident_urls.py +3 -3
  85. firefighter_tests/test_incidents/test_models/test_incident_category.py +165 -0
  86. firefighter_tests/test_incidents/test_models/test_incident_model.py +2 -2
  87. firefighter_tests/test_raid/test_priority_mapping.py +267 -0
  88. firefighter_tests/test_raid/test_raid_client.py +580 -0
  89. firefighter_tests/test_raid/test_raid_forms.py +795 -0
  90. firefighter_tests/test_raid/test_raid_models.py +185 -0
  91. firefighter_tests/test_raid/test_raid_serializers.py +507 -0
  92. firefighter_tests/test_raid/test_raid_service.py +442 -0
  93. firefighter_tests/test_raid/test_raid_views.py +196 -0
  94. firefighter_tests/test_slack/views/modals/test_close.py +6 -6
  95. firefighter_tests/test_slack/views/modals/test_update_status.py +4 -4
  96. firefighter_fixtures/raid/area.json +0 -1
  97. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.14.dist-info}/WHEEL +0 -0
  98. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.14.dist-info}/entry_points.txt +0 -0
  99. {firefighter_incident-0.0.13.dist-info → firefighter_incident-0.0.14.dist-info}/licenses/LICENSE +0 -0
@@ -6,8 +6,8 @@ from django.forms import Form
6
6
 
7
7
  from firefighter.incidents.enums import IncidentStatus
8
8
  from firefighter.incidents.forms.utils import EnumChoiceField, GroupedModelChoiceField
9
- from firefighter.incidents.models.component import Component
10
9
  from firefighter.incidents.models.group import Group
10
+ from firefighter.incidents.models.incident_category import IncidentCategory
11
11
 
12
12
 
13
13
  class EnumChoiceFieldForm(Form):
@@ -15,8 +15,8 @@ class EnumChoiceFieldForm(Form):
15
15
 
16
16
 
17
17
  class GroupedModelChoiceFieldForm(Form):
18
- component = GroupedModelChoiceField(
19
- choices_groupby="group", queryset=Component.objects.all()
18
+ incident_category = GroupedModelChoiceField(
19
+ choices_groupby="group", queryset=IncidentCategory.objects.all()
20
20
  )
21
21
 
22
22
 
@@ -26,10 +26,10 @@ def group() -> Group:
26
26
 
27
27
 
28
28
  @pytest.fixture
29
- def components(group: Group):
29
+ def incident_categories(group: Group):
30
30
  return [
31
- Component.objects.create(name="Issue category 1", group=group, order=1),
32
- Component.objects.create(name="Issue category 2", group=group, order=2),
31
+ IncidentCategory.objects.create(name="Issue category 1", group=group, order=1),
32
+ IncidentCategory.objects.create(name="Issue category 2", group=group, order=2),
33
33
  ]
34
34
 
35
35
 
@@ -47,27 +47,27 @@ def test_enum_choice_field_invalid() -> None:
47
47
 
48
48
 
49
49
  @pytest.mark.django_db
50
- def test_grouped_model_choice_field_valid(components: list[Component]):
51
- form = GroupedModelChoiceFieldForm({"component": components[0].id})
50
+ def test_grouped_model_choice_field_valid(incident_categories: list[IncidentCategory]):
51
+ form = GroupedModelChoiceFieldForm({"incident_category": incident_categories[0].id})
52
52
  assert form.is_valid()
53
- assert form.cleaned_data["component"] == components[0]
53
+ assert form.cleaned_data["incident_category"] == incident_categories[0]
54
54
 
55
55
 
56
56
  def test_grouped_model_choice_field_invalid() -> None:
57
- form = GroupedModelChoiceFieldForm({"component": "non-existent-id"})
57
+ form = GroupedModelChoiceFieldForm({"incident_category": "non-existent-id"})
58
58
  assert not form.is_valid()
59
- assert "component" in form.errors
59
+ assert "incident_category" in form.errors
60
60
 
61
61
 
62
62
  @pytest.fixture(scope="module")
63
63
  def test_grouped_model_choice_field_grouping(
64
- components: list[Component],
64
+ incident_categories: list[IncidentCategory],
65
65
  ):
66
66
  form = GroupedModelChoiceFieldForm()
67
67
  grouped_choices = list(
68
- form.fields["component"].iterator(field=form.fields["component"])
68
+ form.fields["incident_category"].iterator(field=form.fields["incident_category"])
69
69
  )
70
70
  assert len(grouped_choices) == 2 # One for the empty choice, and one for the group
71
- assert grouped_choices[0] == ("", form.fields["component"].empty_label)
72
- assert grouped_choices[1][0] == components[0].group
71
+ assert grouped_choices[0] == ("", form.fields["incident_category"].empty_label)
72
+ assert grouped_choices[1][0] == incident_categories[0].group
73
73
  assert len(grouped_choices[1][1]) == 2 # Two components in the group
@@ -100,9 +100,9 @@ def test_incident_create_unauthorized(client: Client) -> None:
100
100
 
101
101
  @pytest.mark.django_db
102
102
  @pytest.mark.usefixtures("_debug")
103
- def test_component_list(client: Client) -> None:
104
- """This test ensures that the component list is accessible."""
105
- response = client.get(reverse("incidents:component-list"))
103
+ def test_incident_category_list(client: Client) -> None:
104
+ """This test ensures that the incident category list is accessible."""
105
+ response = client.get(reverse("incidents:incident-category-list"))
106
106
 
107
107
  assert response.status_code == 302
108
108
 
@@ -0,0 +1,165 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import timedelta
4
+
5
+ import pytest
6
+ from django.utils import timezone
7
+
8
+ from firefighter.incidents.factories import GroupFactory, IncidentCategoryFactory
9
+ from firefighter.incidents.models.incident_category import (
10
+ IncidentCategory,
11
+ IncidentCategoryFilterSet,
12
+ IncidentCategoryManager,
13
+ )
14
+
15
+
16
+ @pytest.mark.django_db
17
+ class TestIncidentCategoryManager:
18
+ def test_queryset_with_mtbf_date_to_in_future(self):
19
+ """Test that date_to is clamped to now() when it's in the future."""
20
+ # Given
21
+ IncidentCategoryFactory()
22
+ manager = IncidentCategoryManager()
23
+ manager.model = IncidentCategory
24
+
25
+ # Create dates where date_to is in the future
26
+ now = timezone.now()
27
+ date_from = now - timedelta(days=10)
28
+ date_to = now + timedelta(days=5) # Future date
29
+
30
+ # When
31
+ result = manager.queryset_with_mtbf(date_from, date_to)
32
+
33
+ # Then
34
+ assert result is not None
35
+ assert list(result) == list(result) # Should not fail to execute
36
+
37
+ def test_queryset_with_mtbf_with_custom_queryset(self):
38
+ """Test that custom queryset parameter is used."""
39
+ # Given
40
+ category1 = IncidentCategoryFactory()
41
+ category2 = IncidentCategoryFactory()
42
+
43
+ manager = IncidentCategoryManager()
44
+ manager.model = IncidentCategory
45
+
46
+ # Create a custom queryset that filters only category1
47
+ custom_queryset = IncidentCategory.objects.filter(id=category1.id)
48
+
49
+ date_from = timezone.now() - timedelta(days=10)
50
+ date_to = timezone.now() - timedelta(days=1)
51
+
52
+ # When
53
+ result = manager.queryset_with_mtbf(date_from, date_to, queryset=custom_queryset)
54
+
55
+ # Then
56
+ assert category1 in result
57
+ assert category2 not in result
58
+
59
+ def test_search_with_none_queryset(self):
60
+ """Test search method when queryset is None."""
61
+ # Given
62
+ category = IncidentCategoryFactory(name="Test Category")
63
+
64
+ # When
65
+ result, is_empty = IncidentCategoryManager.search(None, "Test")
66
+
67
+ # Then
68
+ assert is_empty is False
69
+ assert category in result
70
+
71
+ def test_search_with_empty_search_term(self):
72
+ """Test search method with empty/None search term."""
73
+ # Given
74
+ IncidentCategoryFactory(name="Category One")
75
+ IncidentCategoryFactory(name="Category Two")
76
+ queryset = IncidentCategory.objects.all()
77
+
78
+ # When - Test with None
79
+ result_none, is_empty_none = IncidentCategoryManager.search(queryset, None)
80
+
81
+ # When - Test with empty string
82
+ result_empty, is_empty_empty = IncidentCategoryManager.search(queryset, "")
83
+
84
+ # When - Test with whitespace
85
+ result_spaces, is_empty_spaces = IncidentCategoryManager.search(queryset, " ")
86
+
87
+ # Then - All should return the original queryset
88
+ assert is_empty_none is False
89
+ assert is_empty_empty is False
90
+ assert is_empty_spaces is False
91
+
92
+ original_ids = set(queryset.values_list("id", flat=True))
93
+ assert set(result_none.values_list("id", flat=True)) == original_ids
94
+ assert set(result_empty.values_list("id", flat=True)) == original_ids
95
+ assert set(result_spaces.values_list("id", flat=True)) == original_ids
96
+
97
+ def test_search_with_valid_search_term(self):
98
+ """Test search method with valid search term."""
99
+ # Given
100
+ group = GroupFactory(name="Test Group", description="Group for testing")
101
+ category = IncidentCategoryFactory(
102
+ name="Infrastructure Issue",
103
+ description="Issues with infrastructure",
104
+ group=group
105
+ )
106
+ other_category = IncidentCategoryFactory(
107
+ name="Database Problem",
108
+ description="Database related issues"
109
+ )
110
+
111
+ # When - Search for "infrastructure"
112
+ result, is_empty = IncidentCategoryManager.search(None, "infrastructure")
113
+
114
+ # Then
115
+ assert is_empty is False
116
+ result_list = list(result)
117
+ assert category in result_list
118
+ # Note: other_category might also appear if search is broad
119
+
120
+ # When - Search for "database"
121
+ result_db, is_empty_db = IncidentCategoryManager.search(None, "database")
122
+
123
+ # Then
124
+ assert is_empty_db is False
125
+ result_db_list = list(result_db)
126
+ assert other_category in result_db_list
127
+
128
+
129
+ @pytest.mark.django_db
130
+ class TestIncidentCategoryFilterSet:
131
+ def test_incident_category_search(self):
132
+ """Test the incident_category_search filter method."""
133
+ # Given
134
+ category = IncidentCategoryFactory(name="Network Issues")
135
+ queryset = IncidentCategory.objects.all()
136
+
137
+ # When
138
+ result = IncidentCategoryFilterSet.incident_category_search(queryset, "search", "network")
139
+
140
+ # Then
141
+ assert category in result
142
+
143
+ def test_metrics_period_filter(self):
144
+ """Test the metrics period filter method."""
145
+ # Given
146
+ category = IncidentCategoryFactory()
147
+ queryset = IncidentCategory.objects.all()
148
+
149
+ # Create date range
150
+ now = timezone.now()
151
+ date_from = now - timedelta(days=30)
152
+ date_to = now
153
+ value = (date_from, date_to, None, None)
154
+
155
+ # When
156
+ result = IncidentCategoryFilterSet.metrics_period_filter(queryset, "metrics", value)
157
+
158
+ # Then
159
+ # Should return a queryset with mtbf annotation
160
+ assert result is not None
161
+ assert category in result
162
+
163
+ # Check that the mtbf annotation is present (will be None if no metrics)
164
+ category_with_mtbf = result.get(id=category.id)
165
+ assert hasattr(category_with_mtbf, "mtbf")
@@ -20,8 +20,8 @@ class TestIncident(django.TestCase):
20
20
  @given(builds(IncidentFactory.build))
21
21
  def test_model_properties(self, instance: Incident) -> None:
22
22
  """Tests that instance can be saved and has correct representation."""
23
- instance.component.group.save()
24
- instance.component.save()
23
+ instance.incident_category.group.save()
24
+ instance.incident_category.save()
25
25
  instance.environment.save()
26
26
  instance.priority.save()
27
27
  instance.created_by.save()
@@ -0,0 +1,267 @@
1
+ """Test priority mapping from Impact to JIRA for all priority values including P5."""
2
+ from __future__ import annotations
3
+
4
+ from unittest.mock import Mock, patch
5
+
6
+ import pytest
7
+ from django.test import TestCase
8
+
9
+ from firefighter.incidents.factories import (
10
+ IncidentFactory,
11
+ UserFactory,
12
+ )
13
+ from firefighter.incidents.models.priority import Priority
14
+ from firefighter.jira_app.client import JiraAPIError, JiraUserNotFoundError
15
+ from firefighter.jira_app.models import JiraUser
16
+ from firefighter.raid.signals.incident_created import create_ticket
17
+ from firefighter.slack.factories import IncidentChannelFactory
18
+
19
+
20
+ class TestPriorityMapping(TestCase):
21
+ """Test priority mapping from Impact to JIRA including P5 support."""
22
+
23
+ def setUp(self):
24
+ """Set up test data."""
25
+ self.user = UserFactory()
26
+
27
+ # Create or get priorities P1-P5 (use get_or_create to avoid duplicates)
28
+ self.priority_p1, _ = Priority.objects.get_or_create(
29
+ value=1, defaults={"name": "Critical", "order": 1}
30
+ )
31
+ self.priority_p2, _ = Priority.objects.get_or_create(
32
+ value=2, defaults={"name": "High", "order": 2}
33
+ )
34
+ self.priority_p3, _ = Priority.objects.get_or_create(
35
+ value=3, defaults={"name": "Medium", "order": 3}
36
+ )
37
+ self.priority_p4, _ = Priority.objects.get_or_create(
38
+ value=4, defaults={"name": "Low", "order": 4}
39
+ )
40
+ self.priority_p5, _ = Priority.objects.get_or_create(
41
+ value=5, defaults={"name": "Lowest", "order": 5}
42
+ )
43
+
44
+ @patch("firefighter.raid.signals.incident_created.client")
45
+ @patch("firefighter.raid.signals.incident_created.get_jira_user_from_user")
46
+ def test_priority_p1_mapping(self, mock_get_jira_user, mock_client):
47
+ """Test P1 priority mapping to JIRA."""
48
+ self._test_priority_mapping(self.priority_p1, 1, mock_get_jira_user, mock_client)
49
+
50
+ @patch("firefighter.raid.signals.incident_created.client")
51
+ @patch("firefighter.raid.signals.incident_created.get_jira_user_from_user")
52
+ def test_priority_p2_mapping(self, mock_get_jira_user, mock_client):
53
+ """Test P2 priority mapping to JIRA."""
54
+ self._test_priority_mapping(self.priority_p2, 2, mock_get_jira_user, mock_client)
55
+
56
+ @patch("firefighter.raid.signals.incident_created.client")
57
+ @patch("firefighter.raid.signals.incident_created.get_jira_user_from_user")
58
+ def test_priority_p3_mapping(self, mock_get_jira_user, mock_client):
59
+ """Test P3 priority mapping to JIRA."""
60
+ self._test_priority_mapping(self.priority_p3, 3, mock_get_jira_user, mock_client)
61
+
62
+ @patch("firefighter.raid.signals.incident_created.client")
63
+ @patch("firefighter.raid.signals.incident_created.get_jira_user_from_user")
64
+ def test_priority_p4_mapping(self, mock_get_jira_user, mock_client):
65
+ """Test P4 priority mapping to JIRA."""
66
+ self._test_priority_mapping(self.priority_p4, 4, mock_get_jira_user, mock_client)
67
+
68
+ @patch("firefighter.raid.signals.incident_created.client")
69
+ @patch("firefighter.raid.signals.incident_created.get_jira_user_from_user")
70
+ def test_priority_p5_mapping(self, mock_get_jira_user, mock_client):
71
+ """Test P5 priority mapping to JIRA - this should now work with our fix."""
72
+ self._test_priority_mapping(self.priority_p5, 5, mock_get_jira_user, mock_client)
73
+
74
+ @patch("firefighter.raid.signals.incident_created.client")
75
+ @patch("firefighter.raid.signals.incident_created.get_jira_user_from_user")
76
+ def test_priority_invalid_value_fallback(self, mock_get_jira_user, mock_client):
77
+ """Test that invalid priority values fall back to P1."""
78
+ # Create a priority with an invalid value (>5)
79
+ invalid_priority, _ = Priority.objects.get_or_create(
80
+ value=6, defaults={"name": "Invalid", "order": 6}
81
+ )
82
+ self._test_priority_mapping(invalid_priority, 1, mock_get_jira_user, mock_client) # Should fallback to 1
83
+
84
+ def _test_priority_mapping(self, priority: Priority, expected_jira_priority: int, mock_get_jira_user, mock_client):
85
+ """Helper method to test priority mapping."""
86
+ # Create a real JiraUser for testing
87
+ jira_user = JiraUser.objects.create(id="test-user-id", user=self.user)
88
+ mock_get_jira_user.return_value = jira_user
89
+
90
+ # Mock the create_issue return value (exclude watchers as it's a ManyToMany field)
91
+ mock_client.create_issue.return_value = {
92
+ "id": "123456",
93
+ "key": "TEST-123",
94
+ "assignee": None,
95
+ "reporter": jira_user, # Return the actual JiraUser object
96
+ "issue_type": "Incident",
97
+ "project_key": "INCIDENT",
98
+ "description": "Test incident",
99
+ "summary": "Test Incident"
100
+ }
101
+ mock_client.get_jira_user_from_jira_id.return_value = jira_user
102
+ mock_client.jira.add_watcher = Mock()
103
+ mock_client.jira.remove_watcher = Mock()
104
+ mock_client.jira.add_simple_link = Mock()
105
+
106
+ # Create incident with the specific priority
107
+ incident = IncidentFactory(
108
+ priority=priority,
109
+ created_by=self.user,
110
+ title="Test Incident",
111
+ description="Test incident description"
112
+ )
113
+
114
+ # Create incident channel
115
+ channel = IncidentChannelFactory(incident=incident)
116
+ channel.add_bookmark = Mock()
117
+ channel.send_message_and_save = Mock()
118
+
119
+ # Call the create_ticket function
120
+ create_ticket(sender=None, incident=incident, channel=channel)
121
+
122
+ # Verify that create_issue was called with the expected priority
123
+ mock_client.create_issue.assert_called_once()
124
+ call_kwargs = mock_client.create_issue.call_args[1]
125
+
126
+ assert call_kwargs["priority"] == expected_jira_priority
127
+ assert call_kwargs["issuetype"] == "Incident"
128
+ assert call_kwargs["summary"] == "Test Incident"
129
+
130
+ @patch("firefighter.raid.signals.incident_created.client")
131
+ @patch("firefighter.raid.signals.incident_created.get_jira_user_from_user")
132
+ def test_create_ticket_no_issue_id_error(self, mock_get_jira_user, mock_client):
133
+ """Test error handling when create_issue returns no ID."""
134
+ test_user = UserFactory()
135
+ jira_user = JiraUser.objects.create(id="test-user-no-id", user=test_user)
136
+ mock_get_jira_user.return_value = jira_user
137
+
138
+ # Mock create_issue to return None ID (error case)
139
+ mock_client.create_issue.return_value = {
140
+ "id": None, # This should trigger the error
141
+ "key": "TEST-123",
142
+ "summary": "Test Incident"
143
+ }
144
+
145
+ incident = IncidentFactory(
146
+ priority=self.priority_p1,
147
+ created_by=test_user,
148
+ title="Test Incident"
149
+ )
150
+ channel = IncidentChannelFactory(incident=incident)
151
+
152
+ # Should raise JiraAPIError
153
+ with pytest.raises(JiraAPIError):
154
+ create_ticket(sender=None, incident=incident, channel=channel)
155
+
156
+ @patch("firefighter.raid.signals.incident_created.client")
157
+ @patch("firefighter.raid.signals.incident_created.get_jira_user_from_user")
158
+ def test_create_ticket_jira_user_not_found_error(self, mock_get_jira_user, mock_client):
159
+ """Test error handling when default JIRA user is not found."""
160
+ test_user = UserFactory()
161
+ jira_user = JiraUser.objects.create(id="test-user-not-found", user=test_user)
162
+ mock_get_jira_user.return_value = jira_user
163
+
164
+ mock_client.create_issue.return_value = {
165
+ "id": "123456",
166
+ "key": "TEST-123",
167
+ "reporter": jira_user,
168
+ "summary": "Test Incident"
169
+ }
170
+
171
+ # Mock get_jira_user_from_jira_id to raise JiraUserNotFoundError
172
+ mock_client.get_jira_user_from_jira_id.side_effect = JiraUserNotFoundError("User not found")
173
+ mock_client.jira.add_watcher = Mock()
174
+ mock_client.jira.remove_watcher = Mock()
175
+ mock_client.jira.add_simple_link = Mock()
176
+
177
+ incident = IncidentFactory(
178
+ priority=self.priority_p1,
179
+ created_by=test_user,
180
+ title="Test Incident"
181
+ )
182
+ channel = IncidentChannelFactory(incident=incident)
183
+ channel.add_bookmark = Mock()
184
+ channel.send_message_and_save = Mock()
185
+
186
+ # Should complete without error despite the exception
187
+ create_ticket(sender=None, incident=incident, channel=channel)
188
+
189
+ # Verify the ticket was still created
190
+ mock_client.create_issue.assert_called_once()
191
+
192
+ @patch("firefighter.raid.signals.incident_created.client")
193
+ @patch("firefighter.raid.signals.incident_created.get_jira_user_from_user")
194
+ def test_create_ticket_add_watcher_error(self, mock_get_jira_user, mock_client):
195
+ """Test error handling when adding watcher fails."""
196
+ test_user = UserFactory()
197
+ default_user = UserFactory()
198
+ jira_user = JiraUser.objects.create(id="test-user-add-watcher", user=test_user)
199
+ default_jira_user = JiraUser.objects.create(id="default-user-add", user=default_user)
200
+ mock_get_jira_user.return_value = jira_user
201
+
202
+ mock_client.create_issue.return_value = {
203
+ "id": "123456",
204
+ "key": "TEST-123",
205
+ "reporter": jira_user,
206
+ "summary": "Test Incident"
207
+ }
208
+ mock_client.get_jira_user_from_jira_id.return_value = default_jira_user
209
+
210
+ # Mock add_watcher to raise JiraAPIError
211
+ mock_client.jira.add_watcher.side_effect = JiraAPIError("Cannot add watcher")
212
+ mock_client.jira.remove_watcher = Mock()
213
+ mock_client.jira.add_simple_link = Mock()
214
+
215
+ incident = IncidentFactory(
216
+ priority=self.priority_p1,
217
+ created_by=test_user,
218
+ title="Test Incident"
219
+ )
220
+ channel = IncidentChannelFactory(incident=incident)
221
+ channel.add_bookmark = Mock()
222
+ channel.send_message_and_save = Mock()
223
+
224
+ # Should complete without error despite the exception
225
+ create_ticket(sender=None, incident=incident, channel=channel)
226
+
227
+ # Verify remove_watcher was called as fallback
228
+ mock_client.jira.remove_watcher.assert_called_once()
229
+
230
+ @patch("firefighter.raid.signals.incident_created.client")
231
+ @patch("firefighter.raid.signals.incident_created.get_jira_user_from_user")
232
+ def test_create_ticket_remove_watcher_error(self, mock_get_jira_user, mock_client):
233
+ """Test error handling when removing default watcher fails."""
234
+ test_user = UserFactory()
235
+ default_user = UserFactory()
236
+ jira_user = JiraUser.objects.create(id="test-user-remove-watcher", user=test_user)
237
+ default_jira_user = JiraUser.objects.create(id="default-user-remove", user=default_user)
238
+ mock_get_jira_user.return_value = jira_user
239
+
240
+ mock_client.create_issue.return_value = {
241
+ "id": "123456",
242
+ "key": "TEST-123",
243
+ "reporter": jira_user,
244
+ "summary": "Test Incident"
245
+ }
246
+ mock_client.get_jira_user_from_jira_id.return_value = default_jira_user
247
+
248
+ # Mock both add_watcher and remove_watcher to raise JiraAPIError
249
+ mock_client.jira.add_watcher.side_effect = JiraAPIError("Cannot add watcher")
250
+ mock_client.jira.remove_watcher.side_effect = JiraAPIError("Cannot remove watcher")
251
+ mock_client.jira.add_simple_link = Mock()
252
+
253
+ incident = IncidentFactory(
254
+ priority=self.priority_p1,
255
+ created_by=test_user,
256
+ title="Test Incident"
257
+ )
258
+ channel = IncidentChannelFactory(incident=incident)
259
+ channel.add_bookmark = Mock()
260
+ channel.send_message_and_save = Mock()
261
+
262
+ # Should complete without error despite both exceptions
263
+ create_ticket(sender=None, incident=incident, channel=channel)
264
+
265
+ # Verify both operations were attempted
266
+ mock_client.jira.add_watcher.assert_called_once()
267
+ mock_client.jira.remove_watcher.assert_called_once()