diff --git a/src/dispatch/database/revisions/tenant/versions/2025-03-28_bccbf255d6d1.py b/src/dispatch/database/revisions/tenant/versions/2025-03-28_bccbf255d6d1.py
new file mode 100644
index 000000000000..4c6a119b3e17
--- /dev/null
+++ b/src/dispatch/database/revisions/tenant/versions/2025-03-28_bccbf255d6d1.py
@@ -0,0 +1,35 @@
+"""adds snooze extension oncall service to project
+
+Revision ID: bccbf255d6d1
+Revises: 92a359040b8e
+Create Date: 2025-03-28 13:12:07.514337
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "bccbf255d6d1"
+down_revision = "92a359040b8e"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column(
+ "project", sa.Column("snooze_extension_oncall_service_id", sa.Integer(), nullable=True)
+ )
+ op.create_foreign_key(
+ None, "project", "service", ["snooze_extension_oncall_service_id"], ["id"]
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_constraint(None, "project", type_="foreignkey")
+ op.drop_column("project", "snooze_extension_oncall_service_id")
+ # ### end Alembic commands ###
diff --git a/src/dispatch/plugins/dispatch_slack/case/interactive.py b/src/dispatch/plugins/dispatch_slack/case/interactive.py
index d7a3c1728c30..403b5e8a9533 100644
--- a/src/dispatch/plugins/dispatch_slack/case/interactive.py
+++ b/src/dispatch/plugins/dispatch_slack/case/interactive.py
@@ -74,6 +74,7 @@
case_type_select,
description_input,
entity_select,
+ extension_request_checkbox,
incident_priority_select,
incident_type_select,
project_select,
@@ -106,6 +107,7 @@
from dispatch.project import service as project_service
from dispatch.search.utils import create_filter_expression
from dispatch.service import flows as service_flows
+from dispatch.service.models import Service
from dispatch.signal import service as signal_service
from dispatch.signal.enums import SignalEngagementStatus
from dispatch.signal.models import (
@@ -733,6 +735,7 @@ def snooze_button_click(
title_input(placeholder="A name for your snooze filter."),
description_input(placeholder="Provide a description for your snooze filter."),
relative_date_picker_input(label="Expiration"),
+ extension_request_checkbox(),
]
# not all signals will have entities and slack doesn't like empty selects
@@ -903,11 +906,16 @@ def handle_snooze_submission_event(
)
mfa_enabled = True if mfa_plugin else False
+ form_data: FormData = context["subject"].form_data
+ extension_request_data = form_data.get(DefaultBlockIds.extension_request_checkbox)
+ extension_request_value = extension_request_data[0].value if extension_request_data else None
+ extension_requested = True if extension_request_value == "Yes" else False
+
def _create_snooze_filter(
db_session: Session,
subject: SubjectMetadata,
user: DispatchUser,
- ) -> None:
+ ) -> SignalFilter:
form_data: FormData = subject.form_data
# Get the existing filters for the signal
signal = signal_service.get(db_session=db_session, signal_id=subject.id)
@@ -1005,6 +1013,8 @@ def _create_snooze_filter(
signal=signal,
new_filter=new_filter,
thread_ts=thread_id,
+ extension_requested=extension_requested,
+ oncall_service=new_filter.project.snooze_extension_oncall_service,
)
send_success_modal(
client=client,
@@ -1044,6 +1054,8 @@ def _create_snooze_filter(
signal=signal,
new_filter=new_filter,
thread_ts=thread_id,
+ extension_requested=extension_requested,
+ oncall_service=new_filter.project.snooze_extension_oncall_service,
)
send_success_modal(
client=client,
@@ -1073,6 +1085,24 @@ def _create_snooze_filter(
)
+def get_user_id_from_oncall_service(
+ client: WebClient,
+ db_session: Session,
+ oncall_service: Service | None,
+) -> str | None:
+ if not oncall_service:
+ return None
+
+ oncall_email = service_flows.resolve_oncall(service=oncall_service, db_session=db_session)
+ if oncall_email:
+ # Get the Slack user ID for the current oncall
+ try:
+ return client.users_lookupByEmail(email=oncall_email)["user"]["id"]
+ except SlackApiError:
+ log.error(f"Failed to find Slack user for email: {oncall_email}")
+ return None
+
+
def post_snooze_message(
client: WebClient,
channel: str,
@@ -1081,6 +1111,8 @@ def post_snooze_message(
db_session: Session,
new_filter: SignalFilter,
thread_ts: str | None = None,
+ extension_requested: bool = False,
+ oncall_service: Service | None = None,
):
def extract_entity_ids(expression: list[dict]) -> list[int]:
entity_ids = []
@@ -1105,13 +1137,20 @@ def extract_entity_ids(expression: list[dict]) -> list[int]:
message = (
f":zzz: *New Signal Snooze Added*\n"
- f"• User: {user.email}\n"
+ f"• Created by: {user.email}\n"
f"• Signal: {signal.name}\n"
f"• Snooze Name: {new_filter.name}\n"
f"• Description: {new_filter.description}\n"
f"• Expiration: {new_filter.expiration}\n"
f"• Entities: {entities_text}"
)
+ if extension_requested:
+ message += "\n• *Extension Requested*"
+ if user_id := get_user_id_from_oncall_service(
+ client=client, db_session=db_session, oncall_service=oncall_service
+ ):
+ message += f" - notifying oncall: <@{user_id}>"
+
client.chat_postMessage(channel=channel, text=message, thread_ts=thread_ts)
diff --git a/src/dispatch/plugins/dispatch_slack/fields.py b/src/dispatch/plugins/dispatch_slack/fields.py
index d911f6326a31..ad0efa955c29 100644
--- a/src/dispatch/plugins/dispatch_slack/fields.py
+++ b/src/dispatch/plugins/dispatch_slack/fields.py
@@ -4,6 +4,7 @@
from typing import List
from blockkit import (
+ Checkboxes,
DatePicker,
Input,
MultiExternalSelect,
@@ -65,6 +66,7 @@ class DefaultBlockIds(DispatchEnum):
# signals
signal_definition_select = "signal-definition-select"
+ extension_request_checkbox = "extension_request_checkbox"
# tags
tags_multi_select = "tag-multi-select"
@@ -102,6 +104,7 @@ class DefaultActionIds(DispatchEnum):
# signals
signal_definition_select = "signal-definition-select"
+ extension_request_checkbox = "extension_request_checkbox"
# tags
tags_multi_select = "tag-multi-select"
@@ -740,3 +743,24 @@ def signal_definition_select(
label=label,
**kwargs,
)
+
+
+def extension_request_checkbox(
+ action_id: str = DefaultActionIds.extension_request_checkbox,
+ block_id: str = DefaultBlockIds.extension_request_checkbox,
+ label: str = "Request longer expiration",
+ **kwargs,
+):
+ options = [
+ PlainOption(
+ text=("Check this box to request an expiration longer than 2 weeks."),
+ value="Yes",
+ )
+ ]
+ return Input(
+ block_id=block_id,
+ element=Checkboxes(options=options, action_id=action_id),
+ label=label,
+ optional=True,
+ **kwargs,
+ )
diff --git a/src/dispatch/project/models.py b/src/dispatch/project/models.py
index d7d974d3848a..6dc4edafefd3 100644
--- a/src/dispatch/project/models.py
+++ b/src/dispatch/project/models.py
@@ -72,6 +72,13 @@ class Project(Base):
report_incident_title_hint = Column(String, nullable=True)
report_incident_description_hint = Column(String, nullable=True)
+ snooze_extension_oncall_service_id = Column(Integer, nullable=True)
+ snooze_extension_oncall_service = relationship(
+ "Service",
+ foreign_keys=[snooze_extension_oncall_service_id],
+ primaryjoin="Service.id == Project.snooze_extension_oncall_service_id",
+ )
+
@hybrid_property
def slug(self):
return slugify(self.name)
@@ -81,6 +88,15 @@ def slug(self):
)
+class Service(DispatchBase):
+ id: PrimaryKey
+ description: Optional[str] = Field(None, nullable=True)
+ external_id: str
+ is_active: Optional[bool] = None
+ name: NameStr
+ type: Optional[str] = Field(None, nullable=True)
+
+
class ProjectBase(DispatchBase):
id: Optional[PrimaryKey]
name: NameStr
@@ -105,6 +121,7 @@ class ProjectBase(DispatchBase):
report_incident_instructions: Optional[str] = Field(None, nullable=True)
report_incident_title_hint: Optional[str] = Field(None, nullable=True)
report_incident_description_hint: Optional[str] = Field(None, nullable=True)
+ snooze_extension_oncall_service: Optional[Service]
class ProjectCreate(ProjectBase):
@@ -116,6 +133,7 @@ class ProjectUpdate(ProjectBase):
send_weekly_reports: Optional[bool] = Field(False, nullable=True)
weekly_report_notification_id: Optional[int] = Field(None, nullable=True)
stable_priority_id: Optional[int]
+ snooze_extension_oncall_service_id: Optional[int]
class ProjectRead(ProjectBase):
diff --git a/src/dispatch/static/dispatch/src/project/NewEditSheet.vue b/src/dispatch/static/dispatch/src/project/NewEditSheet.vue
index 33ffcb963cc6..30a93c5a9f15 100644
--- a/src/dispatch/static/dispatch/src/project/NewEditSheet.vue
+++ b/src/dispatch/static/dispatch/src/project/NewEditSheet.vue
@@ -130,6 +130,15 @@
name="Owner Conversation"
/>
+
+
+
+
+
Alternative folder structure
{
diff --git a/tests/plugins/dispatch_slack/case/test_interactive.py b/tests/plugins/dispatch_slack/case/test_interactive.py
new file mode 100644
index 000000000000..ec5322ffeb01
--- /dev/null
+++ b/tests/plugins/dispatch_slack/case/test_interactive.py
@@ -0,0 +1,432 @@
+from datetime import datetime, timedelta, timezone
+from unittest.mock import MagicMock, patch
+
+import pytest
+from slack_sdk.errors import SlackApiError
+
+from dispatch.auth.models import MfaChallengeStatus
+from dispatch.plugins.dispatch_slack.case.interactive import (
+ handle_snooze_submission_event,
+ get_user_id_from_oncall_service,
+ post_snooze_message,
+)
+
+
+class TestHandleSnoozeSubmissionEvent:
+ @pytest.fixture
+ def mock_ack(self):
+ return MagicMock()
+
+ @pytest.fixture
+ def mock_body(self):
+ return {"view": {"id": "view_id"}}
+
+ @pytest.fixture
+ def mock_client(self):
+ return MagicMock()
+
+ @pytest.fixture
+ def mock_context(self):
+ relative_date_picker = MagicMock()
+ relative_date_picker.value = "1 day, 0:00:00"
+
+ extension_checkbox_item = MagicMock()
+ extension_checkbox_item.value = "Yes"
+
+ entity_select_item = MagicMock()
+ entity_select_item.value = "789"
+
+ return {
+ "subject": MagicMock(
+ id="123",
+ project_id="456",
+ channel_id="C123",
+ thread_id="T123",
+ form_data={
+ "title-input": "Test Snooze",
+ "description-input": "Test Description",
+ "relative-date-picker-input": relative_date_picker,
+ "extension-request-checkbox": [extension_checkbox_item],
+ "entity-select": [entity_select_item],
+ },
+ dict=MagicMock(return_value={}),
+ )
+ }
+
+ @pytest.fixture
+ def mock_db_session(self):
+ return MagicMock()
+
+ @pytest.fixture
+ def mock_user(self):
+ return MagicMock(email="test@example.com")
+
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.plugin_service")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.signal_service")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.post_snooze_message")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.send_success_modal")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.project_service")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.SignalFilterCreate")
+ def test_handle_snooze_submission_event_no_mfa(
+ self,
+ mock_signal_filter_create,
+ mock_project_service,
+ mock_send_success_modal,
+ mock_post_snooze_message,
+ mock_signal_service,
+ mock_plugin_service,
+ mock_ack,
+ mock_body,
+ mock_client,
+ mock_context,
+ mock_db_session,
+ mock_user,
+ ):
+ # Setup
+ mock_plugin_service.get_active_instance.return_value = None
+ mock_signal = MagicMock()
+ mock_signal_service.get.return_value = mock_signal
+ mock_new_filter = MagicMock()
+ mock_signal_service.create_signal_filter.return_value = mock_new_filter
+
+ # Mock the project
+ mock_project = MagicMock()
+ mock_project.id = "456"
+ mock_project.name = "Test Project"
+ mock_project.organization.slug = "test-org"
+ mock_project_service.get.return_value = mock_project
+
+ # Mock the SignalFilterCreate to avoid validation errors
+ mock_filter_instance = MagicMock()
+ mock_signal_filter_create.return_value = mock_filter_instance
+
+ # Execute
+ handle_snooze_submission_event(
+ ack=mock_ack,
+ body=mock_body,
+ client=mock_client,
+ context=mock_context,
+ db_session=mock_db_session,
+ user=mock_user,
+ )
+
+ # Assert
+ mock_signal_service.create_signal_filter.assert_called_once()
+ # The implementation calls get twice, so we don't assert the exact number of calls
+ assert mock_signal_service.get.call_count >= 1
+ mock_post_snooze_message.assert_called_once()
+ mock_send_success_modal.assert_called_once()
+
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.plugin_service")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.signal_service")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.post_snooze_message")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.send_success_modal")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.project_service")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.SignalFilterCreate")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.ack_mfa_required_submission_event")
+ def test_handle_snooze_submission_event_with_mfa_approved(
+ self,
+ mock_ack_mfa_required,
+ mock_signal_filter_create,
+ mock_project_service,
+ mock_send_success_modal,
+ mock_post_snooze_message,
+ mock_signal_service,
+ mock_plugin_service,
+ mock_ack,
+ mock_body,
+ mock_client,
+ mock_context,
+ mock_db_session,
+ mock_user,
+ ):
+ # Setup
+ mock_mfa_plugin = MagicMock()
+ mock_mfa_plugin.instance.create_mfa_challenge.return_value = (MagicMock(), "challenge_url")
+ mock_mfa_plugin.instance.wait_for_challenge.return_value = MfaChallengeStatus.APPROVED
+ mock_plugin_service.get_active_instance.return_value = mock_mfa_plugin
+
+ mock_signal = MagicMock()
+ mock_signal_service.get.return_value = mock_signal
+ mock_new_filter = MagicMock()
+ mock_signal_service.create_signal_filter.return_value = mock_new_filter
+
+ # Mock the project
+ mock_project = MagicMock()
+ mock_project.id = "456"
+ mock_project.name = "Test Project"
+ mock_project.organization.slug = "test-org"
+ mock_project_service.get.return_value = mock_project
+
+ # Mock the SignalFilterCreate to avoid validation errors
+ mock_filter_instance = MagicMock()
+ mock_signal_filter_create.return_value = mock_filter_instance
+
+ # Execute
+ handle_snooze_submission_event(
+ ack=mock_ack,
+ body=mock_body,
+ client=mock_client,
+ context=mock_context,
+ db_session=mock_db_session,
+ user=mock_user,
+ )
+
+ # Assert
+ mock_mfa_plugin.instance.create_mfa_challenge.assert_called_once()
+ mock_mfa_plugin.instance.wait_for_challenge.assert_called_once()
+ mock_signal_service.create_signal_filter.assert_called_once()
+ # The implementation calls get twice, so we don't assert the exact number of calls
+ assert mock_signal_service.get.call_count >= 1
+ mock_post_snooze_message.assert_called_once()
+ mock_send_success_modal.assert_called_once()
+ assert mock_user.last_mfa_time is not None
+
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.plugin_service")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.signal_service")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.post_snooze_message")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.send_success_modal")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.ack_mfa_required_submission_event")
+ def test_handle_snooze_submission_event_with_mfa_denied(
+ self,
+ mock_ack_mfa,
+ mock_send_success_modal,
+ mock_post_snooze_message,
+ mock_signal_service,
+ mock_plugin_service,
+ mock_ack,
+ mock_body,
+ mock_client,
+ mock_context,
+ mock_db_session,
+ mock_user,
+ ):
+ # Setup
+ mock_mfa_plugin = MagicMock()
+ mock_mfa_plugin.instance.create_mfa_challenge.return_value = (MagicMock(), "challenge_url")
+ mock_mfa_plugin.instance.wait_for_challenge.return_value = MfaChallengeStatus.DENIED
+ mock_plugin_service.get_active_instance.return_value = mock_mfa_plugin
+
+ # Execute
+ handle_snooze_submission_event(
+ ack=mock_ack,
+ body=mock_body,
+ client=mock_client,
+ context=mock_context,
+ db_session=mock_db_session,
+ user=mock_user,
+ )
+
+ # Assert
+ mock_mfa_plugin.instance.create_mfa_challenge.assert_called_once()
+ mock_mfa_plugin.instance.wait_for_challenge.assert_called_once()
+ mock_signal_service.create_signal_filter.assert_not_called()
+ mock_signal_service.get.assert_not_called()
+ mock_post_snooze_message.assert_not_called()
+ mock_client.views_update.assert_called_once()
+
+
+class TestGetUserIdFromOncallService:
+ @pytest.fixture
+ def mock_client(self):
+ return MagicMock()
+
+ @pytest.fixture
+ def mock_db_session(self):
+ return MagicMock()
+
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.service_flows")
+ def test_get_user_id_from_oncall_service_none_service(
+ self, mock_service_flows, mock_client, mock_db_session
+ ):
+ # Setup
+ oncall_service = None
+
+ # Execute
+ result = get_user_id_from_oncall_service(
+ client=mock_client, db_session=mock_db_session, oncall_service=oncall_service
+ )
+
+ # Assert
+ assert result is None
+ mock_service_flows.resolve_oncall.assert_not_called()
+ mock_client.users_lookupByEmail.assert_not_called()
+
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.service_flows")
+ def test_get_user_id_from_oncall_service_no_email(
+ self, mock_service_flows, mock_client, mock_db_session
+ ):
+ # Setup
+ oncall_service = MagicMock()
+ mock_service_flows.resolve_oncall.return_value = None
+
+ # Execute
+ result = get_user_id_from_oncall_service(
+ client=mock_client, db_session=mock_db_session, oncall_service=oncall_service
+ )
+
+ # Assert
+ assert result is None
+ mock_service_flows.resolve_oncall.assert_called_once_with(
+ service=oncall_service, db_session=mock_db_session
+ )
+ mock_client.users_lookupByEmail.assert_not_called()
+
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.service_flows")
+ def test_get_user_id_from_oncall_service_success(
+ self, mock_service_flows, mock_client, mock_db_session
+ ):
+ # Setup
+ oncall_service = MagicMock()
+ mock_service_flows.resolve_oncall.return_value = "oncall@example.com"
+ mock_client.users_lookupByEmail.return_value = {"user": {"id": "U123"}}
+
+ # Execute
+ result = get_user_id_from_oncall_service(
+ client=mock_client, db_session=mock_db_session, oncall_service=oncall_service
+ )
+
+ # Assert
+ assert result == "U123"
+ mock_service_flows.resolve_oncall.assert_called_once_with(
+ service=oncall_service, db_session=mock_db_session
+ )
+ mock_client.users_lookupByEmail.assert_called_once_with(email="oncall@example.com")
+
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.service_flows")
+ def test_get_user_id_from_oncall_service_slack_error(
+ self, mock_service_flows, mock_client, mock_db_session
+ ):
+ # Setup
+ oncall_service = MagicMock()
+ mock_service_flows.resolve_oncall.return_value = "oncall@example.com"
+ mock_client.users_lookupByEmail.side_effect = SlackApiError("Error", {"error": "not_found"})
+
+ # Execute
+ result = get_user_id_from_oncall_service(
+ client=mock_client, db_session=mock_db_session, oncall_service=oncall_service
+ )
+
+ # Assert
+ assert result is None
+ mock_service_flows.resolve_oncall.assert_called_once_with(
+ service=oncall_service, db_session=mock_db_session
+ )
+ mock_client.users_lookupByEmail.assert_called_once_with(email="oncall@example.com")
+
+
+class TestPostSnoozeMessage:
+ @pytest.fixture
+ def mock_client(self):
+ return MagicMock()
+
+ @pytest.fixture
+ def mock_db_session(self):
+ return MagicMock()
+
+ @pytest.fixture
+ def mock_user(self):
+ return MagicMock(email="test@example.com")
+
+ @pytest.fixture
+ def mock_signal(self):
+ return MagicMock(name="Test Signal")
+
+ @pytest.fixture
+ def mock_new_filter(self):
+ return MagicMock(
+ name="Test Filter",
+ description="Test Description",
+ expiration=datetime.now(tz=timezone.utc) + timedelta(days=1),
+ expression=[],
+ )
+
+ def test_post_snooze_message_no_entities(
+ self, mock_client, mock_db_session, mock_user, mock_signal, mock_new_filter
+ ):
+ # Setup
+ channel = "C123"
+ thread_ts = "T123"
+ extension_requested = False
+ oncall_service = None
+
+ # Execute
+ post_snooze_message(
+ client=mock_client,
+ channel=channel,
+ user=mock_user,
+ signal=mock_signal,
+ db_session=mock_db_session,
+ new_filter=mock_new_filter,
+ thread_ts=thread_ts,
+ extension_requested=extension_requested,
+ oncall_service=oncall_service,
+ )
+
+ # Assert
+ mock_client.chat_postMessage.assert_called_once()
+ args, kwargs = mock_client.chat_postMessage.call_args
+ assert kwargs["channel"] == channel
+ assert kwargs["thread_ts"] == thread_ts
+ assert "New Signal Snooze Added" in kwargs["text"]
+ assert "Entities: All" in kwargs["text"]
+ assert "Extension Requested" not in kwargs["text"]
+
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.entity_service")
+ @patch("dispatch.plugins.dispatch_slack.case.interactive.get_user_id_from_oncall_service")
+ def test_post_snooze_message_with_entities_and_extension(
+ self,
+ mock_get_user_id,
+ mock_entity_service,
+ mock_client,
+ mock_db_session,
+ mock_user,
+ mock_signal,
+ mock_new_filter,
+ ):
+ # Setup
+ channel = "C123"
+ thread_ts = "T123"
+ extension_requested = True
+ oncall_service = MagicMock()
+
+ # Setup entity expression
+ mock_new_filter.expression = [
+ {
+ "or": [
+ {"model": "Entity", "field": "id", "value": "123"},
+ {"model": "Entity", "field": "id", "value": "456"},
+ ]
+ }
+ ]
+
+ # Setup entities
+ mock_entity1 = MagicMock(id=123, value="entity1")
+ mock_entity2 = MagicMock(id=456, value="entity2")
+ mock_entity_service.get.side_effect = [mock_entity1, mock_entity2]
+
+ # Setup oncall user
+ mock_get_user_id.return_value = "U123"
+
+ # Execute
+ post_snooze_message(
+ client=mock_client,
+ channel=channel,
+ user=mock_user,
+ signal=mock_signal,
+ db_session=mock_db_session,
+ new_filter=mock_new_filter,
+ thread_ts=thread_ts,
+ extension_requested=extension_requested,
+ oncall_service=oncall_service,
+ )
+
+ # Assert
+ mock_client.chat_postMessage.assert_called_once()
+ args, kwargs = mock_client.chat_postMessage.call_args
+ assert kwargs["channel"] == channel
+ assert kwargs["thread_ts"] == thread_ts
+ assert "New Signal Snooze Added" in kwargs["text"]
+ assert "Entities: entity1 (123), entity2 (456)" in kwargs["text"]
+ assert "Extension Requested" in kwargs["text"]
+ assert "notifying oncall: <@U123>" in kwargs["text"]