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.
- firefighter/_version.py +2 -2
- firefighter/api/serializers.py +9 -0
- firefighter/confluence/signals/incident_updated.py +2 -2
- firefighter/incidents/enums.py +22 -2
- firefighter/incidents/forms/closure_reason.py +45 -0
- firefighter/incidents/forms/unified_incident.py +406 -0
- firefighter/incidents/forms/update_status.py +87 -1
- firefighter/incidents/migrations/0027_add_closure_fields.py +40 -0
- firefighter/incidents/migrations/0028_add_closure_reason_constraint.py +33 -0
- firefighter/incidents/migrations/0029_add_custom_fields_to_incident.py +22 -0
- firefighter/incidents/models/incident.py +32 -5
- firefighter/incidents/static/css/main.min.css +1 -1
- firefighter/incidents/templates/layouts/partials/status_pill.html +1 -1
- firefighter/incidents/views/reports.py +3 -3
- firefighter/raid/apps.py +9 -26
- firefighter/raid/client.py +2 -2
- firefighter/raid/forms.py +75 -238
- firefighter/raid/signals/incident_created.py +38 -13
- firefighter/raid/signals/incident_updated.py +3 -2
- firefighter/slack/messages/slack_messages.py +19 -4
- firefighter/slack/rules.py +1 -1
- firefighter/slack/signals/create_incident_conversation.py +6 -0
- firefighter/slack/signals/incident_updated.py +7 -1
- firefighter/slack/views/modals/__init__.py +4 -0
- firefighter/slack/views/modals/base_modal/form_utils.py +63 -0
- firefighter/slack/views/modals/close.py +15 -2
- firefighter/slack/views/modals/closure_reason.py +193 -0
- firefighter/slack/views/modals/open.py +59 -12
- firefighter/slack/views/modals/opening/details/unified.py +203 -0
- firefighter/slack/views/modals/opening/set_details.py +3 -2
- firefighter/slack/views/modals/postmortem.py +10 -2
- firefighter/slack/views/modals/update_status.py +28 -2
- firefighter/slack/views/modals/utils.py +51 -0
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/METADATA +1 -1
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/RECORD +61 -37
- firefighter_tests/test_incidents/test_enums.py +100 -0
- firefighter_tests/test_incidents/test_forms/conftest.py +179 -0
- firefighter_tests/test_incidents/test_forms/test_closure_reason.py +91 -0
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form.py +570 -0
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +581 -0
- firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +410 -0
- firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +343 -0
- firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +167 -0
- firefighter_tests/test_incidents/test_models/test_incident_model.py +68 -0
- firefighter_tests/test_raid/conftest.py +154 -0
- firefighter_tests/test_raid/test_p1_p3_jira_fields.py +372 -0
- firefighter_tests/test_raid/test_raid_forms.py +10 -253
- firefighter_tests/test_raid/test_raid_signals.py +187 -0
- firefighter_tests/test_slack/messages/__init__.py +0 -0
- firefighter_tests/test_slack/messages/test_slack_messages.py +367 -0
- firefighter_tests/test_slack/views/modals/conftest.py +140 -0
- firefighter_tests/test_slack/views/modals/test_close.py +65 -3
- firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +138 -0
- firefighter_tests/test_slack/views/modals/test_form_utils_multiple_choice.py +249 -0
- firefighter_tests/test_slack/views/modals/test_open.py +146 -2
- firefighter_tests/test_slack/views/modals/test_opening_unified.py +421 -0
- firefighter_tests/test_slack/views/modals/test_update_status.py +327 -3
- firefighter_tests/test_slack/views/modals/test_utils.py +135 -0
- firefighter/raid/views/open_normal.py +0 -139
- firefighter/slack/views/modals/opening/details/critical.py +0 -88
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/WHEEL +0 -0
- {firefighter_incident-0.0.14.dist-info → firefighter_incident-0.0.15.dist-info}/entry_points.txt +0 -0
- {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"
|