firefighter-incident 0.0.22__py3-none-any.whl → 0.0.23__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 (50) hide show
  1. firefighter/_version.py +2 -2
  2. firefighter/api/serializers.py +18 -0
  3. firefighter/api/views/incidents.py +3 -0
  4. firefighter/confluence/models.py +66 -6
  5. firefighter/confluence/signals/incident_updated.py +8 -26
  6. firefighter/firefighter/settings/components/jira_app.py +33 -0
  7. firefighter/incidents/admin.py +3 -0
  8. firefighter/incidents/models/impact.py +3 -5
  9. firefighter/incidents/models/incident.py +24 -9
  10. firefighter/incidents/views/views.py +2 -0
  11. firefighter/jira_app/admin.py +15 -1
  12. firefighter/jira_app/apps.py +3 -0
  13. firefighter/jira_app/client.py +151 -3
  14. firefighter/jira_app/management/__init__.py +1 -0
  15. firefighter/jira_app/management/commands/__init__.py +1 -0
  16. firefighter/jira_app/migrations/0002_add_jira_postmortem_model.py +71 -0
  17. firefighter/jira_app/models.py +50 -0
  18. firefighter/jira_app/service_postmortem.py +292 -0
  19. firefighter/jira_app/signals/__init__.py +10 -0
  20. firefighter/jira_app/signals/incident_key_events_updated.py +88 -0
  21. firefighter/jira_app/signals/postmortem_created.py +155 -0
  22. firefighter/jira_app/templates/jira/postmortem/impact.txt +12 -0
  23. firefighter/jira_app/templates/jira/postmortem/incident_summary.txt +17 -0
  24. firefighter/jira_app/templates/jira/postmortem/mitigation_actions.txt +9 -0
  25. firefighter/jira_app/templates/jira/postmortem/root_causes.txt +12 -0
  26. firefighter/jira_app/templates/jira/postmortem/timeline.txt +7 -0
  27. firefighter/raid/signals/incident_updated.py +31 -11
  28. firefighter/slack/messages/slack_messages.py +39 -3
  29. firefighter/slack/signals/postmortem_created.py +51 -3
  30. firefighter/slack/views/modals/closure_reason.py +15 -0
  31. firefighter/slack/views/modals/key_event_message.py +9 -0
  32. firefighter/slack/views/modals/postmortem.py +32 -40
  33. firefighter/slack/views/modals/update_status.py +7 -1
  34. {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/METADATA +1 -1
  35. {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/RECORD +50 -31
  36. {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/WHEEL +1 -1
  37. firefighter_tests/test_api/test_renderer.py +41 -0
  38. firefighter_tests/test_incidents/test_models/test_incident_model.py +29 -0
  39. firefighter_tests/test_jira_app/test_incident_key_events_sync.py +112 -0
  40. firefighter_tests/test_jira_app/test_models.py +138 -0
  41. firefighter_tests/test_jira_app/test_postmortem_issue_link.py +201 -0
  42. firefighter_tests/test_jira_app/test_postmortem_service.py +416 -0
  43. firefighter_tests/test_jira_app/test_timeline_template.py +135 -0
  44. firefighter_tests/test_raid/test_raid_signals.py +50 -8
  45. firefighter_tests/test_slack/messages/test_slack_messages.py +112 -23
  46. firefighter_tests/test_slack/views/modals/test_closure_reason_modal.py +18 -2
  47. firefighter_tests/test_slack/views/modals/test_key_event_message.py +30 -0
  48. firefighter_tests/test_slack/views/modals/test_update_status.py +161 -129
  49. {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/entry_points.txt +0 -0
  50. {firefighter_incident-0.0.22.dist-info → firefighter_incident-0.0.23.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,135 @@
1
+ """Tests for Jira post-mortem timeline template rendering."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import pytest
8
+ from django.template.loader import render_to_string
9
+ from django.utils import timezone
10
+
11
+ from firefighter.incidents.enums import IncidentStatus
12
+ from firefighter.incidents.factories import IncidentFactory, UserFactory
13
+ from firefighter.incidents.models.incident_update import IncidentUpdate
14
+
15
+ if TYPE_CHECKING:
16
+ from firefighter.incidents.models.incident import Incident
17
+ from firefighter.incidents.models.user import User
18
+
19
+
20
+ @pytest.mark.django_db
21
+ class TestTimelineTemplate:
22
+ """Test timeline template rendering with chronological ordering."""
23
+
24
+ @staticmethod
25
+ def test_timeline_includes_key_events_and_status_changes() -> None:
26
+ """Test that timeline includes both key events and status changes in chronological order."""
27
+ # Create a user
28
+ user: User = UserFactory.create()
29
+
30
+ # Create an incident
31
+ incident: Incident = IncidentFactory.create(
32
+ _status=IncidentStatus.POST_MORTEM,
33
+ created_by=user,
34
+ )
35
+
36
+ # Create key events and status changes at different times
37
+ base_time = timezone.now()
38
+
39
+ # Event 1: detected (key event) - earliest
40
+ IncidentUpdate.objects.create(
41
+ incident=incident,
42
+ event_type="detected",
43
+ event_ts=base_time,
44
+ created_by=user,
45
+ message="Issue was detected",
46
+ )
47
+
48
+ # Event 2: status change to INVESTIGATING - middle
49
+ IncidentUpdate.objects.create(
50
+ incident=incident,
51
+ _status=IncidentStatus.INVESTIGATING,
52
+ event_ts=base_time + timezone.timedelta(minutes=5),
53
+ created_by=user,
54
+ )
55
+
56
+ # Event 3: started (key event) - later
57
+ IncidentUpdate.objects.create(
58
+ incident=incident,
59
+ event_type="started",
60
+ event_ts=base_time + timezone.timedelta(minutes=10),
61
+ created_by=user,
62
+ message="Investigation started",
63
+ )
64
+
65
+ # Event 4: status change to MITIGATING - latest
66
+ IncidentUpdate.objects.create(
67
+ incident=incident,
68
+ _status=IncidentStatus.MITIGATING,
69
+ event_ts=base_time + timezone.timedelta(minutes=15),
70
+ created_by=user,
71
+ )
72
+
73
+ # Render the timeline template
74
+ timeline_content = render_to_string(
75
+ "jira/postmortem/timeline.txt",
76
+ {"incident": incident},
77
+ )
78
+
79
+ # Verify timeline contains all events
80
+ assert "Incident created" in timeline_content
81
+ assert "Key event: Detected" in timeline_content
82
+ assert "Issue was detected" in timeline_content
83
+ assert "Status changed to: Investigating" in timeline_content
84
+ assert "Key event: Started" in timeline_content
85
+ assert "Investigation started" in timeline_content
86
+ assert "Status changed to: Mitigating" in timeline_content
87
+
88
+ # Verify chronological order by checking positions in the timeline
89
+ # The events should appear in this order:
90
+ # 1. Incident created
91
+ # 2. detected (key event)
92
+ # 3. INVESTIGATING (status change)
93
+ # 4. started (key event)
94
+ # 5. MITIGATING (status change)
95
+ created_pos = timeline_content.find("Incident created")
96
+ detected_pos = timeline_content.find("Key event: Detected")
97
+ investigating_pos = timeline_content.find("Status changed to: Investigating")
98
+ started_pos = timeline_content.find("Key event: Started")
99
+ mitigating_pos = timeline_content.find("Status changed to: Mitigating")
100
+
101
+ assert created_pos < detected_pos < investigating_pos < started_pos < mitigating_pos, (
102
+ "Events are not in chronological order"
103
+ )
104
+
105
+ @staticmethod
106
+ def test_timeline_handles_key_events_without_message() -> None:
107
+ """Test that timeline handles key events that have no message."""
108
+ # Create a user
109
+ user: User = UserFactory.create()
110
+
111
+ # Create an incident
112
+ incident: Incident = IncidentFactory.create(
113
+ _status=IncidentStatus.POST_MORTEM,
114
+ created_by=user,
115
+ )
116
+
117
+ # Create a key event without message
118
+ IncidentUpdate.objects.create(
119
+ incident=incident,
120
+ event_type="detected",
121
+ event_ts=timezone.now(),
122
+ created_by=user,
123
+ message=None,
124
+ )
125
+
126
+ # Render the timeline template
127
+ timeline_content = render_to_string(
128
+ "jira/postmortem/timeline.txt",
129
+ {"incident": incident},
130
+ )
131
+
132
+ # Verify the key event appears without a dash for empty message
133
+ assert "Key event: Detected" in timeline_content
134
+ # Should not have " - " when there's no message
135
+ assert "Key event: Detected -" not in timeline_content
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  from unittest.mock import Mock, patch
6
6
 
7
7
  import pytest
8
+ from django.test import override_settings
8
9
 
9
10
  from firefighter.incidents.enums import IncidentStatus
10
11
  from firefighter.incidents.models.incident_update import IncidentUpdate
@@ -19,11 +20,13 @@ class TestIncidentUpdatedCloseJiraTicket:
19
20
 
20
21
  @patch("firefighter.raid.signals.incident_updated.client.close_issue")
21
22
  def test_close_jira_ticket_when_status_changes_to_mitigated(
22
- self, mock_close_issue: Mock, incident_factory, user_factory, jira_ticket_factory
23
+ self, mock_close_issue: Mock, incident_factory, user_factory, jira_ticket_factory, priority_factory
23
24
  ) -> None:
24
- """Test that Jira ticket is closed when incident status changes to MITIGATED."""
25
+ """Test that Jira ticket is closed when incident status changes to MITIGATED for P3+."""
25
26
  user = user_factory()
26
- incident = incident_factory(created_by=user)
27
+ # Create P3 priority (no postmortem needed)
28
+ p3_priority = priority_factory(value=3, name="P3", needs_postmortem=False)
29
+ incident = incident_factory(created_by=user, priority=p3_priority)
27
30
  jira_ticket = jira_ticket_factory(incident=incident)
28
31
  incident.jira_ticket = jira_ticket
29
32
 
@@ -41,14 +44,53 @@ class TestIncidentUpdatedCloseJiraTicket:
41
44
  updated_fields=["_status"],
42
45
  )
43
46
 
44
- # Verify close_issue was called
47
+ # Verify close_issue was called for P3+ incidents
45
48
  mock_close_issue.assert_called_once_with(issue_id=jira_ticket.id)
46
49
 
50
+ @override_settings(ENABLE_JIRA_POSTMORTEM=True)
51
+ @patch("firefighter.raid.signals.incident_updated.client.close_issue")
52
+ def test_do_not_close_jira_ticket_when_p1_mitigated(
53
+ self, mock_close_issue: Mock, incident_factory, user_factory, jira_ticket_factory, priority_factory, environment_factory
54
+ ) -> None:
55
+ """Test that Jira ticket is NOT closed when P1 incident status changes to MITIGATED.
56
+
57
+ For P1/P2 incidents requiring postmortem, the ticket should stay open through
58
+ MITIGATED and POST_MORTEM phases, closing only at CLOSED.
59
+ """
60
+ user = user_factory()
61
+ # Create P1 priority (needs postmortem)
62
+ p1_priority = priority_factory(value=1, name="P1", needs_postmortem=True)
63
+ prd_env = environment_factory(value="PRD", name="Production")
64
+ incident = incident_factory(created_by=user, priority=p1_priority, environment=prd_env)
65
+ jira_ticket = jira_ticket_factory(incident=incident)
66
+ incident.jira_ticket = jira_ticket
67
+
68
+ incident_update = IncidentUpdate(
69
+ incident=incident,
70
+ status=IncidentStatus.MITIGATED,
71
+ created_by=user,
72
+ )
73
+
74
+ # Call the signal handler
75
+ incident_updated_close_ticket_when_mitigated_or_postmortem(
76
+ sender="update_status",
77
+ incident=incident,
78
+ incident_update=incident_update,
79
+ updated_fields=["_status"],
80
+ )
81
+
82
+ # Verify close_issue was NOT called - P1 needs to go through postmortem first
83
+ mock_close_issue.assert_not_called()
84
+
47
85
  @patch("firefighter.raid.signals.incident_updated.client.close_issue")
48
- def test_close_jira_ticket_when_status_changes_to_postmortem(
86
+ def test_do_not_close_jira_ticket_when_status_changes_to_postmortem(
49
87
  self, mock_close_issue: Mock, incident_factory, user_factory, jira_ticket_factory
50
88
  ) -> None:
51
- """Test that Jira ticket is closed when incident status changes to POST_MORTEM."""
89
+ """Test that Jira ticket is NOT closed when incident status changes to POST_MORTEM.
90
+
91
+ The ticket should remain open during the post-mortem phase and only close
92
+ when the incident reaches CLOSED status.
93
+ """
52
94
  user = user_factory()
53
95
  incident = incident_factory(created_by=user)
54
96
  jira_ticket = jira_ticket_factory(incident=incident)
@@ -68,8 +110,8 @@ class TestIncidentUpdatedCloseJiraTicket:
68
110
  updated_fields=["_status"],
69
111
  )
70
112
 
71
- # Verify close_issue was called
72
- mock_close_issue.assert_called_once_with(issue_id=jira_ticket.id)
113
+ # Verify close_issue was NOT called - ticket stays open during PM
114
+ mock_close_issue.assert_not_called()
73
115
 
74
116
  @patch("firefighter.raid.signals.incident_updated.client.close_issue")
75
117
  def test_close_jira_ticket_when_status_changes_to_closed(
@@ -5,6 +5,7 @@ import pytest
5
5
  from firefighter.incidents.enums import IncidentStatus
6
6
  from firefighter.incidents.factories import IncidentFactory, UserFactory
7
7
  from firefighter.incidents.models import IncidentUpdate
8
+ from firefighter.jira_app.models import JiraPostMortem
8
9
  from firefighter.slack.factories import IncidentChannelFactory
9
10
  from firefighter.slack.messages.slack_messages import (
10
11
  SlackMessageDeployWarning,
@@ -12,6 +13,11 @@ from firefighter.slack.messages.slack_messages import (
12
13
  SlackMessageIncidentStatusUpdated,
13
14
  )
14
15
 
16
+ try:
17
+ from firefighter.confluence.models import PostMortem
18
+ except (ImportError, AttributeError):
19
+ PostMortem = None
20
+
15
21
 
16
22
  @pytest.mark.django_db
17
23
  class TestSlackMessageIncidentStatusUpdated:
@@ -31,9 +37,7 @@ class TestSlackMessageIncidentStatusUpdated:
31
37
  # Create an IncidentUpdate
32
38
  user = UserFactory.create()
33
39
  incident_update = IncidentUpdate.objects.create(
34
- incident=incident,
35
- status=IncidentStatus.MITIGATED,
36
- created_by=user
40
+ incident=incident, status=IncidentStatus.MITIGATED, created_by=user
37
41
  )
38
42
 
39
43
  # Create the message with status_changed=True and in_channel=False
@@ -41,7 +45,7 @@ class TestSlackMessageIncidentStatusUpdated:
41
45
  incident=incident,
42
46
  incident_update=incident_update,
43
47
  in_channel=False,
44
- status_changed=True
48
+ status_changed=True,
45
49
  )
46
50
 
47
51
  # Verify the title contains "Mitigated" (the status label)
@@ -61,9 +65,7 @@ class TestSlackMessageIncidentStatusUpdated:
61
65
  # Create an IncidentUpdate
62
66
  user = UserFactory.create()
63
67
  incident_update = IncidentUpdate.objects.create(
64
- incident=incident,
65
- status=IncidentStatus.INVESTIGATING,
66
- created_by=user
68
+ incident=incident, status=IncidentStatus.INVESTIGATING, created_by=user
67
69
  )
68
70
 
69
71
  # Create the message with status_changed=True and in_channel=False
@@ -71,7 +73,7 @@ class TestSlackMessageIncidentStatusUpdated:
71
73
  incident=incident,
72
74
  incident_update=incident_update,
73
75
  in_channel=False,
74
- status_changed=True
76
+ status_changed=True,
75
77
  )
76
78
 
77
79
  # Verify the title does NOT contain the MITIGATED-specific format
@@ -93,9 +95,7 @@ class TestSlackMessageIncidentStatusUpdated:
93
95
  # Create an IncidentUpdate with status change
94
96
  user = UserFactory.create()
95
97
  incident_update = IncidentUpdate.objects.create(
96
- incident=incident,
97
- status=IncidentStatus.CLOSED,
98
- created_by=user
98
+ incident=incident, status=IncidentStatus.CLOSED, created_by=user
99
99
  )
100
100
 
101
101
  # Create the message with in_channel=True (normal case for in-channel messages)
@@ -103,7 +103,7 @@ class TestSlackMessageIncidentStatusUpdated:
103
103
  incident=incident,
104
104
  incident_update=incident_update,
105
105
  in_channel=True,
106
- status_changed=True
106
+ status_changed=True,
107
107
  )
108
108
 
109
109
  # Get the blocks
@@ -118,7 +118,9 @@ class TestSlackMessageIncidentStatusUpdated:
118
118
 
119
119
  # If there is a status update block, verify it has NO accessory (button)
120
120
  if status_update_block:
121
- assert status_update_block.accessory is None, "Update button should not be present when incident is CLOSED"
121
+ assert status_update_block.accessory is None, (
122
+ "Update button should not be present when incident is CLOSED"
123
+ )
122
124
 
123
125
  def test_update_button_shown_when_incident_not_closed(self) -> None:
124
126
  """Test that Update Status button IS displayed when incident is NOT CLOSED."""
@@ -131,9 +133,7 @@ class TestSlackMessageIncidentStatusUpdated:
131
133
  # Create an IncidentUpdate
132
134
  user = UserFactory.create()
133
135
  incident_update = IncidentUpdate.objects.create(
134
- incident=incident,
135
- status=IncidentStatus.INVESTIGATING,
136
- created_by=user
136
+ incident=incident, status=IncidentStatus.INVESTIGATING, created_by=user
137
137
  )
138
138
 
139
139
  # Create the message with in_channel=True
@@ -141,7 +141,7 @@ class TestSlackMessageIncidentStatusUpdated:
141
141
  incident=incident,
142
142
  incident_update=incident_update,
143
143
  in_channel=True,
144
- status_changed=True
144
+ status_changed=True,
145
145
  )
146
146
 
147
147
  # Get the blocks
@@ -156,7 +156,9 @@ class TestSlackMessageIncidentStatusUpdated:
156
156
 
157
157
  # Verify the block has an accessory (Update button)
158
158
  assert status_update_block is not None, "Should have a status update block"
159
- assert status_update_block.accessory is not None, "Update button should be present when incident is not CLOSED"
159
+ assert status_update_block.accessory is not None, (
160
+ "Update button should be present when incident is not CLOSED"
161
+ )
160
162
  assert status_update_block.accessory.text.text == "Update"
161
163
 
162
164
 
@@ -183,7 +185,7 @@ class TestSlackMessageDeployWarning:
183
185
 
184
186
  # The first block should be a HeaderBlock with "(Mitigated)" in the text
185
187
  header_block = blocks[0]
186
- header_text = header_block.text.text # type: ignore[attr-defined]
188
+ header_text = header_block.text.text
187
189
 
188
190
  assert "(Mitigated)" in header_text
189
191
  assert ":warning:" in header_text
@@ -204,7 +206,7 @@ class TestSlackMessageDeployWarning:
204
206
 
205
207
  # The first block should be a HeaderBlock WITHOUT "(Mitigated)"
206
208
  header_block = blocks[0]
207
- header_text = header_block.text.text # type: ignore[attr-defined]
209
+ header_text = header_block.text.text
208
210
 
209
211
  assert "(Mitigated)" not in header_text
210
212
  assert ":warning:" in header_text
@@ -283,7 +285,10 @@ class TestSlackMessageIncidentDeclaredAnnouncement:
283
285
  assert fields_block is not None, "Should have a block with fields"
284
286
 
285
287
  # Convert fields to strings for easier assertion (access .text attribute)
286
- fields_text = " ".join(field.text if hasattr(field, "text") else str(field) for field in fields_block.fields)
288
+ fields_text = " ".join(
289
+ field.text if hasattr(field, "text") else str(field)
290
+ for field in fields_block.fields
291
+ )
287
292
 
288
293
  # Verify custom fields are present
289
294
  assert "Zendesk Ticket" in fields_text
@@ -316,7 +321,10 @@ class TestSlackMessageIncidentDeclaredAnnouncement:
316
321
  assert fields_block is not None, "Should have a block with fields"
317
322
 
318
323
  # Convert fields to strings (access .text attribute)
319
- fields_text = " ".join(field.text if hasattr(field, "text") else str(field) for field in fields_block.fields)
324
+ fields_text = " ".join(
325
+ field.text if hasattr(field, "text") else str(field)
326
+ for field in fields_block.fields
327
+ )
320
328
 
321
329
  # Verify custom fields are NOT present
322
330
  assert "Zendesk Ticket" not in fields_text
@@ -354,7 +362,10 @@ class TestSlackMessageIncidentDeclaredAnnouncement:
354
362
  assert fields_block is not None, "Should have a block with fields"
355
363
 
356
364
  # Convert fields to strings (access .text attribute)
357
- fields_text = " ".join(field.text if hasattr(field, "text") else str(field) for field in fields_block.fields)
365
+ fields_text = " ".join(
366
+ field.text if hasattr(field, "text") else str(field)
367
+ for field in fields_block.fields
368
+ )
358
369
 
359
370
  # Verify only zendesk_ticket_id is present
360
371
  assert "Zendesk Ticket" in fields_text
@@ -365,3 +376,81 @@ class TestSlackMessageIncidentDeclaredAnnouncement:
365
376
  assert "Zoho Desk Ticket" not in fields_text
366
377
  assert "Key Account" not in fields_text
367
378
  assert "Golden List Seller" not in fields_text
379
+
380
+ def test_jira_postmortem_link_displayed_when_present(self) -> None:
381
+ """Test that Jira post-mortem link is displayed when available."""
382
+ # Create an incident
383
+ incident = IncidentFactory.create()
384
+ user = UserFactory.create()
385
+
386
+ # Create a Jira post-mortem for the incident
387
+ jira_pm = JiraPostMortem.objects.create(
388
+ incident=incident,
389
+ jira_issue_key="PM-123",
390
+ jira_issue_id="12345",
391
+ created_by=user,
392
+ )
393
+
394
+ # Create the message
395
+ message = SlackMessageIncidentDeclaredAnnouncement(incident=incident)
396
+
397
+ # Get the blocks
398
+ blocks = message.get_blocks()
399
+
400
+ # Collect all fields from all SectionBlocks
401
+ all_fields = []
402
+ for block in blocks:
403
+ if hasattr(block, "fields") and block.fields:
404
+ all_fields.extend(block.fields)
405
+
406
+ assert len(all_fields) > 0, "Should have blocks with fields"
407
+
408
+ # Convert fields to strings (access .text attribute)
409
+ fields_text = " ".join(
410
+ field.text if hasattr(field, "text") else str(field)
411
+ for field in all_fields
412
+ )
413
+
414
+ # Verify Jira post-mortem link is present (now in separate PM block)
415
+ assert "Jira Post-mortem" in fields_text
416
+ assert "PM-123" in fields_text
417
+ assert jira_pm.issue_url in fields_text
418
+
419
+ def test_confluence_postmortem_link_displayed_when_present(self) -> None:
420
+ """Test that Confluence post-mortem link is displayed when available."""
421
+ if PostMortem is None:
422
+ pytest.skip("Confluence app not installed")
423
+
424
+ # Create an incident
425
+ incident = IncidentFactory.create()
426
+
427
+ # Create a Confluence post-mortem for the incident
428
+ confluence_pm = PostMortem.objects.create(
429
+ incident=incident,
430
+ page_id="123456",
431
+ )
432
+
433
+ # Create the message
434
+ message = SlackMessageIncidentDeclaredAnnouncement(incident=incident)
435
+
436
+ # Get the blocks
437
+ blocks = message.get_blocks()
438
+
439
+ # Find the SectionBlock with fields
440
+ fields_block = None
441
+ for block in blocks:
442
+ if hasattr(block, "fields") and block.fields:
443
+ fields_block = block
444
+ break
445
+
446
+ assert fields_block is not None, "Should have a block with fields"
447
+
448
+ # Convert fields to strings (access .text attribute)
449
+ fields_text = " ".join(
450
+ field.text if hasattr(field, "text") else str(field)
451
+ for field in fields_block.fields
452
+ )
453
+
454
+ # Verify Confluence post-mortem link is present
455
+ assert "Confluence Post-mortem" in fields_text
456
+ assert confluence_pm.page_url in fields_text
@@ -16,7 +16,7 @@ from firefighter.slack.views.modals.closure_reason import ClosureReasonModal
16
16
  class TestClosureReasonModalMessageTabDisabled:
17
17
  """Test ClosureReasonModal handles messages_tab_disabled gracefully."""
18
18
 
19
- def test_closure_reason_handles_messages_tab_disabled(self, caplog: pytest.LogCaptureFixture) -> None:
19
+ def test_closure_reason_handles_messages_tab_disabled(self, caplog: pytest.LogCaptureFixture, mocker) -> None:
20
20
  """Test that messages_tab_disabled error is handled gracefully with warning log."""
21
21
  # Create test data
22
22
  user = UserFactory.build()
@@ -24,6 +24,14 @@ class TestClosureReasonModalMessageTabDisabled:
24
24
  incident = IncidentFactory.build(_status=IncidentStatus.INVESTIGATING, created_by=user)
25
25
  incident.save()
26
26
 
27
+ # Mock can_be_closed to return True so the closure can proceed
28
+ mocker.patch.object(
29
+ type(incident),
30
+ "can_be_closed",
31
+ new_callable=mocker.PropertyMock,
32
+ return_value=(True, [])
33
+ )
34
+
27
35
  # Create modal and mock
28
36
  modal = ClosureReasonModal()
29
37
  ack = MagicMock()
@@ -83,7 +91,7 @@ class TestClosureReasonModalMessageTabDisabled:
83
91
  for record in caplog.records
84
92
  )
85
93
 
86
- def test_closure_reason_reraises_other_slack_errors(self) -> None:
94
+ def test_closure_reason_reraises_other_slack_errors(self, mocker) -> None:
87
95
  """Test that other Slack API errors are re-raised."""
88
96
  # Create test data
89
97
  user = UserFactory.build()
@@ -91,6 +99,14 @@ class TestClosureReasonModalMessageTabDisabled:
91
99
  incident = IncidentFactory.build(_status=IncidentStatus.INVESTIGATING, created_by=user)
92
100
  incident.save()
93
101
 
102
+ # Mock can_be_closed to return True so the closure can proceed
103
+ mocker.patch.object(
104
+ type(incident),
105
+ "can_be_closed",
106
+ new_callable=mocker.PropertyMock,
107
+ return_value=(True, [])
108
+ )
109
+
94
110
  # Create modal and mock
95
111
  modal = ClosureReasonModal()
96
112
  ack = MagicMock()
@@ -0,0 +1,30 @@
1
+ """Tests for Slack key event message view."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from firefighter.slack.views.modals import key_event_message
8
+
9
+
10
+ class TestKeyEventMessageImports:
11
+ """Test that key event message module has necessary imports."""
12
+
13
+ @staticmethod
14
+ def test_signal_imported() -> None:
15
+ """Test that incident_key_events_updated signal is imported."""
16
+ # Verify the signal is imported in the module
17
+ assert hasattr(
18
+ key_event_message, "incident_key_events_updated"
19
+ ), "incident_key_events_updated signal should be imported"
20
+
21
+
22
+ @pytest.mark.django_db
23
+ class TestKeyEventMessageSignalIntegration:
24
+ """Integration tests verifying signal is sent - covered by test_jira_app tests."""
25
+
26
+ # Note: The actual signal sending is tested in:
27
+ # - tests/test_jira_app/test_incident_key_events_sync.py
28
+ # These tests verify the signal handler receives and processes signals correctly.
29
+ # Testing the Slack view's signal sending requires complex Slack mocking,
30
+ # so we rely on manual testing and the signal handler tests instead.