mirror of
https://github.com/apache/superset.git
synced 2026-09-01 13:01:33 +00:00
2531 lines
85 KiB
Python
2531 lines
85 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you under the Apache License, Version 2.0 (the
|
|
# "License"); you may not use this file except in compliance
|
|
# with the License. You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing,
|
|
# software distributed under the License is distributed on an
|
|
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
# KIND, either express or implied. See the License for the
|
|
# specific language governing permissions and limitations
|
|
# under the License.
|
|
|
|
import uuid
|
|
from email.message import Message
|
|
from http.client import RemoteDisconnected
|
|
from typing import Any, TYPE_CHECKING
|
|
from unittest.mock import ANY, call, MagicMock, patch
|
|
from urllib.error import HTTPError, URLError
|
|
|
|
import pandas as pd
|
|
import pytest
|
|
from slack_sdk.errors import (
|
|
BotUserAccessError,
|
|
SlackApiError,
|
|
SlackClientConfigurationError,
|
|
SlackClientError,
|
|
SlackClientNotConnectedError,
|
|
SlackObjectFormationError,
|
|
SlackRequestError,
|
|
SlackTokenRotationError,
|
|
)
|
|
from slack_sdk.web.slack_response import SlackResponse
|
|
|
|
from superset.reports.notifications.exceptions import (
|
|
NotificationAuthorizationException,
|
|
NotificationError,
|
|
NotificationMalformedException,
|
|
NotificationParamException,
|
|
NotificationTransientError,
|
|
NotificationUnprocessableException,
|
|
SlackV1NotificationError,
|
|
)
|
|
from superset.reports.notifications.slack_transport import (
|
|
_give_up_slack_api_retry,
|
|
call_slack_api,
|
|
send_to_slack_channels,
|
|
SlackRetryDeadlineError,
|
|
)
|
|
from superset.reports.notifications.slackv2 import (
|
|
_upload_file_data,
|
|
_upload_file_to_slack,
|
|
SlackV2Notification,
|
|
)
|
|
from superset.utils.core import HeaderDataType
|
|
from superset.utils.slack import SlackV2ProbeClientError, SlackV2ProbeError
|
|
|
|
if TYPE_CHECKING:
|
|
from superset.reports.notifications.slack import SlackNotification
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _skip_backoff_sleep():
|
|
"""Make phase-level Slack retry waits instant.
|
|
|
|
Classified API and transport failures can use exponential backoff, while
|
|
terminal writes avoid retries for ambiguous outcomes. Unit tests care about
|
|
attempts and error classification rather than wall-clock delays.
|
|
"""
|
|
with patch("time.sleep"):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_header_data() -> HeaderDataType:
|
|
return {
|
|
"notification_format": "PNG",
|
|
"notification_type": "Alert",
|
|
"editors": [1],
|
|
"notification_source": None,
|
|
"chart_id": None,
|
|
"dashboard_id": None,
|
|
"slack_channels": ["some_channel"],
|
|
"execution_id": "test-execution-id",
|
|
}
|
|
|
|
|
|
def _make_v1_notification(
|
|
mock_header_data: HeaderDataType,
|
|
*,
|
|
target: str,
|
|
**content_overrides: Any,
|
|
) -> "SlackNotification":
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
from superset.reports.notifications.slack import SlackNotification
|
|
|
|
content_values: dict[str, Any] = {
|
|
"name": "test alert",
|
|
"header_data": mock_header_data,
|
|
"description": "desc",
|
|
}
|
|
content_values.update(content_overrides)
|
|
return SlackNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.SLACK,
|
|
recipient_config_json=f'{{"target": "{target}"}}',
|
|
),
|
|
content=NotificationContent(**content_values),
|
|
)
|
|
|
|
|
|
def test_get_channels_with_multi_recipients(mock_header_data) -> None:
|
|
"""
|
|
Test _get_channels returns normalized recipients without duplicates.
|
|
"""
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
from superset.reports.notifications.slack import SlackNotification
|
|
|
|
content = NotificationContent(
|
|
name="test alert",
|
|
header_data=mock_header_data,
|
|
embedded_data=pd.DataFrame(
|
|
{
|
|
"A": [1, 2, 3],
|
|
"B": [4, 5, 6],
|
|
"C": ["111", "222", '<a href="http://www.example.com">333</a>'],
|
|
}
|
|
),
|
|
description='<p>This is <a href="#">a test</a> alert</p><br />',
|
|
)
|
|
slack_notification = SlackNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.SLACK,
|
|
recipient_config_json='{"target": "some_channel; second_channel, third_channel, some_channel"}', # noqa: E501
|
|
),
|
|
content=content,
|
|
)
|
|
|
|
result = slack_notification._get_channels()
|
|
|
|
assert result == ["some_channel", "second_channel", "third_channel"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"recipient_config_json",
|
|
["{not-json", '{"target": ["private-channel"]}'],
|
|
ids=["malformed-json", "non-string-target"],
|
|
)
|
|
def test_get_channels_rejects_invalid_recipient_config(
|
|
mock_header_data: HeaderDataType,
|
|
recipient_config_json: str,
|
|
) -> None:
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
from superset.reports.notifications.slack import SlackNotification
|
|
|
|
notification = SlackNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.SLACK,
|
|
recipient_config_json=recipient_config_json,
|
|
),
|
|
content=NotificationContent(
|
|
name="test alert",
|
|
header_data=mock_header_data,
|
|
),
|
|
)
|
|
|
|
with pytest.raises(NotificationParamException, match="No recipients"):
|
|
notification._get_channels()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"recipient_config_json",
|
|
["{not-json", '{"target": ["C12345"]}'],
|
|
ids=["malformed-json", "non-string-target"],
|
|
)
|
|
def test_slackv2_get_channels_rejects_invalid_recipient_config(
|
|
mock_header_data: HeaderDataType,
|
|
recipient_config_json: str,
|
|
) -> None:
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
notification = SlackV2Notification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.SLACKV2,
|
|
recipient_config_json=recipient_config_json,
|
|
),
|
|
content=NotificationContent(
|
|
name="test alert",
|
|
header_data=mock_header_data,
|
|
),
|
|
)
|
|
|
|
with pytest.raises(NotificationParamException, match="No recipients"):
|
|
notification._get_channels()
|
|
|
|
|
|
def test_valid_recipient_config_json_slackv2(mock_header_data) -> None:
|
|
"""
|
|
Test if the recipient configuration JSON is valid when using a SlackV2 recipient type
|
|
""" # noqa: E501
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
from superset.reports.notifications.slack import SlackNotification
|
|
|
|
content = NotificationContent(
|
|
name="test alert",
|
|
header_data=mock_header_data,
|
|
embedded_data=pd.DataFrame(
|
|
{
|
|
"A": [1, 2, 3],
|
|
"B": [4, 5, 6],
|
|
"C": ["111", "222", '<a href="http://www.example.com">333</a>'],
|
|
}
|
|
),
|
|
description='<p>This is <a href="#">a test</a> alert</p><br />',
|
|
)
|
|
slack_notification = SlackNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.SLACKV2,
|
|
recipient_config_json='{"target": "some_channel"}',
|
|
),
|
|
content=content,
|
|
)
|
|
|
|
result = slack_notification._recipient.recipient_config_json
|
|
|
|
assert result == '{"target": "some_channel"}'
|
|
|
|
# Ensure _get_inline_files function returns the correct tuple when content has screenshots # noqa: E501
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"content_overrides,expected",
|
|
[
|
|
({"screenshots": [b"screenshot"]}, True),
|
|
({"csv": b"csv_content"}, True),
|
|
({"xlsx": b"xlsx_content"}, True),
|
|
({"pdf": b"pdf_content"}, True),
|
|
({}, False),
|
|
],
|
|
ids=["screenshots", "csv", "xlsx", "pdf", "none"],
|
|
)
|
|
def test_notification_content_has_attachments(
|
|
mock_header_data: HeaderDataType,
|
|
content_overrides: dict[str, Any],
|
|
expected: bool,
|
|
) -> None:
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
content = NotificationContent(
|
|
name="test alert",
|
|
header_data=mock_header_data,
|
|
**content_overrides,
|
|
)
|
|
|
|
assert content.has_attachments is expected
|
|
|
|
|
|
def test_get_inline_files_with_xlsx_slackv2(mock_header_data: HeaderDataType) -> None:
|
|
"""SlackV2 _get_inline_files returns the xlsx tuple when content has Excel."""
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
from superset.reports.notifications.slackv2 import SlackV2Notification
|
|
|
|
content = NotificationContent(
|
|
name="test alert",
|
|
header_data=mock_header_data,
|
|
xlsx=b"xlsx_content",
|
|
)
|
|
slack_notification = SlackV2Notification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.SLACKV2,
|
|
recipient_config_json='{"target": "some_channel"}',
|
|
),
|
|
content=content,
|
|
)
|
|
|
|
result = slack_notification._get_inline_files()
|
|
|
|
assert result == ("xlsx", [b"xlsx_content"])
|
|
|
|
# Ensure _get_inline_files function returns None when content has no screenshots or csv # noqa: E501
|
|
|
|
|
|
@patch(
|
|
"superset.reports.notifications.slack.feature_flag_manager.is_feature_enabled",
|
|
return_value=False,
|
|
)
|
|
@patch("superset.reports.notifications.slack.g")
|
|
@patch("superset.reports.notifications.slack.should_use_v2_api", return_value=False)
|
|
@patch("superset.reports.notifications.slack.get_slack_client")
|
|
def test_v1_send_without_channels_raises(
|
|
slack_client_mock: MagicMock,
|
|
should_use_v2_api_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
feature_flag_mock: MagicMock,
|
|
mock_header_data: HeaderDataType,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
notification = _make_v1_notification(mock_header_data, target="")
|
|
|
|
with pytest.raises(NotificationParamException, match="No recipients"):
|
|
notification.send()
|
|
|
|
should_use_v2_api_mock.assert_called_once_with(raise_on_error=False)
|
|
feature_flag_mock.assert_called_once_with("ALERT_REPORT_SLACK_V2")
|
|
slack_client_mock.return_value.chat_postMessage.assert_not_called()
|
|
|
|
|
|
@patch("superset.reports.notifications.slack.g")
|
|
@patch("superset.reports.notifications.slack.should_use_v2_api")
|
|
@patch("superset.reports.notifications.slack.get_slack_client")
|
|
def test_send_legacy_text_skips_v2_probe(
|
|
slack_client_mock: MagicMock,
|
|
should_use_v2_api_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data: HeaderDataType,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
notification = _make_v1_notification(
|
|
mock_header_data,
|
|
target="private-channel",
|
|
)
|
|
|
|
notification.send_legacy_text()
|
|
|
|
should_use_v2_api_mock.assert_not_called()
|
|
slack_client_mock.assert_called_once_with(for_delivery=True)
|
|
slack_client_mock.return_value.chat_postMessage.assert_called_once_with(
|
|
channel="private-channel",
|
|
text=ANY,
|
|
)
|
|
|
|
|
|
@patch("superset.reports.notifications.slack.should_use_v2_api", return_value=True)
|
|
def test_v1_upgrade_handoff_does_not_record_terminal_send_metric(
|
|
should_use_v2_api_mock: MagicMock,
|
|
mock_header_data: HeaderDataType,
|
|
) -> None:
|
|
"""The v1-to-v2 handoff is routing, not a terminal Slack delivery."""
|
|
notification = _make_v1_notification(
|
|
mock_header_data,
|
|
target="private-channel",
|
|
)
|
|
|
|
with (
|
|
patch("superset.extensions.stats_logger_manager.instance.gauge") as statsd_mock,
|
|
pytest.raises(SlackV1NotificationError),
|
|
):
|
|
notification.send()
|
|
|
|
should_use_v2_api_mock.assert_called_once_with(raise_on_error=False)
|
|
statsd_mock.assert_not_called()
|
|
|
|
|
|
@patch("superset.reports.notifications.slack.get_slack_client")
|
|
def test_send_legacy_text_rejects_attachments(
|
|
slack_client_mock: MagicMock,
|
|
mock_header_data: HeaderDataType,
|
|
) -> None:
|
|
notification = _make_v1_notification(
|
|
mock_header_data,
|
|
target="private-channel",
|
|
screenshots=[b"screenshot"],
|
|
)
|
|
|
|
with pytest.raises(
|
|
NotificationParamException,
|
|
match="Slack v1 file uploads are no longer supported",
|
|
):
|
|
notification.send_legacy_text()
|
|
|
|
slack_client_mock.assert_not_called()
|
|
|
|
|
|
@patch(
|
|
"superset.reports.notifications.slack.feature_flag_manager.is_feature_enabled",
|
|
return_value=False,
|
|
)
|
|
@patch("superset.reports.notifications.slack.g")
|
|
@patch("superset.reports.notifications.slack.should_use_v2_api", return_value=False)
|
|
@patch("superset.reports.notifications.slack.get_slack_client")
|
|
def test_v1_send_rejects_files_when_v2_probe_is_unavailable(
|
|
slack_client_mock: MagicMock,
|
|
should_use_v2_api_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
feature_flag_mock: MagicMock,
|
|
mock_header_data: HeaderDataType,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
notification = _make_v1_notification(
|
|
mock_header_data,
|
|
target="private-channel",
|
|
screenshots=[b"screenshot"],
|
|
)
|
|
|
|
with pytest.raises(
|
|
NotificationParamException,
|
|
match="Slack v1 file uploads are no longer supported",
|
|
):
|
|
notification.send()
|
|
|
|
should_use_v2_api_mock.assert_called_once_with(raise_on_error=True)
|
|
feature_flag_mock.assert_called_once_with("ALERT_REPORT_SLACK_V2")
|
|
slack_client_mock.return_value.files_upload.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("probe_error", "metric"),
|
|
[
|
|
(
|
|
SlackV2ProbeError(
|
|
"Slack v2 availability probe failed: service_unavailable"
|
|
),
|
|
"reports.slack.send.error",
|
|
),
|
|
(
|
|
SlackV2ProbeClientError("Slack v2 availability probe failed: invalid_auth"),
|
|
"reports.slack.send.warning",
|
|
),
|
|
],
|
|
ids=["system", "client"],
|
|
)
|
|
@patch("superset.reports.notifications.slack.g")
|
|
@patch("superset.reports.notifications.slack.should_use_v2_api")
|
|
@patch("superset.reports.notifications.slack.get_slack_client")
|
|
def test_v1_file_send_records_v2_probe_failure_metric(
|
|
slack_client_mock: MagicMock,
|
|
should_use_v2_api_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data: HeaderDataType,
|
|
probe_error: SlackV2ProbeError,
|
|
metric: str,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
should_use_v2_api_mock.side_effect = probe_error
|
|
notification = _make_v1_notification(
|
|
mock_header_data,
|
|
target="private-channel",
|
|
screenshots=[b"screenshot"],
|
|
)
|
|
|
|
with (
|
|
patch("superset.extensions.stats_logger_manager.instance.gauge") as statsd_mock,
|
|
pytest.raises(type(probe_error)),
|
|
):
|
|
notification.send()
|
|
|
|
should_use_v2_api_mock.assert_called_once_with(raise_on_error=True)
|
|
slack_client_mock.assert_not_called()
|
|
statsd_mock.assert_called_once_with(metric, 1)
|
|
|
|
|
|
@patch(
|
|
"superset.reports.notifications.slack.feature_flag_manager.is_feature_enabled",
|
|
return_value=False,
|
|
)
|
|
@patch("superset.reports.notifications.slack.g")
|
|
@patch("superset.reports.notifications.slack.should_use_v2_api", return_value=False)
|
|
@patch("superset.reports.notifications.slack.get_slack_client")
|
|
def test_v1_send_retries_only_the_failed_channel(
|
|
slack_client_mock: MagicMock,
|
|
should_use_v2_api_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
feature_flag_mock: MagicMock,
|
|
mock_header_data: HeaderDataType,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
failed_attempts = 0
|
|
|
|
def chat_side_effect(channel: str, text: str) -> dict[str, bool]:
|
|
nonlocal failed_attempts
|
|
if channel == "private-b" and failed_attempts < 2:
|
|
failed_attempts += 1
|
|
response = _make_slack_response(
|
|
429,
|
|
{"ok": False, "error": "ratelimited"},
|
|
headers={"Retry-After": ["0"]},
|
|
)
|
|
raise SlackApiError(
|
|
message="rate limited",
|
|
response=response,
|
|
)
|
|
return {"ok": True}
|
|
|
|
slack_client_mock.return_value.chat_postMessage.side_effect = chat_side_effect
|
|
notification = _make_v1_notification(
|
|
mock_header_data,
|
|
target="private-a,private-b",
|
|
)
|
|
|
|
notification.send()
|
|
|
|
should_use_v2_api_mock.assert_called_once_with(raise_on_error=False)
|
|
feature_flag_mock.assert_called_once_with("ALERT_REPORT_SLACK_V2")
|
|
assert [
|
|
slack_call.kwargs["channel"]
|
|
for slack_call in slack_client_mock.return_value.chat_postMessage.call_args_list
|
|
] == ["private-a", "private-b", "private-b", "private-b"]
|
|
|
|
|
|
@patch(
|
|
"superset.reports.notifications.slack.feature_flag_manager.is_feature_enabled",
|
|
return_value=False,
|
|
)
|
|
@patch("superset.reports.notifications.slack.g")
|
|
@patch("superset.reports.notifications.slack.should_use_v2_api", return_value=False)
|
|
@patch("superset.reports.notifications.slack.get_slack_client")
|
|
def test_v1_send_reports_failed_channel_and_continues_later_channels(
|
|
slack_client_mock: MagicMock,
|
|
should_use_v2_api_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
feature_flag_mock: MagicMock,
|
|
mock_header_data: HeaderDataType,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
|
|
def chat_side_effect(channel: str, text: str) -> dict[str, bool]:
|
|
if channel == "private-b":
|
|
raise SlackApiError(
|
|
message="channel not found",
|
|
response={"ok": False, "error": "channel_not_found"},
|
|
)
|
|
return {"ok": True}
|
|
|
|
slack_client_mock.return_value.chat_postMessage.side_effect = chat_side_effect
|
|
notification = _make_v1_notification(
|
|
mock_header_data,
|
|
target="private-a,private-b,private-c",
|
|
)
|
|
|
|
with pytest.raises(
|
|
NotificationUnprocessableException,
|
|
match="private-b",
|
|
):
|
|
notification.send()
|
|
|
|
should_use_v2_api_mock.assert_called_once_with(raise_on_error=False)
|
|
feature_flag_mock.assert_called_once_with("ALERT_REPORT_SLACK_V2")
|
|
assert [
|
|
slack_call.kwargs["channel"]
|
|
for slack_call in slack_client_mock.return_value.chat_postMessage.call_args_list
|
|
] == ["private-a", "private-b", "private-c"]
|
|
|
|
|
|
@patch(
|
|
"superset.reports.notifications.slack.feature_flag_manager.is_feature_enabled",
|
|
return_value=False,
|
|
)
|
|
@patch("superset.reports.notifications.slack.g")
|
|
@patch("superset.reports.notifications.slack.should_use_v2_api", return_value=False)
|
|
@patch("superset.reports.notifications.slack.get_slack_client")
|
|
def test_v1_send_deduplicates_channels(
|
|
slack_client_mock: MagicMock,
|
|
should_use_v2_api_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
feature_flag_mock: MagicMock,
|
|
mock_header_data: HeaderDataType,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
notification = _make_v1_notification(
|
|
mock_header_data,
|
|
target="private-a, private-a",
|
|
)
|
|
|
|
notification.send()
|
|
|
|
should_use_v2_api_mock.assert_called_once_with(raise_on_error=False)
|
|
feature_flag_mock.assert_called_once_with("ALERT_REPORT_SLACK_V2")
|
|
slack_client_mock.return_value.chat_postMessage.assert_called_once_with(
|
|
channel="private-a",
|
|
text=ANY,
|
|
)
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.logger")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_send_slackv2(
|
|
slack_client_mock: MagicMock,
|
|
logger_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
# `superset.models.helpers`, a dependency of following imports,
|
|
# requires app context
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
execution_id = uuid.uuid4()
|
|
flask_global_mock.logs_context = {"execution_id": execution_id}
|
|
slack_client_mock.return_value.chat_postMessage.return_value = {"ok": True}
|
|
content = NotificationContent(
|
|
name="test alert",
|
|
header_data=mock_header_data,
|
|
embedded_data=pd.DataFrame(
|
|
{
|
|
"A": [1, 2, 3],
|
|
"B": [4, 5, 6],
|
|
"C": ["111", "222", '<a href="http://www.example.com">333</a>'],
|
|
}
|
|
),
|
|
description='<p>This is <a href="#">a test</a> alert</p><br />',
|
|
)
|
|
|
|
notification = SlackV2Notification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.SLACKV2,
|
|
recipient_config_json='{"target": "some_channel"}',
|
|
),
|
|
content=content,
|
|
)
|
|
notification.send()
|
|
slack_client_mock.assert_called_once_with(for_delivery=True)
|
|
logger_mock.info.assert_called_with(
|
|
"Report sent to slack", extra={"execution_id": execution_id}
|
|
)
|
|
slack_client_mock.return_value.chat_postMessage.assert_called_with(
|
|
channel="some_channel",
|
|
text="""*test alert*
|
|
|
|
<p>This is <a href="#">a test</a> alert</p><br />
|
|
|
|
<None|Explore in Superset>
|
|
|
|
```
|
|
| | A | B | C |
|
|
|---:|----:|----:|:-----------------------------------------|
|
|
| 0 | 1 | 4 | 111 |
|
|
| 1 | 2 | 5 | 222 |
|
|
| 2 | 3 | 6 | <a href="http://www.example.com">333</a> |
|
|
```
|
|
""",
|
|
)
|
|
|
|
|
|
@patch("superset.reports.notifications.slack.g")
|
|
@patch("superset.reports.notifications.slack.logger")
|
|
@patch("superset.utils.slack.get_slack_client")
|
|
@patch("superset.reports.notifications.slack.get_slack_client")
|
|
def test_send_slack(
|
|
slack_client_mock: MagicMock,
|
|
slack_client_mock_util: MagicMock,
|
|
logger_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
mocker,
|
|
) -> None:
|
|
# `superset.models.helpers`, a dependency of following imports,
|
|
# requires app context
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
from superset.reports.notifications.slack import SlackNotification
|
|
|
|
execution_id = uuid.uuid4()
|
|
flask_global_mock.logs_context = {"execution_id": execution_id}
|
|
mocker.patch(
|
|
"superset.reports.notifications.slack.feature_flag_manager.is_feature_enabled",
|
|
return_value=False,
|
|
)
|
|
slack_client_mock.return_value.chat_postMessage.return_value = {"ok": True}
|
|
slack_client_mock_util.return_value.conversations_list.side_effect = SlackApiError(
|
|
"scope not found", "error"
|
|
)
|
|
|
|
content = NotificationContent(
|
|
name="test alert",
|
|
header_data=mock_header_data,
|
|
embedded_data=pd.DataFrame(
|
|
{
|
|
"A": [1, 2, 3],
|
|
"B": [4, 5, 6],
|
|
"C": ["111", "222", '<a href="http://www.example.com">333</a>'],
|
|
}
|
|
),
|
|
description='<p>This is <a href="#">a test</a> alert</p><br />',
|
|
)
|
|
|
|
notification = SlackNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.SLACKV2,
|
|
recipient_config_json='{"target": "some_channel"}',
|
|
),
|
|
content=content,
|
|
)
|
|
notification.send()
|
|
|
|
logger_mock.info.assert_called_with(
|
|
"Report sent to slack", extra={"execution_id": execution_id}
|
|
)
|
|
slack_client_mock.return_value.chat_postMessage.assert_called_with(
|
|
channel="some_channel",
|
|
text="""*test alert*
|
|
|
|
<p>This is <a href="#">a test</a> alert</p><br />
|
|
|
|
<None|Explore in Superset>
|
|
|
|
```
|
|
| | A | B | C |
|
|
|---:|----:|----:|:-----------------------------------------|
|
|
| 0 | 1 | 4 | 111 |
|
|
| 1 | 2 | 5 | 222 |
|
|
| 2 | 3 | 6 | <a href="http://www.example.com">333</a> |
|
|
```
|
|
""",
|
|
)
|
|
|
|
|
|
@patch(
|
|
"superset.utils.slack.feature_flag_manager.is_feature_enabled",
|
|
return_value=False,
|
|
)
|
|
@patch("superset.reports.notifications.slack.g")
|
|
@patch("superset.reports.notifications.slack.logger")
|
|
@patch("superset.utils.slack.get_slack_client")
|
|
@patch("superset.reports.notifications.slack.get_slack_client")
|
|
def test_send_slack_no_feature_flag(
|
|
slack_client_mock: MagicMock,
|
|
slack_client_mock_util: MagicMock,
|
|
logger_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
feature_flag_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
# `superset.models.helpers`, a dependency of following imports,
|
|
# requires app context
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
from superset.reports.notifications.slack import SlackNotification
|
|
|
|
execution_id = uuid.uuid4()
|
|
flask_global_mock.logs_context = {"execution_id": execution_id}
|
|
slack_client_mock.return_value.chat_postMessage.return_value = {"ok": True}
|
|
# Even with valid scopes, ALERT_REPORT_SLACK_V2=False forces the v1 path.
|
|
slack_client_mock_util.return_value.conversations_list.return_value = {
|
|
"channels": [{"id": "foo", "name": "bar"}]
|
|
}
|
|
|
|
content = NotificationContent(
|
|
name="test alert",
|
|
header_data=mock_header_data,
|
|
embedded_data=pd.DataFrame(
|
|
{
|
|
"A": [1, 2, 3],
|
|
"B": [4, 5, 6],
|
|
"C": ["111", "222", '<a href="http://www.example.com">333</a>'],
|
|
}
|
|
),
|
|
description='<p>This is <a href="#">a test</a> alert</p><br />',
|
|
)
|
|
|
|
notification = SlackNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.SLACKV2,
|
|
recipient_config_json='{"target": "some_channel"}',
|
|
),
|
|
content=content,
|
|
)
|
|
notification.send()
|
|
|
|
logger_mock.info.assert_called_with(
|
|
"Report sent to slack", extra={"execution_id": execution_id}
|
|
)
|
|
slack_client_mock.return_value.chat_postMessage.assert_called_with(
|
|
channel="some_channel",
|
|
text="""*test alert*
|
|
|
|
<p>This is <a href="#">a test</a> alert</p><br />
|
|
|
|
<None|Explore in Superset>
|
|
|
|
```
|
|
| | A | B | C |
|
|
|---:|----:|----:|:-----------------------------------------|
|
|
| 0 | 1 | 4 | 111 |
|
|
| 1 | 2 | 5 | 222 |
|
|
| 2 | 3 | 6 | <a href="http://www.example.com">333</a> |
|
|
```
|
|
""",
|
|
)
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_slackv2_send_without_channels_raises(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
from superset.reports.notifications.exceptions import NotificationParamException
|
|
|
|
flask_global_mock.logs_context = {}
|
|
content = NotificationContent(name="test", header_data=mock_header_data)
|
|
notification = SlackV2Notification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.SLACKV2,
|
|
recipient_config_json='{"target": ""}',
|
|
),
|
|
content=content,
|
|
)
|
|
with pytest.raises(NotificationParamException, match="No recipients"):
|
|
notification.send()
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_slack_mixin_get_body_truncates_large_table(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
flask_global_mock.logs_context = {}
|
|
# Create a large DataFrame that exceeds the 4000-char message limit
|
|
large_df = pd.DataFrame({"col_" + str(i): range(100) for i in range(10)})
|
|
content = NotificationContent(
|
|
name="test",
|
|
header_data=mock_header_data,
|
|
embedded_data=large_df,
|
|
description="desc",
|
|
)
|
|
notification = SlackV2Notification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.SLACKV2,
|
|
recipient_config_json='{"target": "some_channel"}',
|
|
),
|
|
content=content,
|
|
)
|
|
body = notification._get_body(content=content)
|
|
assert "(table was truncated)" in body
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_slackv2_body_omits_cta_when_include_cta_is_false(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
flask_global_mock.logs_context = {}
|
|
content = NotificationContent(
|
|
name="test alert",
|
|
header_data=mock_header_data,
|
|
description="desc",
|
|
url="http://example.com/superset/dashboard/1/",
|
|
include_cta=False,
|
|
)
|
|
notification = SlackV2Notification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.SLACKV2,
|
|
recipient_config_json='{"target": "some_channel"}',
|
|
),
|
|
content=content,
|
|
)
|
|
body = notification._get_body(content=content)
|
|
assert "Explore in Superset" not in body
|
|
assert "http://example.com/superset/dashboard/1/" not in body
|
|
assert "*test alert*" in body
|
|
assert "desc" in body
|
|
|
|
|
|
@patch("superset.reports.notifications.slack.g")
|
|
@patch("superset.reports.notifications.slack.get_slack_client")
|
|
def test_slack_body_omits_cta_when_include_cta_is_false(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
from superset.reports.notifications.slack import SlackNotification
|
|
|
|
flask_global_mock.logs_context = {}
|
|
content = NotificationContent(
|
|
name="test alert",
|
|
header_data=mock_header_data,
|
|
description="desc",
|
|
url="http://example.com/superset/dashboard/1/",
|
|
include_cta=False,
|
|
)
|
|
notification = SlackNotification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.SLACK,
|
|
recipient_config_json='{"target": "some_channel"}',
|
|
),
|
|
content=content,
|
|
)
|
|
body = notification._get_body(content=content)
|
|
assert "Explore in Superset" not in body
|
|
assert "http://example.com/superset/dashboard/1/" not in body
|
|
assert "*test alert*" in body
|
|
assert "desc" in body
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_slack_mixin_truncated_body_omits_cta_when_include_cta_is_false(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
flask_global_mock.logs_context = {}
|
|
# Create a large DataFrame that exceeds the 4000-char message limit
|
|
large_df = pd.DataFrame({"col_" + str(i): range(100) for i in range(10)})
|
|
content = NotificationContent(
|
|
name="test",
|
|
header_data=mock_header_data,
|
|
embedded_data=large_df,
|
|
description="desc",
|
|
url="http://example.com/superset/dashboard/1/",
|
|
include_cta=False,
|
|
)
|
|
notification = SlackV2Notification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.SLACKV2,
|
|
recipient_config_json='{"target": "some_channel"}',
|
|
),
|
|
content=content,
|
|
)
|
|
body = notification._get_body(content=content)
|
|
assert "(table was truncated)" in body
|
|
assert "Explore in Superset" not in body
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Bulletproof v2 send-path coverage
|
|
#
|
|
# The tests above exercise the chat_postMessage path (text-only sends). The
|
|
# tests below cover the three-phase v2 upload across screenshots/CSV/PDF,
|
|
# multi-channel fan-out, exception mapping, backoff, statsd, and logs propagation.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_v2_notification(content, target: str = "C12345"):
|
|
"""Helper to build a SlackV2Notification with a given target string."""
|
|
from superset.reports.models import ReportRecipients, ReportRecipientType
|
|
|
|
return SlackV2Notification(
|
|
recipient=ReportRecipients(
|
|
type=ReportRecipientType.SLACKV2,
|
|
recipient_config_json=f'{{"target": "{target}"}}',
|
|
),
|
|
content=content,
|
|
)
|
|
|
|
|
|
def _make_content(mock_header_data, **overrides):
|
|
"""Helper to build a minimal NotificationContent."""
|
|
from superset.reports.notifications.base import NotificationContent
|
|
|
|
defaults: dict = {
|
|
"name": "test alert",
|
|
"header_data": mock_header_data,
|
|
"description": "desc",
|
|
}
|
|
defaults.update(overrides)
|
|
return NotificationContent(**defaults)
|
|
|
|
|
|
def _configure_v2_upload_client(client: MagicMock) -> MagicMock:
|
|
"""Configure an SDK-shaped client for the three-phase upload flow."""
|
|
client.timeout = 30
|
|
client.proxy = None
|
|
client.ssl = None
|
|
|
|
def create_upload_url(**kwargs: object) -> dict[str, str]:
|
|
index = client.files_getUploadURLExternal.call_count
|
|
return {
|
|
"file_id": f"F{index}",
|
|
"upload_url": f"https://files.slack.com/upload/{index}",
|
|
}
|
|
|
|
client.files_getUploadURLExternal.side_effect = create_upload_url
|
|
client.files_completeUploadExternal.return_value = {"files": [{"id": "F1"}]}
|
|
return client
|
|
|
|
|
|
@pytest.fixture
|
|
def raw_upload_mock(mocker) -> MagicMock:
|
|
"""Stub Slack's issued upload URL without depending on SDK internals."""
|
|
return mocker.patch(
|
|
"superset.reports.notifications.slackv2._upload_file_data",
|
|
return_value=(200, "ok"),
|
|
)
|
|
|
|
|
|
def test_raw_upload_uses_stable_http_api_with_integer_timeout(mocker) -> None:
|
|
response = MagicMock(status=200)
|
|
response.headers.get_content_charset.return_value = "utf-8"
|
|
response.read.return_value = b"ok"
|
|
urlopen_mock = mocker.patch(
|
|
"superset.reports.notifications.slackv2.urlopen",
|
|
return_value=response,
|
|
)
|
|
|
|
assert _upload_file_data(
|
|
url="https://files.slack.com/upload/1",
|
|
data=b"report",
|
|
timeout=7,
|
|
proxy=None,
|
|
ssl=None,
|
|
) == (200, "ok")
|
|
|
|
request = urlopen_mock.call_args.args[0]
|
|
assert request.full_url == "https://files.slack.com/upload/1"
|
|
assert request.data == b"report"
|
|
assert request.method == "POST"
|
|
assert urlopen_mock.call_args.kwargs["timeout"] == 7
|
|
assert isinstance(urlopen_mock.call_args.kwargs["timeout"], int)
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_with_single_screenshot_uses_three_phase_upload(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
raw_upload_mock: MagicMock,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {"execution_id": uuid.uuid4()}
|
|
client = _configure_v2_upload_client(slack_client_mock.return_value)
|
|
content = _make_content(mock_header_data, screenshots=[b"screenshot-bytes"])
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
|
|
notification.send()
|
|
|
|
client.files_getUploadURLExternal.assert_called_once_with(
|
|
filename="test alert.png",
|
|
length=len(b"screenshot-bytes"),
|
|
)
|
|
assert raw_upload_mock.call_args.kwargs["data"] == b"screenshot-bytes"
|
|
completion_kwargs = client.files_completeUploadExternal.call_args.kwargs
|
|
assert completion_kwargs["channel_id"] == "C12345"
|
|
assert completion_kwargs["files"] == [{"id": "F1", "title": "test alert"}]
|
|
assert "test alert" in completion_kwargs["initial_comment"]
|
|
client.files_upload_v2.assert_not_called()
|
|
# chat_postMessage should NOT be called when files are present
|
|
client.chat_postMessage.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status_code", "expected_exception", "expected_calls"),
|
|
[
|
|
(413, NotificationUnprocessableException, 1),
|
|
(429, NotificationTransientError, 3),
|
|
(504, NotificationTransientError, 5),
|
|
],
|
|
)
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_file_upload_classifies_raw_http_status(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
status_code: int,
|
|
expected_exception: type[Exception],
|
|
expected_calls: int,
|
|
mock_header_data: HeaderDataType,
|
|
raw_upload_mock: MagicMock,
|
|
) -> None:
|
|
"""Classify raw-upload HTTP failures and honor exposed Retry-After headers."""
|
|
flask_global_mock.logs_context = {}
|
|
client = _configure_v2_upload_client(slack_client_mock.return_value)
|
|
headers = Message()
|
|
if status_code == 429:
|
|
headers["Retry-After"] = "0"
|
|
raw_upload_mock.side_effect = HTTPError(
|
|
"https://files.slack.com/upload",
|
|
status_code,
|
|
"upload failed",
|
|
headers,
|
|
None,
|
|
)
|
|
notification = _make_v2_notification(
|
|
_make_content(mock_header_data, screenshots=[b"screenshot-bytes"]),
|
|
target="C12345",
|
|
)
|
|
|
|
with pytest.raises(expected_exception, match="C12345"):
|
|
notification.send()
|
|
|
|
assert raw_upload_mock.call_count == expected_calls
|
|
client.files_getUploadURLExternal.assert_called_once()
|
|
client.files_completeUploadExternal.assert_not_called()
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_raw_upload_result_429_without_headers_does_not_retry(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data: HeaderDataType,
|
|
raw_upload_mock: MagicMock,
|
|
) -> None:
|
|
"""A raw result without headers cannot safely synthesize a retry delay."""
|
|
flask_global_mock.logs_context = {}
|
|
client = _configure_v2_upload_client(slack_client_mock.return_value)
|
|
raw_upload_mock.return_value = (429, "rate limited")
|
|
notification = _make_v2_notification(
|
|
_make_content(mock_header_data, screenshots=[b"screenshot-bytes"]),
|
|
target="C12345",
|
|
)
|
|
|
|
with pytest.raises(NotificationTransientError, match="C12345"):
|
|
notification.send()
|
|
|
|
raw_upload_mock.assert_called_once()
|
|
client.files_getUploadURLExternal.assert_called_once()
|
|
client.files_completeUploadExternal.assert_not_called()
|
|
|
|
|
|
def test_raw_http_rate_limit_respects_zero_retry_config(mocker) -> None:
|
|
headers = Message()
|
|
headers["Retry-After"] = "0"
|
|
error = HTTPError(
|
|
"https://files.slack.com/upload",
|
|
429,
|
|
"upload rate limited",
|
|
headers,
|
|
None,
|
|
)
|
|
method = MagicMock(side_effect=error)
|
|
mocker.patch.dict(
|
|
"superset.reports.notifications.slack_transport.app.config",
|
|
{"SLACK_API_RATE_LIMIT_RETRY_COUNT": 0},
|
|
)
|
|
|
|
with pytest.raises(HTTPError):
|
|
call_slack_api(method)
|
|
|
|
method.assert_called_once_with()
|
|
|
|
|
|
def test_raw_http_rate_limit_respects_shared_deadline(mocker) -> None:
|
|
headers = Message()
|
|
headers["Retry-After"] = "120"
|
|
error = HTTPError(
|
|
"https://files.slack.com/upload",
|
|
429,
|
|
"upload rate limited",
|
|
headers,
|
|
None,
|
|
)
|
|
method = MagicMock(side_effect=error)
|
|
clock = [0.0]
|
|
sleep = mocker.patch("superset.reports.notifications.slack_transport.time.sleep")
|
|
sleep.side_effect = lambda duration: clock.__setitem__(0, clock[0] + duration)
|
|
mocker.patch(
|
|
"superset.reports.notifications.slack_transport.time.monotonic",
|
|
side_effect=lambda: clock[0],
|
|
)
|
|
mocker.patch.dict(
|
|
"superset.reports.notifications.slack_transport.app.config",
|
|
{"SLACK_API_RATE_LIMIT_RETRY_COUNT": 2},
|
|
)
|
|
|
|
with pytest.raises(SlackRetryDeadlineError, match="deadline exceeded"):
|
|
call_slack_api(method, retry_deadline=150.0)
|
|
|
|
assert method.call_count == 2
|
|
sleep.assert_called_once_with(120.0)
|
|
|
|
|
|
@pytest.mark.parametrize("retry_after", ["NaN", "inf", "-inf", ""])
|
|
def test_malformed_retry_after_preserves_channel_isolation(
|
|
retry_after: str,
|
|
) -> None:
|
|
response = _make_slack_response(
|
|
429,
|
|
{"ok": False, "error": "ratelimited"},
|
|
headers={"Retry-After": [retry_after]},
|
|
)
|
|
methods = {
|
|
"C1": MagicMock(
|
|
side_effect=SlackApiError(message="rate limited", response=response)
|
|
),
|
|
"C2": MagicMock(return_value={"ok": True}),
|
|
}
|
|
|
|
def send(channel: str, retry_deadline: float) -> None:
|
|
call_slack_api(methods[channel], retry_deadline=retry_deadline)
|
|
|
|
with pytest.raises(NotificationTransientError, match="C1"):
|
|
send_to_slack_channels(["C1", "C2"], send)
|
|
|
|
methods["C1"].assert_called_once_with()
|
|
methods["C2"].assert_called_once_with()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("status_code", "expected_calls", "expected_exception"),
|
|
[
|
|
(429, 2, None),
|
|
(503, 1, NotificationTransientError),
|
|
],
|
|
)
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_completion_retry_does_not_replay_prior_upload_phases(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
status_code: int,
|
|
expected_calls: int,
|
|
expected_exception: type[Exception] | None,
|
|
mock_header_data: HeaderDataType,
|
|
raw_upload_mock: MagicMock,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
client = _configure_v2_upload_client(slack_client_mock.return_value)
|
|
response = _make_slack_response(
|
|
status_code,
|
|
{"ok": False, "error": "ratelimited" if status_code == 429 else "fatal_error"},
|
|
)
|
|
if status_code == 429:
|
|
response.headers["Retry-After"] = "0"
|
|
client.files_completeUploadExternal.side_effect = [
|
|
SlackApiError(message="completion failed", response=response),
|
|
{"files": [{"id": "F1"}]},
|
|
]
|
|
notification = _make_v2_notification(
|
|
_make_content(mock_header_data, screenshots=[b"screenshot"]),
|
|
)
|
|
|
|
if expected_exception:
|
|
with pytest.raises(expected_exception, match="C12345"):
|
|
notification.send()
|
|
else:
|
|
notification.send()
|
|
|
|
client.files_getUploadURLExternal.assert_called_once()
|
|
raw_upload_mock.assert_called_once()
|
|
assert client.files_completeUploadExternal.call_count == expected_calls
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"invalid_response",
|
|
[
|
|
object(),
|
|
{"file_id": None, "upload_url": None},
|
|
{"file_id": "", "upload_url": ""},
|
|
],
|
|
ids=["non-mapping", "missing-values", "empty-values"],
|
|
)
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_upload_aggregates_missing_metadata_per_channel(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
invalid_response: object,
|
|
mock_header_data: HeaderDataType,
|
|
raw_upload_mock: MagicMock,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
client = _configure_v2_upload_client(slack_client_mock.return_value)
|
|
client.files_getUploadURLExternal.side_effect = [
|
|
invalid_response,
|
|
{"file_id": "F2", "upload_url": "https://files.slack.com/upload/2"},
|
|
]
|
|
notification = _make_v2_notification(
|
|
_make_content(mock_header_data, screenshots=[b"screenshot"]),
|
|
target="C12345,C67890",
|
|
)
|
|
|
|
with pytest.raises(NotificationTransientError, match="C12345"):
|
|
notification.send()
|
|
|
|
assert client.files_getUploadURLExternal.call_count == 2
|
|
raw_upload_mock.assert_called_once()
|
|
client.files_completeUploadExternal.assert_called_once()
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_upload_url_creation_retries_transient_transport_failure(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data: HeaderDataType,
|
|
raw_upload_mock: MagicMock,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
client = _configure_v2_upload_client(slack_client_mock.return_value)
|
|
client.files_getUploadURLExternal.side_effect = [
|
|
URLError("connection reset"),
|
|
{
|
|
"file_id": "F1",
|
|
"upload_url": "https://files.slack.com/upload/1",
|
|
},
|
|
]
|
|
notification = _make_v2_notification(
|
|
_make_content(mock_header_data, screenshots=[b"screenshot"]),
|
|
)
|
|
|
|
notification.send()
|
|
|
|
assert client.files_getUploadURLExternal.call_count == 2
|
|
raw_upload_mock.assert_called_once()
|
|
client.files_completeUploadExternal.assert_called_once()
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_raw_upload_retries_transient_transport_failure(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data: HeaderDataType,
|
|
raw_upload_mock: MagicMock,
|
|
) -> None:
|
|
"""The non-terminal raw upload can safely retry a transient socket error."""
|
|
flask_global_mock.logs_context = {}
|
|
client = _configure_v2_upload_client(slack_client_mock.return_value)
|
|
raw_upload_mock.side_effect = [
|
|
URLError("connection reset"),
|
|
(200, "ok"),
|
|
]
|
|
notification = _make_v2_notification(
|
|
_make_content(mock_header_data, screenshots=[b"screenshot"]),
|
|
)
|
|
|
|
notification.send()
|
|
|
|
assert raw_upload_mock.call_count == 2
|
|
client.files_getUploadURLExternal.assert_called_once()
|
|
client.files_completeUploadExternal.assert_called_once()
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_upload_does_not_start_completion_after_deadline(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data: HeaderDataType,
|
|
mocker,
|
|
raw_upload_mock: MagicMock,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
client = _configure_v2_upload_client(slack_client_mock.return_value)
|
|
clock = [0.0]
|
|
mocker.patch(
|
|
"superset.reports.notifications.slack_transport.time.monotonic",
|
|
side_effect=lambda: clock[0],
|
|
)
|
|
|
|
def cross_deadline(**kwargs: object) -> tuple[int, str]:
|
|
clock[0] = 151.0
|
|
return 200, "ok"
|
|
|
|
raw_upload_mock.side_effect = cross_deadline
|
|
notification = _make_v2_notification(
|
|
_make_content(mock_header_data, screenshots=[b"screenshot"]),
|
|
)
|
|
|
|
with pytest.raises(NotificationTransientError, match="deadline exceeded"):
|
|
notification.send()
|
|
|
|
client.files_getUploadURLExternal.assert_called_once()
|
|
raw_upload_mock.assert_called_once()
|
|
client.files_completeUploadExternal.assert_not_called()
|
|
|
|
|
|
def test_raw_upload_timeout_is_clamped_to_remaining_deadline(
|
|
mocker,
|
|
raw_upload_mock: MagicMock,
|
|
) -> None:
|
|
client = _configure_v2_upload_client(MagicMock())
|
|
client.timeout = 300
|
|
clock = [0.0]
|
|
|
|
def return_upload_metadata(**kwargs: object) -> dict[str, str]:
|
|
clock[0] = 140.0
|
|
return {
|
|
"file_id": "F1",
|
|
"upload_url": "https://files.slack.com/upload/F1",
|
|
}
|
|
|
|
client.files_getUploadURLExternal.side_effect = return_upload_metadata
|
|
mocker.patch(
|
|
"superset.reports.notifications.slack_transport.time.monotonic",
|
|
side_effect=lambda: clock[0],
|
|
)
|
|
|
|
_upload_file_to_slack(
|
|
client,
|
|
channel="C12345",
|
|
file=b"screenshot",
|
|
initial_comment="report",
|
|
title="Report",
|
|
filename="report.png",
|
|
retry_deadline=150.0,
|
|
)
|
|
|
|
assert raw_upload_mock.call_args.kwargs["timeout"] == 10
|
|
assert isinstance(raw_upload_mock.call_args.kwargs["timeout"], int)
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_with_multiple_screenshots_uploads_each(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
raw_upload_mock: MagicMock,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
client = _configure_v2_upload_client(slack_client_mock.return_value)
|
|
content = _make_content(
|
|
mock_header_data, screenshots=[b"shot-1", b"shot-2", b"shot-3"]
|
|
)
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
|
|
notification.send()
|
|
|
|
assert raw_upload_mock.call_count == 3
|
|
uploaded_files = [c.kwargs["data"] for c in raw_upload_mock.call_args_list]
|
|
assert uploaded_files == [b"shot-1", b"shot-2", b"shot-3"]
|
|
assert [
|
|
c.kwargs["channel_id"]
|
|
for c in client.files_completeUploadExternal.call_args_list
|
|
] == ["C12345", "C12345", "C12345"]
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_with_csv_calls_files_upload_v2(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
raw_upload_mock: MagicMock,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
client = _configure_v2_upload_client(slack_client_mock.return_value)
|
|
content = _make_content(mock_header_data, csv=b"col1,col2\n1,2\n")
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
|
|
notification.send()
|
|
|
|
raw_upload_mock.assert_called_once()
|
|
assert raw_upload_mock.call_args.kwargs["data"] == b"col1,col2\n1,2\n"
|
|
client.files_getUploadURLExternal.assert_called_once_with(
|
|
filename="test alert.csv",
|
|
length=len(b"col1,col2\n1,2\n"),
|
|
)
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_with_pdf_calls_files_upload_v2(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
raw_upload_mock: MagicMock,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
client = _configure_v2_upload_client(slack_client_mock.return_value)
|
|
content = _make_content(mock_header_data, pdf=b"%PDF-1.4...")
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
|
|
notification.send()
|
|
|
|
raw_upload_mock.assert_called_once()
|
|
assert raw_upload_mock.call_args.kwargs["data"] == b"%PDF-1.4..."
|
|
client.files_getUploadURLExternal.assert_called_once_with(
|
|
filename="test alert.pdf",
|
|
length=len(b"%PDF-1.4..."),
|
|
)
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_to_multiple_channels_uploads_per_channel(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
raw_upload_mock: MagicMock,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
client = _configure_v2_upload_client(slack_client_mock.return_value)
|
|
content = _make_content(mock_header_data, screenshots=[b"shot-1", b"shot-2"])
|
|
notification = _make_v2_notification(content, target="C12345,C67890,C11111")
|
|
|
|
notification.send()
|
|
|
|
# 3 channels x 2 files = 6 uploads
|
|
assert raw_upload_mock.call_count == 6
|
|
seen = {
|
|
(completion.kwargs["channel_id"], upload_call.kwargs["data"])
|
|
for completion, upload_call in zip(
|
|
client.files_completeUploadExternal.call_args_list,
|
|
raw_upload_mock.call_args_list,
|
|
strict=True,
|
|
)
|
|
}
|
|
assert seen == {
|
|
("C12345", b"shot-1"),
|
|
("C12345", b"shot-2"),
|
|
("C67890", b"shot-1"),
|
|
("C67890", b"shot-2"),
|
|
("C11111", b"shot-1"),
|
|
("C11111", b"shot-2"),
|
|
}
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_deduplicates_channels(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data: HeaderDataType,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
notification = _make_v2_notification(
|
|
_make_content(mock_header_data),
|
|
target="C12345,C12345",
|
|
)
|
|
|
|
notification.send()
|
|
|
|
slack_client_mock.return_value.chat_postMessage.assert_called_once_with(
|
|
channel="C12345",
|
|
text=ANY,
|
|
)
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_text_only_uses_chat_post_message(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
content = _make_content(mock_header_data)
|
|
notification = _make_v2_notification(content, target="C12345,C67890")
|
|
|
|
notification.send()
|
|
|
|
# No files → chat_postMessage per channel, no files_upload_v2 calls
|
|
slack_client_mock.return_value.files_upload_v2.assert_not_called()
|
|
chat = slack_client_mock.return_value.chat_postMessage
|
|
assert chat.call_count == 2
|
|
channels = sorted(c.kwargs["channel"] for c in chat.call_args_list)
|
|
assert channels == ["C12345", "C67890"]
|
|
|
|
|
|
def test_v2_inline_files_precedence(mock_header_data) -> None:
|
|
"""CSV beats screenshots beats PDF; only one inline-file type is sent."""
|
|
content = _make_content(
|
|
mock_header_data,
|
|
csv=b"a,b\n1,2",
|
|
screenshots=[b"shot-1"],
|
|
pdf=b"%PDF",
|
|
)
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
file_type, files = notification._get_inline_files()
|
|
assert file_type == "csv"
|
|
assert files == [b"a,b\n1,2"]
|
|
|
|
content = _make_content(
|
|
mock_header_data,
|
|
screenshots=[b"shot-1"],
|
|
pdf=b"%PDF",
|
|
)
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
file_type, files = notification._get_inline_files()
|
|
assert file_type == "png"
|
|
assert files == [b"shot-1"]
|
|
|
|
content = _make_content(mock_header_data, pdf=b"%PDF")
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
file_type, files = notification._get_inline_files()
|
|
assert file_type == "pdf"
|
|
assert files == [b"%PDF"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("slack_exc_factory", "expected_exc"),
|
|
[
|
|
(
|
|
lambda: BotUserAccessError("bot user blocked"),
|
|
NotificationParamException,
|
|
),
|
|
(
|
|
lambda: SlackRequestError("bad request"),
|
|
NotificationParamException,
|
|
),
|
|
(
|
|
lambda: SlackClientConfigurationError("misconfigured"),
|
|
NotificationParamException,
|
|
),
|
|
(
|
|
lambda: SlackObjectFormationError("malformed"),
|
|
NotificationMalformedException,
|
|
),
|
|
(
|
|
lambda: SlackTokenRotationError(
|
|
SlackApiError(message="rotation failed", response={"ok": False})
|
|
),
|
|
NotificationAuthorizationException,
|
|
),
|
|
(
|
|
lambda: SlackClientNotConnectedError("offline"),
|
|
NotificationError,
|
|
),
|
|
(
|
|
# Fallback: any other SlackClientError becomes Unprocessable.
|
|
lambda: SlackClientError("misc client error"),
|
|
NotificationUnprocessableException,
|
|
),
|
|
],
|
|
)
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_maps_slack_sdk_exceptions(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
slack_exc_factory,
|
|
expected_exc,
|
|
mock_header_data,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
slack_client_mock.return_value.chat_postMessage.side_effect = slack_exc_factory()
|
|
|
|
content = _make_content(mock_header_data)
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
|
|
with pytest.raises(expected_exc):
|
|
notification.send()
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_does_not_retry_ambiguous_transient_slack_api_error(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
"""An outcome-ambiguous terminal write is attempted at most once."""
|
|
flask_global_mock.logs_context = {}
|
|
slack_client_mock.return_value.chat_postMessage.side_effect = SlackApiError(
|
|
message="service unavailable",
|
|
response={"ok": False, "error": "service_unavailable"},
|
|
)
|
|
|
|
content = _make_content(mock_header_data)
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
|
|
with pytest.raises(NotificationError, match="C12345"):
|
|
notification.send()
|
|
|
|
slack_client_mock.return_value.chat_postMessage.assert_called_once()
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_retries_then_succeeds_on_explicit_http_rate_limit(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
"""An explicit HTTP 429 safely retries a rejected terminal write."""
|
|
flask_global_mock.logs_context = {}
|
|
response = _make_slack_response(
|
|
429,
|
|
{"ok": False, "error": "ratelimited"},
|
|
headers={"Retry-After": ["0"]},
|
|
)
|
|
slack_client_mock.return_value.chat_postMessage.side_effect = [
|
|
SlackApiError(message="rate limited", response=response),
|
|
SlackApiError(message="rate limited", response=response),
|
|
{"ok": True},
|
|
]
|
|
|
|
content = _make_content(mock_header_data)
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
|
|
with patch(
|
|
"superset.extensions.stats_logger_manager.instance.gauge"
|
|
) as statsd_mock:
|
|
notification.send()
|
|
|
|
assert slack_client_mock.return_value.chat_postMessage.call_count == 3
|
|
statsd_mock.assert_called_with("reports.slack.send.ok", 1)
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_retries_only_failed_channel(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
failed_attempts = 0
|
|
|
|
def chat_side_effect(channel: str, text: str) -> dict[str, bool]:
|
|
nonlocal failed_attempts
|
|
if channel == "C67890" and failed_attempts < 2:
|
|
failed_attempts += 1
|
|
response = _make_slack_response(
|
|
429,
|
|
{"ok": False, "error": "ratelimited"},
|
|
headers={"Retry-After": ["0"]},
|
|
)
|
|
raise SlackApiError(
|
|
message="rate limited",
|
|
response=response,
|
|
)
|
|
return {"ok": True}
|
|
|
|
slack_client_mock.return_value.chat_postMessage.side_effect = chat_side_effect
|
|
|
|
content = _make_content(mock_header_data)
|
|
notification = _make_v2_notification(content, target="C12345,C67890")
|
|
|
|
notification.send()
|
|
|
|
assert [
|
|
slack_call.kwargs["channel"]
|
|
for slack_call in slack_client_mock.return_value.chat_postMessage.call_args_list
|
|
] == ["C12345", "C67890", "C67890", "C67890"]
|
|
|
|
|
|
@pytest.mark.parametrize("send_fails", [False, True], ids=["success", "failure"])
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_text_send_clamps_timeout_to_shared_deadline(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
send_fails: bool,
|
|
mock_header_data: HeaderDataType,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
client = slack_client_mock.return_value
|
|
client.timeout = 30
|
|
|
|
def assert_timeout(**kwargs: object) -> dict[str, bool]:
|
|
assert client.timeout == 10
|
|
assert type(client.timeout) is int
|
|
if send_fails:
|
|
raise SlackApiError(
|
|
message="service unavailable",
|
|
response={"ok": False, "error": "service_unavailable"},
|
|
)
|
|
return {"ok": True}
|
|
|
|
client.chat_postMessage.side_effect = assert_timeout
|
|
notification = _make_v2_notification(
|
|
_make_content(mock_header_data),
|
|
target="C12345",
|
|
)
|
|
|
|
clock = iter([0.0, 140.0])
|
|
with patch(
|
|
"superset.reports.notifications.slack_transport.time.monotonic",
|
|
side_effect=lambda: next(clock, 140.0),
|
|
):
|
|
if send_fails:
|
|
with pytest.raises(NotificationTransientError, match="C12345"):
|
|
notification.send()
|
|
else:
|
|
notification.send()
|
|
|
|
assert client.timeout == 30
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_does_not_retry_permanent_slack_api_error(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
slack_client_mock.return_value.chat_postMessage.side_effect = SlackApiError(
|
|
message="channel not found",
|
|
response={"ok": False, "error": "channel_not_found"},
|
|
)
|
|
|
|
content = _make_content(mock_header_data)
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
|
|
with pytest.raises(NotificationUnprocessableException):
|
|
notification.send()
|
|
|
|
assert slack_client_mock.return_value.chat_postMessage.call_count == 1
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("content_overrides", "method_name", "target_keyword"),
|
|
[
|
|
({}, "chat_postMessage", "channel"),
|
|
(
|
|
{"screenshots": [b"screenshot"]},
|
|
"files_completeUploadExternal",
|
|
"channel_id",
|
|
),
|
|
],
|
|
ids=["text", "file"],
|
|
)
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_reports_failed_channel_and_continues_later_channels(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
content_overrides: dict[str, Any],
|
|
method_name: str,
|
|
target_keyword: str,
|
|
mock_header_data: HeaderDataType,
|
|
raw_upload_mock: MagicMock,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
if content_overrides:
|
|
_configure_v2_upload_client(slack_client_mock.return_value)
|
|
raw_upload_mock.return_value = (200, "ok")
|
|
|
|
def send_side_effect(**kwargs: object) -> dict[str, bool]:
|
|
channel = kwargs[target_keyword]
|
|
if channel == "C2":
|
|
raise SlackApiError(
|
|
message="channel not found",
|
|
response={"ok": False, "error": "channel_not_found"},
|
|
)
|
|
return {"ok": True}
|
|
|
|
method = getattr(slack_client_mock.return_value, method_name)
|
|
method.side_effect = send_side_effect
|
|
notification = _make_v2_notification(
|
|
_make_content(mock_header_data, **content_overrides),
|
|
target="C1,C2,C3",
|
|
)
|
|
|
|
with pytest.raises(NotificationUnprocessableException, match="C2"):
|
|
notification.send()
|
|
|
|
assert [
|
|
slack_call.kwargs[target_keyword] for slack_call in method.call_args_list
|
|
] == [
|
|
"C1",
|
|
"C2",
|
|
"C3",
|
|
]
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_does_not_retry_param_errors(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
"""Non-transient configuration and authorization errors are not retried.
|
|
|
|
A NotificationParamException-class failure (BotUserAccessError → 422) hits
|
|
the API exactly once and surfaces immediately.
|
|
"""
|
|
flask_global_mock.logs_context = {}
|
|
slack_client_mock.return_value.chat_postMessage.side_effect = BotUserAccessError(
|
|
"bot user blocked"
|
|
)
|
|
|
|
content = _make_content(mock_header_data)
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
|
|
with pytest.raises(NotificationParamException):
|
|
notification.send()
|
|
|
|
assert slack_client_mock.return_value.chat_postMessage.call_count == 1
|
|
|
|
|
|
def _make_slack_response(
|
|
status_code: int,
|
|
data: dict[str, Any],
|
|
headers: dict[str, list[str]] | None = None,
|
|
) -> SlackResponse:
|
|
"""Build an SDK-faithful SlackResponse that carries a real ``status_code``.
|
|
|
|
The existing retry tests pass ``SlackApiError(response={...})`` — a plain
|
|
dict, which has no ``status_code`` attribute. That makes
|
|
``get_slack_api_status_code`` return ``None`` and the ``429 / 5xx → retry``
|
|
branch in ``_give_up_slack_api_retry`` is never exercised. The real SDK hands
|
|
back a ``SlackResponse`` with a populated ``status_code``, so we mirror that
|
|
here to cover the status-code branch faithfully.
|
|
"""
|
|
return SlackResponse(
|
|
client=None,
|
|
http_verb="POST",
|
|
api_url="https://slack.com/api/chat.postMessage",
|
|
req_args={},
|
|
data=data,
|
|
headers=headers or {},
|
|
status_code=status_code,
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"status_code,data",
|
|
[
|
|
# 5xx with an empty / unknown body: no `error` code to classify, so the
|
|
# status-code branch is the only thing that can decide to retry. This is
|
|
# the case the dict-based tests silently lost.
|
|
(503, {"ok": False}),
|
|
(502, {}),
|
|
],
|
|
)
|
|
def test_give_up_slack_api_retry_retries_on_status_code(
|
|
status_code: int, data: dict[str, Any]
|
|
) -> None:
|
|
response = _make_slack_response(status_code, data)
|
|
ex = SlackApiError(message="transient", response=response)
|
|
|
|
# give_up == False -> the request WILL be retried by backoff.
|
|
assert _give_up_slack_api_retry(ex) is False
|
|
|
|
|
|
def test_give_up_slack_api_retry_retries_http_408() -> None:
|
|
"""Request timeout stays transient even though it is a 4xx response."""
|
|
response = _make_slack_response(
|
|
408,
|
|
{"ok": False, "error": "request_timeout"},
|
|
)
|
|
ex = SlackApiError(message="request timed out", response=response)
|
|
|
|
assert _give_up_slack_api_retry(ex) is False
|
|
|
|
|
|
def test_give_up_slack_api_retry_stops_after_rate_limit_budget() -> None:
|
|
"""Do not multiply call_slack_api's configured rate-limit retry budget."""
|
|
response = _make_slack_response(
|
|
429,
|
|
{"ok": False, "error": "ratelimited"},
|
|
)
|
|
ex = SlackApiError(message="rate limited", response=response)
|
|
|
|
assert _give_up_slack_api_retry(ex) is True
|
|
|
|
|
|
def test_call_slack_api_rate_limit_retries_respect_shared_deadline(mocker) -> None:
|
|
"""A Retry-After wait cannot begin an SDK write past the shared deadline."""
|
|
response = _make_slack_response(
|
|
429,
|
|
{"ok": False, "error": "ratelimited"},
|
|
headers={"Retry-After": ["120"]},
|
|
)
|
|
method = MagicMock(
|
|
side_effect=SlackApiError(message="rate limited", response=response)
|
|
)
|
|
clock = [0.0]
|
|
sleep = mocker.patch("superset.reports.notifications.slack_transport.time.sleep")
|
|
sleep.side_effect = lambda duration: clock.__setitem__(0, clock[0] + duration)
|
|
mocker.patch(
|
|
"superset.reports.notifications.slack_transport.time.monotonic",
|
|
side_effect=lambda: clock[0],
|
|
)
|
|
mocker.patch.dict(
|
|
"superset.reports.notifications.slack_transport.app.config",
|
|
{"SLACK_API_RATE_LIMIT_RETRY_COUNT": 2},
|
|
)
|
|
|
|
with pytest.raises(SlackRetryDeadlineError, match="deadline exceeded"):
|
|
call_slack_api(method, retry_deadline=150.0)
|
|
|
|
assert method.call_count == 2
|
|
sleep.assert_called_once_with(120.0)
|
|
|
|
|
|
def test_call_slack_api_rate_limit_budget_spans_server_error_retries(mocker) -> None:
|
|
"""A server-error retry does not reset the configured HTTP 429 budget."""
|
|
rate_limit_error = SlackApiError(
|
|
message="rate limited",
|
|
response=_make_slack_response(
|
|
429,
|
|
{"ok": False, "error": "ratelimited"},
|
|
headers={"Retry-After": ["0"]},
|
|
),
|
|
)
|
|
server_error = SlackApiError(
|
|
message="service unavailable",
|
|
response=_make_slack_response(503, {"ok": False}),
|
|
)
|
|
method = MagicMock(side_effect=[rate_limit_error, server_error, rate_limit_error])
|
|
mocker.patch.dict(
|
|
"superset.reports.notifications.slack_transport.app.config",
|
|
{"SLACK_API_RATE_LIMIT_RETRY_COUNT": 1},
|
|
)
|
|
|
|
with pytest.raises(SlackApiError):
|
|
call_slack_api(method)
|
|
|
|
assert method.call_count == 3
|
|
|
|
|
|
def test_call_slack_api_retries_ratelimited_code_without_http_429() -> None:
|
|
"""A ratelimited response without HTTP 429 uses application backoff."""
|
|
ex = SlackApiError(
|
|
message="rate limited",
|
|
response={"ok": False, "error": "ratelimited"},
|
|
)
|
|
method = MagicMock(side_effect=ex)
|
|
|
|
with pytest.raises(SlackApiError):
|
|
call_slack_api(method)
|
|
|
|
assert method.call_count == 5
|
|
|
|
|
|
def test_send_to_slack_channels_shares_one_delivery_budget() -> None:
|
|
"""A failed channel cannot multiply the schedule-wide delivery budget."""
|
|
clock = [0.0]
|
|
starts: dict[str, float] = {}
|
|
deadlines: dict[str, float] = {}
|
|
error = SlackApiError(
|
|
message="service unavailable",
|
|
response={"ok": False, "error": "service_unavailable"},
|
|
)
|
|
first_method = MagicMock()
|
|
|
|
def fail_first_channel() -> None:
|
|
clock[0] = 151.0
|
|
raise error
|
|
|
|
first_method.side_effect = fail_first_channel
|
|
methods = {
|
|
"private-a": first_method,
|
|
"private-b": MagicMock(return_value={"ok": True}),
|
|
"private-c": MagicMock(return_value={"ok": True}),
|
|
}
|
|
|
|
def send(channel: str, retry_deadline: float) -> None:
|
|
starts[channel] = clock[0]
|
|
deadlines[channel] = retry_deadline
|
|
call_slack_api(methods[channel], retry_deadline=retry_deadline)
|
|
|
|
with (
|
|
patch(
|
|
"superset.reports.notifications.slack_transport.time.monotonic",
|
|
side_effect=lambda: clock[0],
|
|
),
|
|
pytest.raises(NotificationTransientError, match="private-a"),
|
|
):
|
|
send_to_slack_channels(["private-a", "private-b", "private-c"], send)
|
|
|
|
methods["private-a"].assert_called_once_with()
|
|
methods["private-b"].assert_not_called()
|
|
methods["private-c"].assert_not_called()
|
|
assert deadlines == pytest.approx(
|
|
{
|
|
"private-a": 150.0,
|
|
"private-b": 150.0,
|
|
"private-c": 150.0,
|
|
}
|
|
)
|
|
assert starts == {"private-a": 0.0, "private-b": 151.0, "private-c": 151.0}
|
|
|
|
|
|
def test_send_to_slack_channels_uses_one_deadline_for_high_fanout(mocker) -> None:
|
|
"""High fanout cannot multiply the configured delivery budget."""
|
|
mocker.patch.dict(
|
|
"superset.reports.notifications.slack_transport.app.config",
|
|
{
|
|
"SLACK_SEND_RETRY_MAX_TIME": 150,
|
|
"SLACK_API_TIMEOUT": 30,
|
|
},
|
|
)
|
|
monotonic = mocker.patch(
|
|
"superset.reports.notifications.slack_transport.time.monotonic",
|
|
side_effect=[10.0, *range(20, 120, 10)],
|
|
)
|
|
deadlines: list[float] = []
|
|
|
|
def send(_channel: str, retry_deadline: float) -> None:
|
|
deadlines.append(retry_deadline)
|
|
|
|
send_to_slack_channels([f"C{index}" for index in range(10)], send)
|
|
|
|
assert deadlines == [160.0] * 10
|
|
monotonic.assert_called_once_with()
|
|
|
|
|
|
def test_send_to_slack_channels_clamps_to_report_deadline(mocker) -> None:
|
|
"""The report task deadline wins when it is shorter than Slack's budget."""
|
|
mocker.patch.dict(
|
|
"superset.reports.notifications.slack_transport.app.config",
|
|
{
|
|
"SLACK_SEND_RETRY_MAX_TIME": 150,
|
|
"SLACK_API_TIMEOUT": 30,
|
|
},
|
|
)
|
|
mocker.patch(
|
|
"superset.reports.notifications.slack_transport.time.monotonic",
|
|
return_value=10.0,
|
|
)
|
|
deadlines: list[float] = []
|
|
|
|
send_to_slack_channels(
|
|
["C1", "C2"],
|
|
lambda _channel, retry_deadline: deadlines.append(retry_deadline),
|
|
retry_deadline=40.0,
|
|
)
|
|
|
|
assert deadlines == [40.0, 40.0]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"transport_error",
|
|
[
|
|
URLError("connection reset"),
|
|
ConnectionResetError("connection reset"),
|
|
RemoteDisconnected("connection closed"),
|
|
TimeoutError("timed out"),
|
|
],
|
|
)
|
|
def test_send_to_slack_channels_does_not_retry_outcome_unknown_transport_errors(
|
|
transport_error: Exception,
|
|
) -> None:
|
|
"""Response-loss errors remain transient without duplicating accepted writes."""
|
|
methods = {
|
|
"private-a": MagicMock(side_effect=transport_error),
|
|
"private-b": MagicMock(return_value={"ok": True}),
|
|
}
|
|
|
|
def send(channel: str, retry_deadline: float) -> None:
|
|
call_slack_api(methods[channel], retry_deadline=retry_deadline)
|
|
|
|
with pytest.raises(NotificationTransientError, match="private-a"):
|
|
send_to_slack_channels(["private-a", "private-b"], send)
|
|
|
|
methods["private-a"].assert_called_once_with()
|
|
methods["private-b"].assert_called_once_with()
|
|
|
|
|
|
def test_mixed_channel_failures_escalate_to_transient() -> None:
|
|
"""One transient failure makes a mixed batch retryable."""
|
|
permanent = SlackApiError(
|
|
message="channel not found",
|
|
response={"ok": False, "error": "channel_not_found"},
|
|
)
|
|
transient = SlackApiError(
|
|
message="service unavailable",
|
|
response={"ok": False, "error": "service_unavailable"},
|
|
)
|
|
failures = {
|
|
"permanent": permanent,
|
|
"transient": transient,
|
|
}
|
|
|
|
def send(channel: str, _retry_deadline: float) -> None:
|
|
raise failures[channel]
|
|
|
|
with pytest.raises(
|
|
NotificationTransientError,
|
|
match=r"(?s)permanent.*transient",
|
|
):
|
|
send_to_slack_channels(["permanent", "transient"], send)
|
|
|
|
|
|
def test_call_slack_api_checks_monotonic_deadline_before_each_retry() -> None:
|
|
"""A failed call that consumes the budget cannot start another attempt."""
|
|
clock = [0.0]
|
|
error = SlackApiError(
|
|
message="service unavailable",
|
|
response={"ok": False, "error": "service_unavailable"},
|
|
)
|
|
method = MagicMock()
|
|
|
|
def fail_after_deadline() -> None:
|
|
clock[0] = 151.0
|
|
raise error
|
|
|
|
method.side_effect = fail_after_deadline
|
|
|
|
with (
|
|
patch(
|
|
"superset.reports.notifications.slack_transport.time.monotonic",
|
|
side_effect=lambda: clock[0],
|
|
),
|
|
pytest.raises(SlackRetryDeadlineError, match="deadline exceeded"),
|
|
):
|
|
call_slack_api(method, retry_deadline=150.0)
|
|
|
|
method.assert_called_once_with()
|
|
|
|
|
|
def test_call_slack_api_without_explicit_deadline_is_still_bounded() -> None:
|
|
"""The helper's default cannot create an unbounded retry operation."""
|
|
clock = [0.0]
|
|
error = SlackApiError(
|
|
message="service unavailable",
|
|
response={"ok": False, "error": "service_unavailable"},
|
|
)
|
|
method = MagicMock()
|
|
|
|
def fail_after_default_deadline() -> None:
|
|
clock[0] = 151.0
|
|
raise error
|
|
|
|
method.side_effect = fail_after_default_deadline
|
|
|
|
with (
|
|
patch(
|
|
"superset.reports.notifications.slack_transport.time.monotonic",
|
|
side_effect=lambda: clock[0],
|
|
),
|
|
pytest.raises(SlackRetryDeadlineError, match="deadline exceeded"),
|
|
):
|
|
call_slack_api(method)
|
|
|
|
method.assert_called_once_with()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("configured_budget", "request_timeout", "expected_budget"),
|
|
[
|
|
(150, 30, 150),
|
|
(150, 300, 301),
|
|
(600, 300, 600),
|
|
],
|
|
)
|
|
def test_send_deadline_respects_channel_budget_and_request_timeout(
|
|
mocker,
|
|
configured_budget: int,
|
|
request_timeout: int,
|
|
expected_budget: int,
|
|
) -> None:
|
|
"""The send budget is configurable and cannot neutralize request timeout."""
|
|
mocker.patch.dict(
|
|
"superset.reports.notifications.slack_transport.app.config",
|
|
{
|
|
"SLACK_SEND_RETRY_MAX_TIME": configured_budget,
|
|
"SLACK_API_TIMEOUT": request_timeout,
|
|
},
|
|
)
|
|
monotonic = mocker.patch(
|
|
"superset.reports.notifications.slack_transport.time.monotonic",
|
|
return_value=10.0,
|
|
)
|
|
send = MagicMock()
|
|
|
|
send_to_slack_channels(["C1"], send)
|
|
|
|
send.assert_called_once_with("C1", 10.0 + expected_budget)
|
|
assert monotonic.called
|
|
|
|
|
|
def test_send_deadline_uses_default_budget_when_config_is_absent(mocker) -> None:
|
|
"""Custom configs predating the setting retain the documented default."""
|
|
mocker.patch.dict(
|
|
"superset.reports.notifications.slack_transport.app.config",
|
|
{"SLACK_API_TIMEOUT": 30},
|
|
clear=True,
|
|
)
|
|
mocker.patch(
|
|
"superset.reports.notifications.slack_transport.time.monotonic",
|
|
return_value=10.0,
|
|
)
|
|
send = MagicMock()
|
|
|
|
send_to_slack_channels(["C1"], send)
|
|
|
|
send.assert_called_once_with("C1", 160.0)
|
|
|
|
|
|
def test_deadline_error_names_operator_setting() -> None:
|
|
"""Expired sends identify the config knob that controls channel budgets."""
|
|
with (
|
|
patch(
|
|
"superset.reports.notifications.slack_transport.time.monotonic",
|
|
return_value=151.0,
|
|
),
|
|
pytest.raises(
|
|
SlackRetryDeadlineError,
|
|
match="SLACK_SEND_RETRY_MAX_TIME",
|
|
),
|
|
):
|
|
call_slack_api(MagicMock(), retry_deadline=150.0)
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_uses_configured_application_rate_limit_budget(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data: HeaderDataType,
|
|
) -> None:
|
|
"""Delivery 429 retries use one configured application-owned budget."""
|
|
flask_global_mock.logs_context = {}
|
|
response = _make_slack_response(
|
|
429,
|
|
{"ok": False, "error": "ratelimited"},
|
|
headers={"Retry-After": ["0"]},
|
|
)
|
|
slack_client_mock.return_value.chat_postMessage.side_effect = SlackApiError(
|
|
message="rate limited",
|
|
response=response,
|
|
)
|
|
notification = _make_v2_notification(
|
|
_make_content(mock_header_data),
|
|
target="C12345",
|
|
)
|
|
|
|
with (
|
|
patch("superset.reports.notifications.slack_transport.time.sleep"),
|
|
pytest.raises(NotificationError, match="C12345"),
|
|
):
|
|
notification.send()
|
|
|
|
assert slack_client_mock.return_value.chat_postMessage.call_count == 3
|
|
|
|
|
|
def test_give_up_slack_api_retry_gives_up_on_permanent_status_code() -> None:
|
|
"""Control: a 4xx (non-429) with a non-transient error code is not retried,
|
|
even when carried by a faithful SlackResponse — so the new helper above is
|
|
asserting the status-code branch, not merely that everything retries.
|
|
"""
|
|
response = _make_slack_response(404, {"ok": False, "error": "channel_not_found"})
|
|
ex = SlackApiError(message="permanent", response=response)
|
|
|
|
assert _give_up_slack_api_retry(ex) is True
|
|
|
|
|
|
def test_call_slack_api_does_not_retry_empty_client_error_response() -> None:
|
|
"""A non-429 4xx is permanent even when Slack omits an error code."""
|
|
response = _make_slack_response(400, {})
|
|
ex = SlackApiError(message="bad request", response=response)
|
|
method = MagicMock(side_effect=ex)
|
|
|
|
with pytest.raises(SlackApiError):
|
|
call_slack_api(method)
|
|
|
|
method.assert_called_once_with()
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_records_statsd_gauge_on_success(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
flask_global_mock.logs_context = {}
|
|
content = _make_content(mock_header_data)
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
|
|
with patch(
|
|
"superset.extensions.stats_logger_manager.instance.gauge"
|
|
) as statsd_mock:
|
|
notification.send()
|
|
|
|
statsd_mock.assert_called_with("reports.slack.send.ok", 1)
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_records_statsd_gauge_warning_on_param_error(
|
|
slack_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
"""Status<500 exceptions (NotificationParamException is 422) → .warning."""
|
|
flask_global_mock.logs_context = {}
|
|
slack_client_mock.return_value.chat_postMessage.side_effect = (
|
|
SlackClientConfigurationError("bad config")
|
|
)
|
|
|
|
content = _make_content(mock_header_data)
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
|
|
with patch(
|
|
"superset.extensions.stats_logger_manager.instance.gauge"
|
|
) as statsd_mock:
|
|
with pytest.raises(NotificationParamException):
|
|
notification.send()
|
|
|
|
assert call("reports.slack.send.warning", 1) in statsd_mock.call_args_list
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.logger")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_propagates_execution_id_to_logs(
|
|
slack_client_mock: MagicMock,
|
|
logger_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
raw_upload_mock: MagicMock,
|
|
) -> None:
|
|
"""The success log carries the execution_id from g.logs_context."""
|
|
execution_id = uuid.uuid4()
|
|
flask_global_mock.logs_context = {"execution_id": execution_id}
|
|
_configure_v2_upload_client(slack_client_mock.return_value)
|
|
|
|
content = _make_content(mock_header_data, screenshots=[b"shot"])
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
notification.send()
|
|
|
|
logger_mock.info.assert_called_with(
|
|
"Report sent to slack", extra={"execution_id": execution_id}
|
|
)
|
|
raw_upload_mock.assert_called_once()
|
|
|
|
|
|
@patch("superset.reports.notifications.slackv2.g")
|
|
@patch("superset.reports.notifications.slackv2.logger")
|
|
@patch("superset.reports.notifications.slackv2.get_slack_client")
|
|
def test_v2_send_handles_missing_logs_context(
|
|
slack_client_mock: MagicMock,
|
|
logger_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
"""When g.logs_context is None or missing, the log uses execution_id=None."""
|
|
# Mirrors `getattr(g, "logs_context", {}) or {}` falsy-coalescing path.
|
|
flask_global_mock.logs_context = None
|
|
|
|
content = _make_content(mock_header_data)
|
|
notification = _make_v2_notification(content, target="C12345")
|
|
notification.send()
|
|
|
|
logger_mock.info.assert_called_with(
|
|
"Report sent to slack", extra={"execution_id": None}
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End-to-end auto-upgrade: v1 recipient → SlackV1NotificationError → upgrade →
|
|
# row mutated to IDs → second send takes the v2 fast path with no resolution.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@patch(
|
|
"superset.utils.slack.feature_flag_manager.is_feature_enabled",
|
|
return_value=True,
|
|
)
|
|
@patch("superset.reports.notifications.slack.g")
|
|
@patch("superset.utils.slack.get_slack_client")
|
|
@patch("superset.reports.notifications.slack.get_slack_client")
|
|
@patch(
|
|
"superset.reports.notifications.slack_channel_resolver"
|
|
".get_channels_with_search_and_cache_status"
|
|
)
|
|
def test_auto_upgrade_round_trip_v1_to_v2(
|
|
get_channels_with_search_mock: MagicMock,
|
|
v1_client_mock: MagicMock,
|
|
util_client_mock: MagicMock,
|
|
flask_global_mock: MagicMock,
|
|
feature_flag_mock: MagicMock,
|
|
mock_header_data,
|
|
) -> None:
|
|
"""A v1 SLACK recipient with channel names is auto-upgraded to SlackV2 with
|
|
channel IDs after the first send raises SlackV1NotificationError.
|
|
|
|
The second send-as-v2 then uses the resolved IDs without further lookups.
|
|
"""
|
|
from superset.commands.report.execute import BaseReportState
|
|
from superset.reports.models import (
|
|
ReportRecipients,
|
|
ReportRecipientType,
|
|
ReportSchedule,
|
|
)
|
|
from superset.reports.notifications.exceptions import SlackV1NotificationError
|
|
from superset.reports.notifications.slack import SlackNotification
|
|
|
|
flask_global_mock.logs_context = {}
|
|
# Scopes are present → should_use_v2_api returns True → v1.send raises
|
|
util_client_mock.return_value.conversations_list.return_value = {
|
|
"channels": [{"id": "C12345", "name": "general"}]
|
|
}
|
|
get_channels_with_search_mock.return_value = (
|
|
[
|
|
{
|
|
"id": "C12345",
|
|
"name": "general",
|
|
"is_member": True,
|
|
"is_private": False,
|
|
}
|
|
],
|
|
False,
|
|
)
|
|
|
|
schedule = ReportSchedule(
|
|
recipients=[
|
|
ReportRecipients(
|
|
type=ReportRecipientType.SLACK,
|
|
recipient_config_json='{"target": "general"}',
|
|
)
|
|
]
|
|
)
|
|
content = _make_content(mock_header_data)
|
|
|
|
# Step 1: v1 send raises SlackV1NotificationError because v2 is available.
|
|
v1 = SlackNotification(recipient=schedule.recipients[0], content=content)
|
|
with pytest.raises(SlackV1NotificationError):
|
|
v1.send()
|
|
|
|
# Step 2: command-level upgrade rewrites the row to SlackV2 with channel IDs.
|
|
state = BaseReportState(schedule, "January 1, 2021", "exec-id")
|
|
state.update_report_schedule_slack_v2()
|
|
assert schedule.recipients[0].type == ReportRecipientType.SLACKV2
|
|
assert schedule.recipients[0].recipient_config_json == '{"target": "C12345"}'
|
|
|
|
# Step 3: a fresh SlackV2Notification on the rewritten row sends to the
|
|
# resolved channel ID directly. (SlackV2Notification.send() never calls
|
|
# get_channels_with_search itself, so the upgraded row carrying IDs in
|
|
# its target is the load-bearing assertion here.)
|
|
with patch(
|
|
"superset.reports.notifications.slackv2.get_slack_client"
|
|
) as v2_client_mock:
|
|
v2 = SlackV2Notification(recipient=schedule.recipients[0], content=content)
|
|
v2.send()
|
|
v2_client_mock.return_value.chat_postMessage.assert_called_once()
|
|
assert (
|
|
v2_client_mock.return_value.chat_postMessage.call_args.kwargs["channel"]
|
|
== "C12345"
|
|
)
|