# 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 typing import Any
from unittest.mock import call, MagicMock, patch
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,
NotificationMalformedException,
NotificationParamException,
NotificationUnprocessableException,
)
from superset.reports.notifications.slackv2 import (
_give_up_slack_api_retry,
SlackV2Notification,
)
from superset.utils.core import HeaderDataType
@pytest.fixture(autouse=True)
def _skip_backoff_sleep():
"""Make any @backoff.on_exception retries instant.
SlackV2Notification.send() retries up to 5 times with `backoff.expo(factor=10,
base=2)` — that's ~150s of real sleep on a persistently-failing send. We
don't care about the wall-clock waits in unit tests; patching `time.sleep`
inside backoff's sync runner keeps the assertion semantics (call_count,
raised exception type) without the wait.
"""
with patch("backoff._sync.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 test_get_channel_with_multi_recipients(mock_header_data) -> None:
"""
Test the _get_channel function to ensure it will return a string
with recipients separated by commas without interstitial spacing
"""
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", '333'],
}
),
description='
This is a test alert
',
)
slack_notification = SlackNotification(
recipient=ReportRecipients(
type=ReportRecipientType.SLACK,
recipient_config_json='{"target": "some_channel; second_channel, third_channel"}', # noqa: E501
),
content=content,
)
result = slack_notification._get_channel()
assert result == "some_channel,second_channel,third_channel"
# Test if the recipient configuration JSON is valid when using a SlackV2 recipient type # noqa: E501
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", '333'],
}
),
description='This is a test alert
',
)
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
def test_get_inline_files_with_screenshots(mock_header_data) -> None:
"""
Test the _get_inline_files function to ensure it will return the correct tuple
when content has screenshots
"""
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", '333'],
}
),
description='This is a test alert
',
screenshots=[b"screenshot1", b"screenshot2"],
)
slack_notification = SlackNotification(
recipient=ReportRecipients(
type=ReportRecipientType.SLACK,
recipient_config_json='{"target": "some_channel"}',
),
content=content,
)
result = slack_notification._get_inline_files()
assert result == ("png", [b"screenshot1", b"screenshot2"])
# Ensure _get_inline_files function returns None when content has no screenshots or csv # noqa: E501
def test_get_inline_files_with_no_screenshots_or_csv(mock_header_data) -> None:
"""
Test the _get_inline_files function to ensure it will return None
when content has no screenshots or csv
"""
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", '333'],
}
),
description='This is a test alert
',
)
slack_notification = SlackNotification(
recipient=ReportRecipients(
type=ReportRecipientType.SLACK,
recipient_config_json='{"target": "some_channel"}',
),
content=content,
)
result = slack_notification._get_inline_files()
assert result == (None, [])
@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", '333'],
}
),
description='This is a test alert
',
)
notification = SlackV2Notification(
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*
This is a test alert
```
| | A | B | C |
|---:|----:|----:|:-----------------------------------------|
| 0 | 1 | 4 | 111 |
| 1 | 2 | 5 | 222 |
| 2 | 3 | 6 | 333 |
```
""",
)
@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,
) -> 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}
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", '333'],
}
),
description='This is a test alert
',
)
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*
This is a test alert
```
| | A | B | C |
|---:|----:|----:|:-----------------------------------------|
| 0 | 1 | 4 | 111 |
| 1 | 2 | 5 | 222 |
| 2 | 3 | 6 | 333 |
```
""",
)
@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", '333'],
}
),
description='This is a test alert
',
)
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*
This is a test alert
```
| | A | B | C |
|---:|----:|----:|:-----------------------------------------|
| 0 | 1 | 4 | 111 |
| 1 | 2 | 5 | 222 |
| 2 | 3 | 6 | 333 |
```
""",
)
@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
# ---------------------------------------------------------------------------
# Bulletproof v2 send-path coverage
#
# The tests above exercise the chat_postMessage path (text-only sends). The
# tests below cover files_upload_v2 across screenshots/CSV/PDF, multi-channel
# fan-out, exception mapping, backoff, statsd, and logs propagation. Together
# they guarantee that every observable behavior of SlackV2Notification.send()
# is locked down before Slack v1 is removed.
# ---------------------------------------------------------------------------
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)
@patch("superset.reports.notifications.slackv2.g")
@patch("superset.reports.notifications.slackv2.get_slack_client")
def test_v2_send_with_single_screenshot_calls_files_upload_v2(
slack_client_mock: MagicMock,
flask_global_mock: MagicMock,
mock_header_data,
) -> None:
flask_global_mock.logs_context = {"execution_id": uuid.uuid4()}
content = _make_content(mock_header_data, screenshots=[b"screenshot-bytes"])
notification = _make_v2_notification(content, target="C12345")
notification.send()
upload = slack_client_mock.return_value.files_upload_v2
upload.assert_called_once()
kwargs = upload.call_args.kwargs
assert kwargs["channel"] == "C12345"
assert kwargs["file"] == b"screenshot-bytes"
assert kwargs["title"] == "test alert"
assert kwargs["filename"] == "test alert.png"
assert "test alert" in kwargs["initial_comment"]
# chat_postMessage should NOT be called when files are present
slack_client_mock.return_value.chat_postMessage.assert_not_called()
@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,
) -> None:
flask_global_mock.logs_context = {}
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()
upload = slack_client_mock.return_value.files_upload_v2
assert upload.call_count == 3
uploaded_files = [c.kwargs["file"] for c in upload.call_args_list]
assert uploaded_files == [b"shot-1", b"shot-2", b"shot-3"]
# All three uploads target the same single channel
for c in upload.call_args_list:
assert c.kwargs["channel"] == "C12345"
assert c.kwargs["filename"] == "test alert.png"
@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,
) -> None:
flask_global_mock.logs_context = {}
content = _make_content(mock_header_data, csv=b"col1,col2\n1,2\n")
notification = _make_v2_notification(content, target="C12345")
notification.send()
upload = slack_client_mock.return_value.files_upload_v2
upload.assert_called_once()
kwargs = upload.call_args.kwargs
assert kwargs["file"] == b"col1,col2\n1,2\n"
assert kwargs["filename"] == "test alert.csv"
@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,
) -> None:
flask_global_mock.logs_context = {}
content = _make_content(mock_header_data, pdf=b"%PDF-1.4...")
notification = _make_v2_notification(content, target="C12345")
notification.send()
upload = slack_client_mock.return_value.files_upload_v2
upload.assert_called_once()
kwargs = upload.call_args.kwargs
assert kwargs["file"] == b"%PDF-1.4..."
assert kwargs["filename"] == "test alert.pdf"
@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,
) -> None:
flask_global_mock.logs_context = {}
content = _make_content(mock_header_data, screenshots=[b"shot-1", b"shot-2"])
notification = _make_v2_notification(content, target="C12345,C67890,C11111")
notification.send()
upload = slack_client_mock.return_value.files_upload_v2
# 3 channels x 2 files = 6 uploads
assert upload.call_count == 6
seen = {(c.kwargs["channel"], c.kwargs["file"]) for c in upload.call_args_list}
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_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"),
NotificationUnprocessableException,
),
(
# 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_retries_on_transient_slack_api_error(
slack_client_mock: MagicMock,
flask_global_mock: MagicMock,
mock_header_data,
) -> None:
"""`@backoff.on_exception(NotificationUnprocessableException, max_tries=5)`
retries the wrapped exception that send() actually raises.
A persistent Slack rate-limit (or any other transient failure that maps to
NotificationUnprocessableException) results in exactly max_tries=5 send
attempts before the final exception propagates. This mirrors the existing
pattern in webhook.py.
"""
flask_global_mock.logs_context = {}
slack_client_mock.return_value.chat_postMessage.side_effect = SlackApiError(
message="rate limited", response={"ok": False, "error": "ratelimited"}
)
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 == 5
@patch("superset.reports.notifications.slackv2.g")
@patch("superset.reports.notifications.slackv2.get_slack_client")
def test_v2_send_retries_then_succeeds_on_transient_failure(
slack_client_mock: MagicMock,
flask_global_mock: MagicMock,
mock_header_data,
) -> None:
"""The point of switching backoff to NotificationUnprocessableException is
that a *transient* failure now retries and the send ultimately succeeds —
behavior the old (dead) SlackApiError decorator never delivered. Fail twice,
then succeed: send() must return normally after exactly 3 attempts and still
record the success gauge.
"""
flask_global_mock.logs_context = {}
slack_client_mock.return_value.chat_postMessage.side_effect = [
SlackApiError(
message="rate limited", response={"ok": False, "error": "ratelimited"}
),
SlackApiError(
message="rate limited", response={"ok": False, "error": "ratelimited"}
),
{"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
raise SlackApiError(
message="rate limited", response={"ok": False, "error": "ratelimited"}
)
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"]
@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
@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 errors (config / auth / malformed) are NOT retried — only
NotificationUnprocessableException triggers backoff. 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]) -> 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={},
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, {}),
# Rate limiting is retryable by status code as well as error code.
(429, {"ok": False, "error": "ratelimited"}),
],
)
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_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
@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,
) -> 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}
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}
)
@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.commands.report.execute.get_channels_with_search")
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}
]
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"
)