firefighter-incident 0.0.16__py3-none-any.whl → 0.0.17__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 (31) hide show
  1. firefighter/_version.py +2 -2
  2. firefighter/incidents/forms/edit.py +5 -3
  3. firefighter/incidents/forms/unified_incident.py +180 -56
  4. firefighter/incidents/forms/update_status.py +94 -58
  5. firefighter/incidents/forms/utils.py +14 -0
  6. firefighter/incidents/models/incident.py +3 -2
  7. firefighter/raid/apps.py +0 -1
  8. firefighter/slack/signals/__init__.py +16 -0
  9. firefighter/slack/signals/incident_updated.py +43 -1
  10. firefighter/slack/utils.py +43 -6
  11. firefighter/slack/views/modals/base_modal/form_utils.py +3 -1
  12. firefighter/slack/views/modals/downgrade_workflow.py +3 -1
  13. firefighter/slack/views/modals/edit.py +53 -7
  14. firefighter/slack/views/modals/opening/set_details.py +20 -0
  15. firefighter_fixtures/incidents/priorities.json +1 -1
  16. {firefighter_incident-0.0.16.dist-info → firefighter_incident-0.0.17.dist-info}/METADATA +1 -1
  17. {firefighter_incident-0.0.16.dist-info → firefighter_incident-0.0.17.dist-info}/RECORD +28 -29
  18. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_integration.py +160 -23
  19. firefighter_tests/test_incidents/test_forms/test_unified_incident_form_p4_p5.py +38 -60
  20. firefighter_tests/test_incidents/test_forms/test_update_status_workflow.py +35 -20
  21. firefighter_tests/test_incidents/test_forms/test_workflow_transitions.py +8 -6
  22. firefighter_tests/test_slack/test_signals_downgrade.py +147 -0
  23. firefighter_tests/test_slack/views/modals/test_edit.py +324 -0
  24. firefighter_tests/test_slack/views/modals/test_opening_unified.py +42 -0
  25. firefighter_tests/test_slack/views/modals/test_update_status.py +72 -2
  26. firefighter/raid/signals/incident_created.py +0 -129
  27. firefighter_tests/test_raid/test_p1_p3_jira_fields.py +0 -372
  28. firefighter_tests/test_raid/test_priority_mapping.py +0 -267
  29. {firefighter_incident-0.0.16.dist-info → firefighter_incident-0.0.17.dist-info}/WHEEL +0 -0
  30. {firefighter_incident-0.0.16.dist-info → firefighter_incident-0.0.17.dist-info}/entry_points.txt +0 -0
  31. {firefighter_incident-0.0.16.dist-info → firefighter_incident-0.0.17.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,147 @@
1
+ """Tests for incident priority downgrade signal handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import pytest
8
+
9
+ from firefighter.incidents.factories import IncidentFactory, UserFactory
10
+ from firefighter.incidents.models import Priority
11
+ from firefighter.slack.factories import IncidentChannelFactory, SlackUserFactory
12
+
13
+ if TYPE_CHECKING:
14
+ from pytest_mock import MockerFixture
15
+
16
+
17
+ @pytest.mark.django_db
18
+ class TestIncidentDowngradeSignal:
19
+ """Test the incident_updated_check_dowmgrade_handler signal."""
20
+
21
+ @staticmethod
22
+ def test_downgrade_hint_shown_for_critical_to_normal(mocker: MockerFixture) -> None:
23
+ """Test that downgrade hint is shown when downgrading from P1/P2/P3 to P4/P5."""
24
+ # Create a user with a Slack user
25
+ user = UserFactory.build()
26
+ user.save()
27
+ slack_user = SlackUserFactory.build(user=user)
28
+ slack_user.save()
29
+
30
+ # Get priorities from DB
31
+ p2 = Priority.objects.get(name="P2")
32
+ p4 = Priority.objects.get(name="P4")
33
+
34
+ # Create an incident with P2 priority
35
+ incident = IncidentFactory.build(priority=p2, created_by=user)
36
+ incident.save()
37
+
38
+ # Create an incident channel (conversation) for this incident
39
+ conversation = IncidentChannelFactory.build(incident=incident)
40
+ conversation.save()
41
+
42
+ # Mock the send_message_ephemeral method
43
+ mock_send = mocker.patch.object(conversation, "send_message_ephemeral")
44
+
45
+ # Downgrade from P2 to P4
46
+ incident.create_incident_update(
47
+ created_by=user, priority_id=p4.id, message="Downgrading to P4"
48
+ )
49
+
50
+ # Verify the downgrade hint message was sent
51
+ mock_send.assert_called_once()
52
+ call_args = mock_send.call_args
53
+ assert call_args is not None
54
+ # Check that the message is about the incident not needing an incident channel
55
+ message = call_args.kwargs["message"]
56
+ message_text = message.get_text().lower()
57
+ assert "might not need an incident channel" in message_text or "p4" in message_text or "p5" in message_text
58
+
59
+ @staticmethod
60
+ def test_no_hint_when_staying_in_critical_range(mocker: MockerFixture) -> None:
61
+ """Test that no hint is shown when changing priority within critical range (P1/P2/P3)."""
62
+ # Create a user
63
+ user = UserFactory.build()
64
+ user.save()
65
+
66
+ # Get priorities from DB
67
+ p1 = Priority.objects.get(name="P1")
68
+ p3 = Priority.objects.get(name="P3")
69
+
70
+ # Create an incident with P1 priority
71
+ incident = IncidentFactory.build(priority=p1, created_by=user)
72
+ incident.save()
73
+
74
+ # Create an incident channel (conversation) for this incident
75
+ conversation = IncidentChannelFactory.build(incident=incident)
76
+ conversation.save()
77
+
78
+ # Mock the send_message_ephemeral method
79
+ mock_send = mocker.patch.object(conversation, "send_message_ephemeral")
80
+
81
+ # Update from P1 to P3 (both critical)
82
+ incident.create_incident_update(
83
+ created_by=user, priority_id=p3.id, message="Updating to P3"
84
+ )
85
+
86
+ # Verify NO downgrade hint was sent
87
+ mock_send.assert_not_called()
88
+
89
+ @staticmethod
90
+ def test_no_hint_when_staying_in_normal_range(mocker: MockerFixture) -> None:
91
+ """Test that no hint is shown when changing priority within normal range (P4/P5)."""
92
+ # Create a user
93
+ user = UserFactory.build()
94
+ user.save()
95
+
96
+ # Get priorities from DB
97
+ p4 = Priority.objects.get(name="P4")
98
+ p5 = Priority.objects.get(name="P5")
99
+
100
+ # Create an incident with P4 priority
101
+ incident = IncidentFactory.build(priority=p4, created_by=user)
102
+ incident.save()
103
+
104
+ # Create an incident channel (conversation) for this incident
105
+ conversation = IncidentChannelFactory.build(incident=incident)
106
+ conversation.save()
107
+
108
+ # Mock the send_message_ephemeral method
109
+ mock_send = mocker.patch.object(conversation, "send_message_ephemeral")
110
+
111
+ # Update from P4 to P5 (both normal)
112
+ incident.create_incident_update(
113
+ created_by=user, priority_id=p5.id, message="Updating to P5"
114
+ )
115
+
116
+ # Verify NO downgrade hint was sent
117
+ mock_send.assert_not_called()
118
+
119
+ @staticmethod
120
+ def test_no_hint_when_upgrading_from_normal_to_critical(mocker: MockerFixture) -> None:
121
+ """Test that no hint is shown when upgrading from P4/P5 to P1/P2/P3."""
122
+ # Create a user
123
+ user = UserFactory.build()
124
+ user.save()
125
+
126
+ # Get priorities from DB
127
+ p5 = Priority.objects.get(name="P5")
128
+ p2 = Priority.objects.get(name="P2")
129
+
130
+ # Create an incident with P5 priority
131
+ incident = IncidentFactory.build(priority=p5, created_by=user)
132
+ incident.save()
133
+
134
+ # Create an incident channel (conversation) for this incident
135
+ conversation = IncidentChannelFactory.build(incident=incident)
136
+ conversation.save()
137
+
138
+ # Mock the send_message_ephemeral method
139
+ mock_send = mocker.patch.object(conversation, "send_message_ephemeral")
140
+
141
+ # Upgrade from P5 to P2
142
+ incident.create_incident_update(
143
+ created_by=user, priority_id=p2.id, message="Escalating to P2"
144
+ )
145
+
146
+ # Verify NO downgrade hint was sent (this is an upgrade, not a downgrade)
147
+ mock_send.assert_not_called()
@@ -0,0 +1,324 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from unittest.mock import MagicMock
5
+
6
+ import pytest
7
+ from pytest_mock import MockerFixture
8
+
9
+ from firefighter.incidents.factories import IncidentFactory, UserFactory
10
+ from firefighter.incidents.models import Environment, Incident
11
+ from firefighter.slack.views import EditMetaModal
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ @pytest.mark.django_db
17
+ class TestEditMetaModal:
18
+ @staticmethod
19
+ @pytest.fixture
20
+ def incident(environment_factory) -> Incident:
21
+ """Returns a valid incident with environment and custom_fields."""
22
+ env_prd = environment_factory(value="PRD", name="Production", order=0)
23
+ return IncidentFactory.create(
24
+ environment=env_prd,
25
+ custom_fields={"environments": ["PRD"]}
26
+ )
27
+
28
+ @staticmethod
29
+ @pytest.fixture
30
+ def multi_env_incident(environment_factory) -> Incident:
31
+ """Returns an incident with multiple environments in custom_fields."""
32
+ env_prd = environment_factory(value="PRD", name="Production", order=0)
33
+ environment_factory(value="STG", name="Staging", order=1)
34
+ environment_factory(value="INT", name="Integration", order=2)
35
+
36
+ return IncidentFactory.create(
37
+ environment=env_prd,
38
+ custom_fields={"environments": ["PRD", "STG", "INT"]}
39
+ )
40
+
41
+ @staticmethod
42
+ def test_build_modal_fn_single_environment(incident: Incident) -> None:
43
+ """Test building modal with single environment."""
44
+ modal = EditMetaModal()
45
+ res = modal.build_modal_fn(incident)
46
+
47
+ # Validate the JSON structure
48
+ assert res.to_dict()
49
+
50
+ values = res.to_dict()
51
+ assert "blocks" in values
52
+ assert values["title"]["text"] == f"Update incident #{incident.id}"[:24]
53
+ assert values["submit"]["text"] == "Update incident"
54
+
55
+ @staticmethod
56
+ def test_build_modal_fn_multiple_environments(multi_env_incident: Incident) -> None:
57
+ """Test building modal with multiple environments in custom_fields."""
58
+ modal = EditMetaModal()
59
+ res = modal.build_modal_fn(multi_env_incident)
60
+
61
+ # Validate the JSON structure
62
+ assert res.to_dict()
63
+
64
+ values = res.to_dict()
65
+ assert "blocks" in values
66
+
67
+ # The form should show all three environments from custom_fields
68
+ # We verify this by checking the initial values passed to the form
69
+ assert multi_env_incident.custom_fields["environments"] == ["PRD", "STG", "INT"]
70
+
71
+ @staticmethod
72
+ def test_build_modal_fn_fallback_to_single_environment(environment_factory) -> None:
73
+ """Test building modal falls back to single environment when custom_fields is empty."""
74
+ env_prd = environment_factory(value="PRD", name="Production", order=0)
75
+ incident = IncidentFactory.create(
76
+ environment=env_prd,
77
+ custom_fields={} # No environments in custom_fields
78
+ )
79
+
80
+ modal = EditMetaModal()
81
+ res = modal.build_modal_fn(incident)
82
+
83
+ # Validate the JSON structure
84
+ assert res.to_dict()
85
+
86
+ values = res.to_dict()
87
+ assert "blocks" in values
88
+
89
+ @staticmethod
90
+ def test_build_modal_fn_empty_custom_fields(environment_factory) -> None:
91
+ """Test building modal when custom_fields has no environments."""
92
+ env_stg = environment_factory(value="STG", name="Staging", order=1)
93
+ incident = IncidentFactory.create(
94
+ environment=env_stg,
95
+ custom_fields={} # Empty custom_fields
96
+ )
97
+
98
+ modal = EditMetaModal()
99
+ res = modal.build_modal_fn(incident)
100
+
101
+ # Should build successfully and fallback to single environment
102
+ assert res.to_dict()
103
+
104
+ @staticmethod
105
+ def test_handle_modal_fn_update_title_and_description(
106
+ mocker: MockerFixture, incident: Incident
107
+ ) -> None:
108
+ """Test handling modal submission with title and description updates."""
109
+ modal = EditMetaModal()
110
+ trigger_workflow = mocker.patch.object(modal, "_trigger_incident_workflow")
111
+
112
+ ack = MagicMock()
113
+ user = UserFactory.create()
114
+
115
+ # Create a submission with updated title and description
116
+ submission = create_edit_submission(
117
+ incident=incident,
118
+ title="Updated Incident Title",
119
+ description="Updated incident description with more details.",
120
+ )
121
+
122
+ modal.handle_modal_fn(ack=ack, body=submission, incident=incident, user=user)
123
+
124
+ # Assert
125
+ ack.assert_called_once_with()
126
+ trigger_workflow.assert_called_once()
127
+ call_kwargs = trigger_workflow.call_args.kwargs
128
+ assert call_kwargs["title"] == "Updated Incident Title"
129
+ assert call_kwargs["description"] == "Updated incident description with more details."
130
+
131
+ @staticmethod
132
+ def test_handle_modal_fn_update_environments(
133
+ mocker: MockerFixture, environment_factory
134
+ ) -> None:
135
+ """Test handling modal submission with environment updates."""
136
+ env_prd = environment_factory(value="PRD", name="Production", order=0)
137
+ env_stg = environment_factory(value="STG", name="Staging", order=1)
138
+ env_int = environment_factory(value="INT", name="Integration", order=2)
139
+
140
+ incident = IncidentFactory.create(
141
+ environment=env_prd,
142
+ custom_fields={"environments": ["PRD"]}
143
+ )
144
+
145
+ modal = EditMetaModal()
146
+ trigger_workflow = mocker.patch.object(modal, "_trigger_incident_workflow")
147
+
148
+ ack = MagicMock()
149
+ user = UserFactory.create()
150
+
151
+ # Create a submission with multiple environments selected
152
+ submission = create_edit_submission(
153
+ incident=incident,
154
+ environment_ids=[str(env_prd.id), str(env_stg.id), str(env_int.id)],
155
+ )
156
+
157
+ modal.handle_modal_fn(ack=ack, body=submission, incident=incident, user=user)
158
+
159
+ # Assert
160
+ ack.assert_called_once_with()
161
+
162
+ # Verify custom_fields were updated
163
+ incident.refresh_from_db()
164
+ assert set(incident.custom_fields["environments"]) == {"PRD", "STG", "INT"}
165
+
166
+ # Verify primary environment is PRD (highest priority)
167
+ trigger_workflow.assert_called_once()
168
+ call_kwargs = trigger_workflow.call_args.kwargs
169
+ assert call_kwargs["environment_id"] == env_prd.id
170
+
171
+ @staticmethod
172
+ def test_handle_modal_fn_change_primary_environment(
173
+ mocker: MockerFixture, environment_factory
174
+ ) -> None:
175
+ """Test changing from one environment to another."""
176
+ env_prd = environment_factory(value="PRD", name="Production", order=0)
177
+ env_stg = environment_factory(value="STG", name="Staging", order=1)
178
+
179
+ incident = IncidentFactory.create(
180
+ environment=env_prd,
181
+ custom_fields={"environments": ["PRD"]}
182
+ )
183
+
184
+ modal = EditMetaModal()
185
+ trigger_workflow = mocker.patch.object(modal, "_trigger_incident_workflow")
186
+
187
+ ack = MagicMock()
188
+ user = UserFactory.create()
189
+
190
+ # Change to STG only
191
+ submission = create_edit_submission(
192
+ incident=incident,
193
+ environment_ids=[str(env_stg.id)],
194
+ )
195
+
196
+ modal.handle_modal_fn(ack=ack, body=submission, incident=incident, user=user)
197
+
198
+ # Assert
199
+ ack.assert_called_once_with()
200
+
201
+ # Verify custom_fields were updated to STG
202
+ incident.refresh_from_db()
203
+ assert incident.custom_fields["environments"] == ["STG"]
204
+
205
+ # Verify primary environment is STG
206
+ trigger_workflow.assert_called_once()
207
+ call_kwargs = trigger_workflow.call_args.kwargs
208
+ assert call_kwargs["environment_id"] == env_stg.id
209
+
210
+ @staticmethod
211
+ def test_handle_modal_fn_no_changes(mocker: MockerFixture, incident: Incident) -> None:
212
+ """Test handling modal submission with no changes."""
213
+ modal = EditMetaModal()
214
+ trigger_workflow = mocker.patch.object(modal, "_trigger_incident_workflow")
215
+
216
+ ack = MagicMock()
217
+ user = UserFactory.create()
218
+
219
+ # Create a submission with same values as current incident
220
+ submission = create_edit_submission(
221
+ incident=incident,
222
+ title=incident.title,
223
+ description=incident.description,
224
+ environment_ids=[str(incident.environment.id)],
225
+ )
226
+
227
+ modal.handle_modal_fn(ack=ack, body=submission, incident=incident, user=user)
228
+
229
+ # Assert that workflow was not triggered (no changes)
230
+ ack.assert_called_once_with()
231
+ trigger_workflow.assert_not_called()
232
+
233
+ @staticmethod
234
+ def test_handle_modal_fn_empty_body(incident: Incident) -> None:
235
+ """Test handling modal with empty body raises TypeError."""
236
+ modal = EditMetaModal()
237
+ ack = MagicMock()
238
+ user = UserFactory.create()
239
+
240
+ with pytest.raises(TypeError, match="Expected a values dict in the body"):
241
+ modal.handle_modal_fn(ack=ack, body={}, incident=incident, user=user)
242
+
243
+
244
+ def create_edit_submission(
245
+ incident: Incident,
246
+ title: str | None = None,
247
+ description: str | None = None,
248
+ environment_ids: list[str] | None = None,
249
+ ) -> dict:
250
+ """Helper function to create a valid edit submission body."""
251
+ # Use current values as defaults
252
+ title = title if title is not None else incident.title
253
+ description = description if description is not None else incident.description
254
+
255
+ # Build environment options
256
+ if environment_ids is None:
257
+ # Use current incident environment
258
+ environment_ids = [str(incident.environment.id)] if incident.environment else []
259
+
260
+ # Convert environment IDs to options format
261
+ env_selected_options = []
262
+ if environment_ids:
263
+ for env_id in environment_ids:
264
+ env_obj = Environment.objects.get(id=env_id)
265
+ env_selected_options.append({
266
+ "text": {"type": "plain_text", "text": f"{env_obj.value} - {env_obj.description}", "emoji": True},
267
+ "value": env_id,
268
+ })
269
+
270
+ return {
271
+ "type": "view_submission",
272
+ "team": {"id": "T01FJ0NNFQD", "domain": "team-firefighter"},
273
+ "user": {
274
+ "id": "U03L9K8P5SA",
275
+ "username": "john.doe",
276
+ "name": "john.doe",
277
+ "team_id": "T01FJ0NNFQD",
278
+ },
279
+ "api_app_id": "A03SXN0ENM9",
280
+ "token": "fake_token",
281
+ "trigger_id": "3924659449141.1528022763829.670d1c03f8d04cf6655676963267ca4e",
282
+ "view": {
283
+ "id": "V03T304049L",
284
+ "team_id": "T01FJ0NNFQD",
285
+ "type": "modal",
286
+ "private_metadata": str(incident.id),
287
+ "callback_id": "incident_edit_incident",
288
+ "state": {
289
+ "values": {
290
+ "title": {
291
+ "title": {
292
+ "type": "plain_text_input",
293
+ "value": title,
294
+ }
295
+ },
296
+ "description": {
297
+ "description": {
298
+ "type": "plain_text_input",
299
+ "value": description,
300
+ }
301
+ },
302
+ "environment": {
303
+ "environment": {
304
+ "type": "multi_static_select",
305
+ "selected_options": env_selected_options,
306
+ }
307
+ },
308
+ }
309
+ },
310
+ "hash": "1660220674.c7elhik9",
311
+ "title": {"type": "plain_text", "text": f"Update incident #{incident.id}"[:24], "emoji": True},
312
+ "clear_on_close": False,
313
+ "notify_on_close": False,
314
+ "close": None,
315
+ "submit": {"type": "plain_text", "text": "Update incident", "emoji": True},
316
+ "app_id": "A03SXN0ENM9",
317
+ "external_id": "",
318
+ "app_installed_team_id": "T01FJ0NNFQD",
319
+ "bot_id": "B03T08W83AQ",
320
+ },
321
+ "response_urls": [],
322
+ "is_enterprise_install": False,
323
+ "enterprise": None,
324
+ }
@@ -419,3 +419,45 @@ class TestOpeningUnifiedModal:
419
419
 
420
420
  # Verify that suggested_team_routing is NOT in the form (critical incident)
421
421
  assert "suggested_team_routing" not in form.fields, "suggested_team_routing should not be present for critical incidents"
422
+
423
+ def test_build_modal_fn_handles_environment_uuid_list(
424
+ self, priority_factory, environment_factory
425
+ ):
426
+ """build_modal_fn should handle list of environment UUIDs and preserve order."""
427
+ priority_factory(value=1, default=True)
428
+ env_prd = environment_factory(value="PRD", default=True)
429
+ env_stg = environment_factory(value="STG", default=False)
430
+
431
+ modal = OpeningUnifiedModal()
432
+
433
+ # Simulate editing scenario where details_form_data contains list of environment UUIDs
434
+ # IMPORTANT: Order is STG first, then PRD (not alphabetical order)
435
+ open_incident_context = {
436
+ "response_type": "critical",
437
+ "impact_form_data": {},
438
+ "details_form_data": {
439
+ "environment": [str(env_stg.id), str(env_prd.id)] # List of UUIDs (STG, PRD)
440
+ },
441
+ }
442
+
443
+ # This should NOT raise ValidationError about invalid UUID
444
+ view = modal.build_modal_fn(open_incident_context=open_incident_context)
445
+
446
+ # Should build view without errors
447
+ assert view is not None
448
+ assert view.type == "modal"
449
+ assert len(view.blocks) > 0
450
+
451
+ # Verify that the environment block preserves the order (STG, PRD)
452
+ env_blocks = [b for b in view.blocks if hasattr(b, "block_id") and b.block_id == "environment"]
453
+ assert len(env_blocks) == 1, "Should find exactly one environment block"
454
+ env_block = env_blocks[0]
455
+
456
+ # Check initial_options preserves order: STG first, then PRD
457
+ assert hasattr(env_block, "element"), "Block should have an element attribute"
458
+ assert hasattr(env_block.element, "initial_options"), "Element should have initial_options"
459
+ initial_options = env_block.element.initial_options
460
+ assert initial_options is not None, "initial_options should not be None"
461
+ assert len(initial_options) == 2, "Should have both environments in initial_options"
462
+ assert initial_options[0].value == str(env_stg.id), "First option should be STG (preserving order)"
463
+ assert initial_options[1].value == str(env_prd.id), "Second option should be PRD (preserving order)"
@@ -332,12 +332,12 @@ class TestUpdateStatusModal:
332
332
 
333
333
  @staticmethod
334
334
  def test_can_close_when_all_conditions_met(mocker: MockerFixture) -> None:
335
- """Test that closing is allowed when all conditions are met."""
335
+ """Test that closing is allowed when all conditions are met for P3+ incidents."""
336
336
  # Create a user first
337
337
  user = UserFactory.build()
338
338
  user.save()
339
339
 
340
- # Create an incident in MITIGATED status with all conditions met
340
+ # Create a P3+ incident in MITIGATED status with all conditions met
341
341
  incident = IncidentFactory.build(
342
342
  _status=IncidentStatus.MITIGATED,
343
343
  created_by=user,
@@ -345,6 +345,14 @@ class TestUpdateStatusModal:
345
345
  # IMPORTANT: Save the incident so it has an ID for the form to reference
346
346
  incident.save()
347
347
 
348
+ # Mock needs_postmortem to return False (P3+ incident)
349
+ mocker.patch.object(
350
+ type(incident),
351
+ "needs_postmortem",
352
+ new_callable=PropertyMock,
353
+ return_value=False
354
+ )
355
+
348
356
  # Mock can_be_closed to return True (all conditions met)
349
357
  mocker.patch.object(
350
358
  type(incident),
@@ -380,6 +388,68 @@ class TestUpdateStatusModal:
380
388
  # Verify that incident update WAS triggered
381
389
  trigger_incident_workflow.assert_called_once()
382
390
 
391
+ @staticmethod
392
+ def test_can_update_priority_without_changing_status(mocker: MockerFixture, priority_factory) -> None:
393
+ """Test that priority can be updated without changing status.
394
+
395
+ This reproduces the bug where trying to update only the priority of a P4
396
+ incident in MITIGATED status fails with:
397
+ "Select a valid choice. 40 is not one of the available choices."
398
+
399
+ The issue is that the form restricts status choices based on current status,
400
+ but the initial value (MITIGATED=40) may not be in those restricted choices.
401
+ """
402
+ # Create a user first
403
+ user = UserFactory.build()
404
+ user.save()
405
+
406
+ # Create P3 and P4 priorities
407
+ p3_priority = priority_factory(value=3, name="P3")
408
+ p4_priority = priority_factory(value=4, name="P4")
409
+
410
+ # Create a P3 incident in MITIGATED status
411
+ incident = IncidentFactory.build(
412
+ _status=IncidentStatus.MITIGATED,
413
+ created_by=user,
414
+ priority=p3_priority,
415
+ )
416
+ incident.save()
417
+
418
+ modal = UpdateStatusModal()
419
+ trigger_incident_workflow = mocker.patch.object(
420
+ modal, "_trigger_incident_workflow"
421
+ )
422
+
423
+ ack = MagicMock()
424
+
425
+ # User tries to change priority from P3 to P4 WITHOUT changing status
426
+ # The status field will have MITIGATED (40) as initial value, but it's not in the available choices
427
+ submission_copy = dict(valid_submission)
428
+ # Status unchanged - keeps MITIGATED (40)
429
+ submission_copy["view"]["state"]["values"]["status"]["status"]["selected_option"] = {
430
+ "text": {"type": "plain_text", "text": "Mitigated", "emoji": True},
431
+ "value": "40", # This should cause validation error with current code
432
+ }
433
+ # Change priority to P4
434
+ submission_copy["view"]["state"]["values"]["priority"]["priority"]["selected_option"] = {
435
+ "text": {"type": "plain_text", "text": "P4", "emoji": True},
436
+ "value": str(p4_priority.id),
437
+ }
438
+ submission_copy["view"]["private_metadata"] = str(incident.id)
439
+
440
+ modal.handle_modal_fn(
441
+ ack=ack, body=submission_copy, incident=incident, user=user
442
+ )
443
+
444
+ # Assert that ack was called successfully WITHOUT errors
445
+ # With the bug, this would fail with "Select a valid choice. 40 is not one of the available choices"
446
+ first_call_kwargs = ack.call_args_list[0][1] if ack.call_args_list else ack.call_args.kwargs
447
+ assert first_call_kwargs == {} or "errors" not in first_call_kwargs, \
448
+ f"Should allow updating priority without changing status. Got errors: {first_call_kwargs.get('errors')}"
449
+
450
+ # Verify that incident update WAS triggered (priority changed)
451
+ trigger_incident_workflow.assert_called_once()
452
+
383
453
 
384
454
  valid_submission = {
385
455
  "type": "view_submission",