firefighter-incident 0.0.14__py3-none-any.whl → 0.0.15__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 (63) hide show
  1. firefighter/_version.py +2 -2
  2. firefighter/api/serializers.py +9 -0
  3. firefighter/confluence/signals/incident_updated.py +2 -2
  4. firefighter/incidents/enums.py +22 -2
  5. firefighter/incidents/forms/closure_reason.py +45 -0
  6. firefighter/incidents/forms/unified_incident.py +406 -0
  7. firefighter/incidents/forms/update_status.py +87 -1
  8. firefighter/incidents/migrations/0027_add_closure_fields.py +40 -0
  9. firefighter/incidents/migrations/0028_add_closure_reason_constraint.py +33 -0
  10. firefighter/incidents/migrations/0029_add_custom_fields_to_incident.py +22 -0
  11. firefighter/incidents/models/incident.py +32 -5
  12. firefighter/incidents/static/css/main.min.css +1 -1
  13. firefighter/incidents/templates/layouts/partials/status_pill.html +1 -1
  14. firefighter/incidents/views/reports.py +3 -3
  15. firefighter/raid/apps.py +9 -26
  16. firefighter/raid/client.py +2 -2
  17. firefighter/raid/forms.py +75 -238
  18. firefighter/raid/signals/incident_created.py +38 -13
  19. firefighter/raid/signals/incident_updated.py +3 -2
  20. firefighter/slack/messages/slack_messages.py +19 -4
  21. firefighter/slack/rules.py +1 -1
  22. firefighter/slack/signals/create_incident_conversation.py +6 -0
  23. firefighter/slack/signals/incident_updated.py +7 -1
  24. firefighter/slack/views/modals/__init__.py +4 -0
  25. firefighter/slack/views/modals/base_modal/form_utils.py +63 -0
  26. firefighter/slack/views/modals/close.py +15 -2
  27. firefighter/slack/views/modals/closure_reason.py +193 -0
  28. firefighter/slack/views/modals/open.py +59 -12
  29. firefighter/slack/views/modals/opening/details/unified.py +203 -0
  30. firefighter/slack/views/modals/opening/set_details.py +3 -2
  31. firefighter/slack/views/modals/postmortem.py +10 -2
  32. firefighter/slack/views/modals/update_status.py +28 -2
  33. firefighter/slack/views/modals/utils.py +51 -0
  34. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/METADATA +1 -1
  35. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/RECORD +61 -37
  36. firefighter_tests/test_incidents/test_enums.py +100 -0
  37. firefighter_tests/test_incidents/test_forms/conftest.py +179 -0
  38. firefighter_tests/test_incidents/test_forms/test_closure_reason.py +91 -0
  39. firefighter_tests/test_incidents/test_forms/test_unified_incident_form.py +570 -0
  40. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +581 -0
  41. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +410 -0
  42. firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +343 -0
  43. firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +167 -0
  44. firefighter_tests/test_incidents/test_models/test_incident_model.py +68 -0
  45. firefighter_tests/test_raid/conftest.py +154 -0
  46. firefighter_tests/test_raid/test_p1_p3_jira_fields.py +372 -0
  47. firefighter_tests/test_raid/test_raid_forms.py +10 -253
  48. firefighter_tests/test_raid/test_raid_signals.py +187 -0
  49. firefighter_tests/test_slack/messages/__init__.py +0 -0
  50. firefighter_tests/test_slack/messages/test_slack_messages.py +367 -0
  51. firefighter_tests/test_slack/views/modals/conftest.py +140 -0
  52. firefighter_tests/test_slack/views/modals/test_close.py +65 -3
  53. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +138 -0
  54. firefighter_tests/test_slack/views/modals/test_form_utils_multiple_choice.py +249 -0
  55. firefighter_tests/test_slack/views/modals/test_open.py +146 -2
  56. firefighter_tests/test_slack/views/modals/test_opening_unified.py +421 -0
  57. firefighter_tests/test_slack/views/modals/test_update_status.py +327 -3
  58. firefighter_tests/test_slack/views/modals/test_utils.py +135 -0
  59. firefighter/raid/views/open_normal.py +0 -139
  60. firefighter/slack/views/modals/opening/details/critical.py +0 -88
  61. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/WHEEL +0 -0
  62. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/entry_points.txt +0 -0
  63. {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,343 @@
1
+ """Test the status workflow logic for update status form."""
2
+ from __future__ import annotations
3
+
4
+ import pytest
5
+ from django.apps import apps
6
+ from django.test import TestCase
7
+
8
+ from firefighter.incidents.enums import IncidentStatus
9
+ from firefighter.incidents.factories import IncidentFactory
10
+ from firefighter.incidents.forms.update_status import UpdateStatusForm
11
+ from firefighter.incidents.models import Environment, Priority
12
+
13
+
14
+ @pytest.mark.django_db
15
+ class TestUpdateStatusWorkflow(TestCase):
16
+ """Test that status choices respect workflow rules."""
17
+
18
+ def test_p1_p2_cannot_skip_postmortem(self):
19
+ """P1/P2 incidents cannot go directly from Mitigated to Closed."""
20
+
21
+ # Get existing P1 or P2 priority that needs post-mortem
22
+ p1_priority = Priority.objects.filter(
23
+ value=1, needs_postmortem=True
24
+ ).first()
25
+
26
+ if not p1_priority:
27
+ # If P1 doesn't exist, try P2
28
+ p1_priority = Priority.objects.filter(
29
+ value=2, needs_postmortem=True
30
+ ).first()
31
+
32
+ if not p1_priority:
33
+ # Skip test if no priority needing postmortem exists
34
+ pytest.skip("No P1/P2 priority with needs_postmortem=True found in database")
35
+
36
+ # Get PRD environment (required for needs_postmortem to be True)
37
+ prd_env = Environment.objects.filter(value="PRD").first()
38
+ if not prd_env:
39
+ pytest.skip("No PRD environment found in database")
40
+
41
+ # Create an incident with P1/P2 priority in Mitigated status with PRD env
42
+ incident = IncidentFactory.create(
43
+ priority=p1_priority,
44
+ environment=prd_env,
45
+ _status=IncidentStatus.MITIGATED,
46
+ )
47
+
48
+ # Note: incident.needs_postmortem also requires firefighter.confluence to be installed
49
+ # For testing, we'll check the underlying conditions directly
50
+ if apps.is_installed("firefighter.confluence"):
51
+ assert incident.needs_postmortem, "Incident should need postmortem (P1/P2 + PRD)"
52
+
53
+ # Create form with the incident
54
+ form = UpdateStatusForm(incident=incident)
55
+
56
+ # Check that CLOSED is not in the choices
57
+ status_choices = dict(form.fields["status"].choices)
58
+ assert IncidentStatus.CLOSED not in status_choices
59
+ assert IncidentStatus.POST_MORTEM in status_choices
60
+
61
+ def test_p1_p2_can_close_from_postmortem(self):
62
+ """P1/P2 incidents can go from Post-mortem to Closed."""
63
+
64
+ # Get existing P1 or P2 priority that needs post-mortem
65
+ p2_priority = Priority.objects.filter(
66
+ value__in=[1, 2], needs_postmortem=True
67
+ ).first()
68
+
69
+ if not p2_priority:
70
+ pytest.skip("No P1/P2 priority with needs_postmortem=True found in database")
71
+
72
+ # Get PRD environment
73
+ prd_env = Environment.objects.filter(value="PRD").first()
74
+ if not prd_env:
75
+ pytest.skip("No PRD environment found in database")
76
+
77
+ # Create an incident with P1/P2 priority in Post-mortem status with PRD env
78
+ incident = IncidentFactory.create(
79
+ priority=p2_priority,
80
+ environment=prd_env,
81
+ _status=IncidentStatus.POST_MORTEM,
82
+ )
83
+
84
+ # Create form with the incident
85
+ form = UpdateStatusForm(incident=incident)
86
+
87
+ # Check that CLOSED is in the choices
88
+ status_choices = dict(form.fields["status"].choices)
89
+ assert IncidentStatus.CLOSED in status_choices
90
+
91
+ def test_p3_plus_can_skip_postmortem(self):
92
+ """P3+ incidents can go directly from Mitigated to Closed and should not have post-mortem."""
93
+ # Get existing P3+ priority that doesn't need post-mortem
94
+ p3_priority = Priority.objects.filter(
95
+ value__gte=3, needs_postmortem=False
96
+ ).first()
97
+
98
+ if not p3_priority:
99
+ # If no P3+ without postmortem, skip the test
100
+ pytest.skip("No P3+ priority with needs_postmortem=False found in database")
101
+
102
+ # Create an incident with P3+ priority in Mitigated status
103
+ incident = IncidentFactory.create(
104
+ priority=p3_priority,
105
+ _status=IncidentStatus.MITIGATED,
106
+ )
107
+
108
+ # Create form with the incident
109
+ form = UpdateStatusForm(incident=incident)
110
+
111
+ # Check that CLOSED is in the choices for P3+ but POST_MORTEM is NOT
112
+ status_choices = dict(form.fields["status"].choices)
113
+ assert IncidentStatus.CLOSED in status_choices
114
+ assert IncidentStatus.POST_MORTEM not in status_choices, "P3+ incidents should not have post-mortem status available"
115
+
116
+ def test_p1_p2_cannot_skip_postmortem_from_mitigating(self):
117
+ """P1/P2 incidents cannot go directly from Mitigating to Closed."""
118
+
119
+ # Get existing P1 or P2 priority that needs post-mortem
120
+ p1_priority = Priority.objects.filter(
121
+ value__in=[1, 2], needs_postmortem=True
122
+ ).first()
123
+
124
+ if not p1_priority:
125
+ pytest.skip("No P1/P2 priority with needs_postmortem=True found in database")
126
+
127
+ # Get PRD environment (required for needs_postmortem to be True)
128
+ prd_env = Environment.objects.filter(value="PRD").first()
129
+ if not prd_env:
130
+ pytest.skip("No PRD environment found in database")
131
+
132
+ # Create an incident with P1/P2 priority in MITIGATING (Mitigating) status with PRD env
133
+ incident = IncidentFactory.create(
134
+ priority=p1_priority,
135
+ environment=prd_env,
136
+ _status=IncidentStatus.MITIGATING, # Test from MITIGATING (Mitigating), not MITIGATED
137
+ )
138
+
139
+ # Create form with the incident
140
+ form = UpdateStatusForm(incident=incident)
141
+
142
+ # Check that CLOSED and POST_MORTEM are not in the choices from MITIGATING status
143
+ status_choices = dict(form.fields["status"].choices)
144
+ assert IncidentStatus.CLOSED not in status_choices
145
+ assert IncidentStatus.POST_MORTEM not in status_choices, "P1/P2 should not be able to go to post-mortem from Mitigating"
146
+
147
+ def test_p3_can_skip_from_investigating_to_closed(self):
148
+ """P3+ incidents can go directly from Investigating to Closed."""
149
+ # Get existing P3+ priority that doesn't need post-mortem
150
+ p3_priority = Priority.objects.filter(
151
+ value__gte=3, needs_postmortem=False
152
+ ).first()
153
+
154
+ if not p3_priority:
155
+ pytest.skip("No P3+ priority with needs_postmortem=False found in database")
156
+
157
+ # Create an incident with P3+ priority in INVESTIGATING status
158
+ incident = IncidentFactory.create(
159
+ priority=p3_priority,
160
+ _status=IncidentStatus.INVESTIGATING, # Test from INVESTIGATING
161
+ )
162
+
163
+ # Create form with the incident
164
+ form = UpdateStatusForm(incident=incident)
165
+
166
+ # Check that CLOSED is in the choices from INVESTIGATING status for P3+ but POST_MORTEM is NOT
167
+ status_choices = dict(form.fields["status"].choices)
168
+ assert IncidentStatus.CLOSED in status_choices
169
+ assert IncidentStatus.POST_MORTEM not in status_choices, "P3+ incidents should not have post-mortem status available"
170
+
171
+ def test_p1_p2_can_go_from_mitigated_to_postmortem(self):
172
+ """P1/P2 incidents can go from Mitigated to Post-mortem."""
173
+
174
+ # Get existing P1 or P2 priority that needs post-mortem
175
+ p1_priority = Priority.objects.filter(
176
+ value__in=[1, 2], needs_postmortem=True
177
+ ).first()
178
+
179
+ if not p1_priority:
180
+ pytest.skip("No P1/P2 priority with needs_postmortem=True found in database")
181
+
182
+ # Get PRD environment
183
+ prd_env = Environment.objects.filter(value="PRD").first()
184
+ if not prd_env:
185
+ pytest.skip("No PRD environment found in database")
186
+
187
+ # Create an incident with P1/P2 priority in Mitigated status with PRD env
188
+ incident = IncidentFactory.create(
189
+ priority=p1_priority,
190
+ environment=prd_env,
191
+ _status=IncidentStatus.MITIGATED, # MITIGATED = "Mitigated"
192
+ )
193
+
194
+ # Create form with the incident
195
+ form = UpdateStatusForm(incident=incident)
196
+
197
+ # Check that POST_MORTEM is available but CLOSED is not
198
+ status_choices = dict(form.fields["status"].choices)
199
+ assert IncidentStatus.POST_MORTEM in status_choices, "P1/P2 should be able to go to Post-mortem from Mitigated"
200
+ assert IncidentStatus.CLOSED not in status_choices, "P1/P2 should NOT be able to skip Post-mortem"
201
+
202
+ def test_complete_workflow_transitions_p3_plus(self):
203
+ """Test the correct workflow for P3+: can close with reason from early statuses, normal close from Mitigated."""
204
+ # Get P3+ priority
205
+ p3_priority = Priority.objects.filter(
206
+ value__gte=3, needs_postmortem=False
207
+ ).first()
208
+
209
+ if not p3_priority:
210
+ pytest.skip("No P3+ priority found")
211
+
212
+ # Test workflow for P3+
213
+ test_cases = [
214
+ (IncidentStatus.OPEN, True), # Can close with reason
215
+ (IncidentStatus.INVESTIGATING, True), # Can close with reason
216
+ (IncidentStatus.MITIGATING, False), # Cannot close from Mitigating (must go to Mitigated first)
217
+ (IncidentStatus.MITIGATED, True), # Can close normally from Mitigated
218
+ ]
219
+
220
+ for current_status, should_have_closed in test_cases:
221
+ incident = IncidentFactory.create(
222
+ priority=p3_priority,
223
+ _status=current_status,
224
+ )
225
+
226
+ form = UpdateStatusForm(incident=incident)
227
+ status_choices = dict(form.fields["status"].choices)
228
+
229
+ if should_have_closed:
230
+ assert IncidentStatus.CLOSED in status_choices, f"P3+ should be able to go to Closed from {current_status.label}"
231
+ else:
232
+ assert IncidentStatus.CLOSED not in status_choices, f"P3+ should NOT be able to go to Closed from {current_status.label}"
233
+
234
+ # P3+ should NEVER have post-mortem available
235
+ assert IncidentStatus.POST_MORTEM not in status_choices, f"P3+ should NEVER have post-mortem available from {current_status.label}"
236
+
237
+ def test_closure_reason_required_from_early_statuses(self):
238
+ """Test that closure reason is required when closing from Opened or Investigating."""
239
+ # Test for both P1/P2 and P3+ priorities
240
+ priorities = [
241
+ Priority.objects.filter(value__in=[1, 2]).first(), # P1/P2
242
+ Priority.objects.filter(value__gte=3).first(), # P3+
243
+ ]
244
+
245
+ for priority in priorities:
246
+ if not priority:
247
+ continue
248
+
249
+ # Test from Opened and Investigating
250
+ for status in [IncidentStatus.OPEN, IncidentStatus.INVESTIGATING]:
251
+ incident = IncidentFactory.create(
252
+ priority=priority,
253
+ _status=status,
254
+ )
255
+
256
+ # Check that requires_closure_reason returns True
257
+ assert UpdateStatusForm.requires_closure_reason(
258
+ incident, IncidentStatus.CLOSED
259
+ ), f"Should require closure reason from {status.label} for {priority.name}"
260
+
261
+ # Test from other statuses - should NOT require reason
262
+ for status in [IncidentStatus.MITIGATING, IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM]:
263
+ incident = IncidentFactory.create(
264
+ priority=priority,
265
+ _status=status,
266
+ )
267
+
268
+ # Check that requires_closure_reason returns False
269
+ assert not UpdateStatusForm.requires_closure_reason(
270
+ incident, IncidentStatus.CLOSED
271
+ ), f"Should NOT require closure reason from {status.label} for {priority.name}"
272
+
273
+ def test_cannot_close_from_mitigating(self):
274
+ """Test that incidents cannot be closed directly from Mitigating status."""
275
+ # Test for all priority levels
276
+ priorities = Priority.objects.all()
277
+
278
+ for priority in priorities:
279
+ incident = IncidentFactory.create(
280
+ priority=priority,
281
+ _status=IncidentStatus.MITIGATING, # Mitigating status
282
+ )
283
+
284
+ form = UpdateStatusForm(incident=incident)
285
+ status_choices = dict(form.fields["status"].choices)
286
+
287
+ # Should NOT have Closed option from Mitigating
288
+ assert IncidentStatus.CLOSED not in status_choices, f"Should NOT be able to close from Mitigating for {priority.name}"
289
+
290
+ # Should have Post-mortem option only for P1/P2 priorities that need postmortem
291
+ if priority.needs_postmortem:
292
+ # For P1/P2, should still NOT have post-mortem from MITIGATING (Mitigating)
293
+ assert IncidentStatus.POST_MORTEM not in status_choices, f"P1/P2 should NOT be able to go to Post-mortem from Mitigating for {priority.name}"
294
+ else:
295
+ # For P3+, should not have post-mortem at all
296
+ assert IncidentStatus.POST_MORTEM not in status_choices, f"P3+ should not have post-mortem available for {priority.name}"
297
+
298
+ def test_no_incident_shows_all_statuses(self):
299
+ """When no incident is provided, all statuses should be available."""
300
+ # Create form without incident
301
+ form = UpdateStatusForm()
302
+
303
+ # Check that all statuses including CLOSED are in the choices
304
+ status_choices = dict(form.fields["status"].choices)
305
+ assert IncidentStatus.CLOSED in status_choices
306
+ assert IncidentStatus.POST_MORTEM in status_choices
307
+
308
+ def test_requires_closure_reason_non_closed_status(self):
309
+ """Test requires_closure_reason with non-CLOSED target status."""
310
+ incident = IncidentFactory.create(_status=IncidentStatus.OPEN)
311
+
312
+ # Should not require reason for non-CLOSED statuses
313
+ assert not UpdateStatusForm.requires_closure_reason(incident, IncidentStatus.INVESTIGATING)
314
+ assert not UpdateStatusForm.requires_closure_reason(incident, IncidentStatus.MITIGATING)
315
+ assert not UpdateStatusForm.requires_closure_reason(incident, IncidentStatus.MITIGATED)
316
+ assert not UpdateStatusForm.requires_closure_reason(incident, IncidentStatus.POST_MORTEM)
317
+
318
+ def test_incident_status_edge_cases(self):
319
+ """Test edge cases for incident status transitions."""
320
+ # Test with incident in default fallback case
321
+ p1_priority = Priority.objects.filter(
322
+ value__in=[1, 2], needs_postmortem=True
323
+ ).first()
324
+
325
+ if not p1_priority:
326
+ pytest.skip("No P1/P2 priority found")
327
+
328
+ prd_env = Environment.objects.filter(value="PRD").first()
329
+ if not prd_env:
330
+ pytest.skip("No PRD environment found")
331
+
332
+ # Create incident with an undefined status (should hit default case)
333
+ incident = IncidentFactory.create(
334
+ priority=p1_priority,
335
+ environment=prd_env,
336
+ _status=IncidentStatus.CLOSED # Already closed
337
+ )
338
+
339
+ form = UpdateStatusForm(incident=incident)
340
+ status_choices = dict(form.fields["status"].choices)
341
+
342
+ # Should use default choices_lte for unknown/closed status
343
+ assert IncidentStatus.CLOSED in status_choices
@@ -0,0 +1,167 @@
1
+ """Test complete workflow transitions according to the diagram.
2
+
3
+ Workflow transitions:
4
+
5
+ P1/P2:
6
+ - OPEN → [INVESTIGATING, CLOSED (avec reason form)]
7
+ - INVESTIGATING → [MITIGATING, CLOSED (avec reason form)]
8
+ - MITIGATING → [MITIGATED]
9
+ - MITIGATED → [POST_MORTEM]
10
+ - POST_MORTEM → [CLOSED]
11
+
12
+ P3/P4/P5:
13
+ - OPEN → [INVESTIGATING, CLOSED (avec reason form)]
14
+ - INVESTIGATING → [MITIGATING, CLOSED (avec reason form)]
15
+ - MITIGATING → [MITIGATED]
16
+ - MITIGATED → [CLOSED]
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import pytest
21
+ from django.test import TestCase
22
+
23
+ from firefighter.incidents.enums import IncidentStatus
24
+ from firefighter.incidents.factories import IncidentFactory
25
+ from firefighter.incidents.forms.update_status import UpdateStatusForm
26
+ from firefighter.incidents.models import Environment, Priority
27
+
28
+
29
+ @pytest.mark.django_db
30
+ class TestCompleteWorkflowTransitions(TestCase):
31
+ """Test that all workflow transitions are implemented correctly."""
32
+
33
+ def test_p1_p2_complete_workflow_transitions(self):
34
+ """Test all P1/P2 workflow transitions according to the diagram."""
35
+ # Get P1/P2 priority
36
+ p1_priority = Priority.objects.filter(
37
+ value__in=[1, 2], needs_postmortem=True
38
+ ).first()
39
+
40
+ if not p1_priority:
41
+ pytest.skip("No P1/P2 priority found")
42
+
43
+ prd_env = Environment.objects.filter(value="PRD").first()
44
+ if not prd_env:
45
+ pytest.skip("No PRD environment found")
46
+
47
+ # Test all transitions
48
+ transitions = [
49
+ # Test transitions from each status
50
+ (IncidentStatus.OPEN, [IncidentStatus.INVESTIGATING, IncidentStatus.CLOSED]),
51
+ (IncidentStatus.INVESTIGATING, [IncidentStatus.MITIGATING, IncidentStatus.CLOSED]),
52
+ (IncidentStatus.MITIGATING, [IncidentStatus.MITIGATED]),
53
+ (IncidentStatus.MITIGATED, [IncidentStatus.POST_MORTEM]),
54
+ (IncidentStatus.POST_MORTEM, [IncidentStatus.CLOSED]),
55
+ ]
56
+
57
+ for current_status, expected_statuses in transitions:
58
+ incident = IncidentFactory.create(
59
+ priority=p1_priority,
60
+ environment=prd_env,
61
+ _status=current_status,
62
+ )
63
+
64
+ form = UpdateStatusForm(incident=incident)
65
+ status_choices = dict(form.fields["status"].choices)
66
+ available_statuses = list(status_choices.keys())
67
+
68
+ # Remove current status from available (can't transition to same status)
69
+ if current_status in available_statuses:
70
+ available_statuses.remove(current_status)
71
+
72
+ for expected_status in expected_statuses:
73
+ assert expected_status in available_statuses, (
74
+ f"P1/P2: From {current_status.label}, should be able to go to {expected_status.label}. "
75
+ f"Available: {[IncidentStatus(s).label for s in available_statuses]}"
76
+ )
77
+
78
+ # Verify no unexpected statuses are available
79
+ unexpected_statuses = set(available_statuses) - set(expected_statuses)
80
+ assert not unexpected_statuses, (
81
+ f"P1/P2: From {current_status.label}, unexpected statuses available: "
82
+ f"{[IncidentStatus(s).label for s in unexpected_statuses]}"
83
+ )
84
+
85
+ def test_p3_plus_complete_workflow_transitions(self):
86
+ """Test all P3+ workflow transitions according to the diagram."""
87
+ # Get P3+ priority
88
+ p3_priority = Priority.objects.filter(
89
+ value__gte=3, needs_postmortem=False
90
+ ).first()
91
+
92
+ if not p3_priority:
93
+ pytest.skip("No P3+ priority found")
94
+
95
+ # Test all transitions
96
+ transitions = [
97
+ # Test transitions from each status
98
+ (IncidentStatus.OPEN, [IncidentStatus.INVESTIGATING, IncidentStatus.CLOSED]),
99
+ (IncidentStatus.INVESTIGATING, [IncidentStatus.MITIGATING, IncidentStatus.CLOSED]),
100
+ (IncidentStatus.MITIGATING, [IncidentStatus.MITIGATED]),
101
+ (IncidentStatus.MITIGATED, [IncidentStatus.CLOSED]),
102
+ ]
103
+
104
+ for current_status, expected_statuses in transitions:
105
+ incident = IncidentFactory.create(
106
+ priority=p3_priority,
107
+ _status=current_status,
108
+ )
109
+
110
+ form = UpdateStatusForm(incident=incident)
111
+ status_choices = dict(form.fields["status"].choices)
112
+ available_statuses = list(status_choices.keys())
113
+
114
+ # Remove current status from available (can't transition to same status)
115
+ if current_status in available_statuses:
116
+ available_statuses.remove(current_status)
117
+
118
+ for expected_status in expected_statuses:
119
+ assert expected_status in available_statuses, (
120
+ f"P3+: From {current_status.label}, should be able to go to {expected_status.label}. "
121
+ f"Available: {[IncidentStatus(s).label for s in available_statuses]}"
122
+ )
123
+
124
+ # Verify no unexpected statuses are available (especially POST_MORTEM)
125
+ unexpected_statuses = set(available_statuses) - set(expected_statuses)
126
+ assert not unexpected_statuses, (
127
+ f"P3+: From {current_status.label}, unexpected statuses available: "
128
+ f"{[IncidentStatus(s).label for s in unexpected_statuses]}"
129
+ )
130
+
131
+ # Specifically verify POST_MORTEM is never available for P3+
132
+ assert IncidentStatus.POST_MORTEM not in available_statuses, (
133
+ f"P3+: POST_MORTEM should never be available, but found from {current_status.label}"
134
+ )
135
+
136
+ def test_closure_reason_requirements(self):
137
+ """Test that closure reason is required only from OPEN and INVESTIGATING."""
138
+ priorities = [
139
+ Priority.objects.filter(value__in=[1, 2]).first(), # P1/P2
140
+ Priority.objects.filter(value__gte=3).first(), # P3+
141
+ ]
142
+
143
+ for priority in priorities:
144
+ if not priority:
145
+ continue
146
+
147
+ # Should require reason from OPEN and INVESTIGATING
148
+ for status in [IncidentStatus.OPEN, IncidentStatus.INVESTIGATING]:
149
+ incident = IncidentFactory.create(
150
+ priority=priority,
151
+ _status=status,
152
+ )
153
+
154
+ assert UpdateStatusForm.requires_closure_reason(
155
+ incident, IncidentStatus.CLOSED
156
+ ), f"Should require closure reason from {status.label} for {priority.name}"
157
+
158
+ # Should NOT require reason from other statuses
159
+ for status in [IncidentStatus.MITIGATING, IncidentStatus.MITIGATED, IncidentStatus.POST_MORTEM]:
160
+ incident = IncidentFactory.create(
161
+ priority=priority,
162
+ _status=status,
163
+ )
164
+
165
+ assert not UpdateStatusForm.requires_closure_reason(
166
+ incident, IncidentStatus.CLOSED
167
+ ), f"Should NOT require closure reason from {status.label} for {priority.name}"
@@ -7,7 +7,9 @@ from hypothesis import given
7
7
  from hypothesis.extra import django
8
8
  from hypothesis.strategies import builds
9
9
 
10
+ from firefighter.incidents.enums import ClosureReason, IncidentStatus
10
11
  from firefighter.incidents.factories import IncidentFactory
12
+ from firefighter.incidents.models import IncidentUpdate
11
13
 
12
14
  if TYPE_CHECKING:
13
15
  from firefighter.incidents.models import Incident
@@ -28,3 +30,69 @@ class TestIncident(django.TestCase):
28
30
  instance.save()
29
31
 
30
32
  assert instance.id > 0
33
+
34
+
35
+ @pytest.mark.django_db
36
+ class TestIncidentCanBeClosed:
37
+ """Test the can_be_closed property logic."""
38
+
39
+ def test_cannot_close_incident_below_mitigated_status(self) -> None:
40
+ """Test that incidents below MITIGATED status cannot be closed without reason."""
41
+ # Create an incident in INVESTIGATING status (below MITIGATED)
42
+ incident = IncidentFactory.create(_status=IncidentStatus.INVESTIGATING)
43
+
44
+ can_close, reasons = incident.can_be_closed
45
+
46
+ assert can_close is False
47
+ assert any(reason[0] == "STATUS_NOT_MITIGATED" for reason in reasons)
48
+ assert any("Mitigated" in reason[1] for reason in reasons)
49
+
50
+ def test_can_close_incident_at_mitigated_status(self) -> None:
51
+ """Test that incidents at MITIGATED status can be closed (if no postmortem required)."""
52
+ # Create a P3 incident in MITIGATED status (no postmortem required)
53
+ incident = IncidentFactory.create(
54
+ _status=IncidentStatus.MITIGATED,
55
+ priority__value=3, # P3 doesn't require postmortem
56
+ )
57
+
58
+ can_close, reasons = incident.can_be_closed
59
+
60
+ # Should be closable (assuming no missing milestones)
61
+ assert can_close is True or "STATUS_NOT_MITIGATED" not in [r[0] for r in reasons]
62
+
63
+ def test_can_close_incident_with_closure_reason(self) -> None:
64
+ """Test that incidents with closure_reason can always be closed."""
65
+ # Create an incident in early status but with closure reason
66
+ incident = IncidentFactory.create(
67
+ _status=IncidentStatus.INVESTIGATING,
68
+ closure_reason=ClosureReason.DUPLICATE,
69
+ )
70
+
71
+ can_close, reasons = incident.can_be_closed
72
+
73
+ assert can_close is True
74
+ assert reasons == []
75
+
76
+
77
+ @pytest.mark.django_db
78
+ class TestIncidentSetStatus:
79
+ """Test the set_status method logic."""
80
+
81
+ def test_set_status_to_mitigated_creates_recovered_event(self) -> None:
82
+ """Test that setting status to MITIGATED creates a 'recovered' event."""
83
+ incident = IncidentFactory.create(_status=IncidentStatus.INVESTIGATING)
84
+
85
+ # Set status to MITIGATED using create_incident_update
86
+ incident.create_incident_update(
87
+ status=IncidentStatus.MITIGATED.value,
88
+ created_by=incident.created_by,
89
+ message="Incident has been mitigated",
90
+ )
91
+
92
+ # Check that a 'recovered' event was created
93
+ recovered_event = IncidentUpdate.objects.filter(
94
+ incident=incident, event_type="recovered"
95
+ ).first()
96
+
97
+ assert recovered_event is not None
98
+ assert recovered_event.event_type == "recovered"