Files
superset2/tests/unit_tests/commands/report/execute_test.py
Joe Li 0c02e0a3fb fix(reports): handle missing executor user and revert Slack v2 atomically
Two robustness fixes in the alert/report execution command:

1. Missing executor user: when the configured executor cannot be resolved
   (security_manager.find_user returns None), the content-generation sites
   (_get_screenshots / _get_csv_data / _get_embedded_data) now raise a
   dedicated ReportScheduleExecutorNotFoundError instead of failing later
   with an opaque NoneType error. The guard lives at the content sites so
   it raises inside the state machine's error envelope, preserving the
   ERROR execution-log row and the owner error notification. The run()
   boundary continues to delegate to the state machine (a missing user is
   tolerated there, matching prior behavior) so visibility is unchanged.

2. Slack v2 migration revert: update_report_schedule_slack_v2 now snapshots
   every recipient it mutates and reverts all of them on failure, instead
   of only the loop variable, and no longer raises UnboundLocalError when
   the failure occurs before the loop binds a recipient.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:11:15 -07:00

1917 lines
69 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 json # noqa: TID251
from datetime import datetime, timedelta
from unittest.mock import patch
from uuid import UUID, uuid4
import pytest
from pytest_mock import MockerFixture
from superset.app import SupersetApp
from superset.commands.exceptions import UpdateFailedError
from superset.commands.report.exceptions import (
ReportScheduleAlertGracePeriodError,
ReportScheduleCsvFailedError,
ReportScheduleExecutorNotFoundError,
ReportSchedulePreviousWorkingError,
ReportScheduleScreenshotFailedError,
ReportScheduleScreenshotTimeout,
ReportScheduleStateNotFoundError,
ReportScheduleUnexpectedError,
ReportScheduleWorkingTimeoutError,
)
from superset.commands.report.execute import (
BaseReportState,
ReportNotTriggeredErrorState,
ReportScheduleStateMachine,
ReportSuccessState,
ReportWorkingState,
)
from superset.daos.report import REPORT_SCHEDULE_ERROR_NOTIFICATION_MARKER
from superset.dashboards.permalink.types import DashboardPermalinkState
from superset.reports.models import (
ReportDataFormat,
ReportRecipients,
ReportRecipientType,
ReportSchedule,
ReportScheduleType,
ReportSourceFormat,
ReportState,
)
from superset.utils.core import HeaderDataType
from superset.utils.screenshots import ChartScreenshot
from tests.integration_tests.conftest import with_feature_flags
def test_log_data_with_chart(mocker: MockerFixture) -> None:
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = True
mock_report_schedule.chart_id = 123
mock_report_schedule.dashboard_id = None
mock_report_schedule.type = "report_type"
mock_report_schedule.report_format = "report_format"
mock_report_schedule.owners = [1, 2]
mock_report_schedule.recipients = []
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
result: HeaderDataType = class_instance._get_log_data()
expected_result: HeaderDataType = {
"notification_type": "report_type",
"notification_source": ReportSourceFormat.CHART,
"notification_format": "report_format",
"chart_id": 123,
"dashboard_id": None,
"owners": [1, 2],
"slack_channels": None,
"execution_id": "execution_id_example",
}
assert result == expected_result
def test_log_data_with_dashboard(mocker: MockerFixture) -> None:
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = 123
mock_report_schedule.type = "report_type"
mock_report_schedule.report_format = "report_format"
mock_report_schedule.owners = [1, 2]
mock_report_schedule.recipients = []
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
result: HeaderDataType = class_instance._get_log_data()
expected_result: HeaderDataType = {
"notification_type": "report_type",
"notification_source": ReportSourceFormat.DASHBOARD,
"notification_format": "report_format",
"chart_id": None,
"dashboard_id": 123,
"owners": [1, 2],
"slack_channels": None,
"execution_id": "execution_id_example",
}
assert result == expected_result
def test_log_data_with_email_recipients(mocker: MockerFixture) -> None:
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = 123
mock_report_schedule.type = "report_type"
mock_report_schedule.report_format = "report_format"
mock_report_schedule.owners = [1, 2]
mock_report_schedule.recipients = []
mock_report_schedule.recipients = [
mocker.Mock(type=ReportRecipientType.EMAIL, recipient_config_json="email_1"),
mocker.Mock(type=ReportRecipientType.EMAIL, recipient_config_json="email_2"),
]
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
result: HeaderDataType = class_instance._get_log_data()
expected_result: HeaderDataType = {
"notification_type": "report_type",
"notification_source": ReportSourceFormat.DASHBOARD,
"notification_format": "report_format",
"chart_id": None,
"dashboard_id": 123,
"owners": [1, 2],
"slack_channels": [],
"execution_id": "execution_id_example",
}
assert result == expected_result
def test_log_data_with_slack_recipients(mocker: MockerFixture) -> None:
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = 123
mock_report_schedule.type = "report_type"
mock_report_schedule.report_format = "report_format"
mock_report_schedule.owners = [1, 2]
mock_report_schedule.recipients = []
mock_report_schedule.recipients = [
mocker.Mock(type=ReportRecipientType.SLACK, recipient_config_json="channel_1"),
mocker.Mock(type=ReportRecipientType.SLACK, recipient_config_json="channel_2"),
]
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
result: HeaderDataType = class_instance._get_log_data()
expected_result: HeaderDataType = {
"notification_type": "report_type",
"notification_source": ReportSourceFormat.DASHBOARD,
"notification_format": "report_format",
"chart_id": None,
"dashboard_id": 123,
"owners": [1, 2],
"slack_channels": ["channel_1", "channel_2"],
"execution_id": "execution_id_example",
}
assert result == expected_result
def test_log_data_no_owners(mocker: MockerFixture) -> None:
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = 123
mock_report_schedule.type = "report_type"
mock_report_schedule.report_format = "report_format"
mock_report_schedule.owners = []
mock_report_schedule.recipients = [
mocker.Mock(type=ReportRecipientType.SLACK, recipient_config_json="channel_1"),
mocker.Mock(type=ReportRecipientType.SLACK, recipient_config_json="channel_2"),
]
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
result: HeaderDataType = class_instance._get_log_data()
expected_result: HeaderDataType = {
"notification_type": "report_type",
"notification_source": ReportSourceFormat.DASHBOARD,
"notification_format": "report_format",
"chart_id": None,
"dashboard_id": 123,
"owners": [],
"slack_channels": ["channel_1", "channel_2"],
"execution_id": "execution_id_example",
}
assert result == expected_result
def test_log_data_with_missing_values(mocker: MockerFixture) -> None:
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = None
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = None
mock_report_schedule.type = "report_type"
mock_report_schedule.report_format = "report_format"
mock_report_schedule.owners = [1, 2]
mock_report_schedule.recipients = [
mocker.Mock(type=ReportRecipientType.SLACK, recipient_config_json="channel_1"),
mocker.Mock(
type=ReportRecipientType.SLACKV2, recipient_config_json="channel_2"
),
]
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
result: HeaderDataType = class_instance._get_log_data()
expected_result: HeaderDataType = {
"notification_type": "report_type",
"notification_source": ReportSourceFormat.DASHBOARD,
"notification_format": "report_format",
"chart_id": None,
"dashboard_id": None,
"owners": [1, 2],
"slack_channels": ["channel_1", "channel_2"],
"execution_id": "execution_id_example",
}
assert result == expected_result
@pytest.mark.parametrize(
"anchors, permalink_side_effect, expected_paths",
[
# Test user select multiple tabs to export in a dashboard report
(
["mock_tab_anchor_1", "mock_tab_anchor_2"],
["url1", "url2"],
[
"superset/dashboard/p/url1/",
"superset/dashboard/p/url2/",
],
),
# Test user select one tab to export in a dashboard report
(
"mock_tab_anchor_1",
["url1"],
["superset/dashboard/p/url1/"],
),
# Test JSON scalar string anchor falls back to single tab
(
json.dumps("mock_tab_anchor_1"),
["url1"],
["superset/dashboard/p/url1/"],
),
],
)
@patch(
"superset.commands.dashboard.permalink.create.CreateDashboardPermalinkCommand.run"
)
@with_feature_flags(ALERT_REPORT_TABS=True)
def test_get_dashboard_urls_with_multiple_tabs(
mock_run, mocker: MockerFixture, anchors, permalink_side_effect, expected_paths, app
) -> None:
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = 123
mock_report_schedule.type = "report_type"
mock_report_schedule.report_format = "report_format"
mock_report_schedule.owners = [1, 2]
mock_report_schedule.recipients = []
mock_report_schedule.extra = {
"dashboard": {
"anchor": json.dumps(anchors) if isinstance(anchors, list) else anchors,
"dataMask": None,
"activeTabs": None,
"urlParams": None,
}
}
mock_report_schedule.get_native_filters_params.return_value = ( # type: ignore
"()",
[],
)
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
mock_run.side_effect = permalink_side_effect
result: list[str] = class_instance.get_dashboard_urls()
# Build expected URIs using the app's configured WEBDRIVER_BASEURL
# Use urljoin to handle proper URL joining (handles double slashes)
import urllib.parse
base_url = app.config.get("WEBDRIVER_BASEURL", "http://0.0.0.0:8080/")
expected_uris = [urllib.parse.urljoin(base_url, path) for path in expected_paths]
assert result == expected_uris
@with_feature_flags(ALERT_REPORT_TABS=True)
def test_get_dashboard_urls_with_exporting_dashboard_only(
mocker: MockerFixture,
) -> None:
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = 123
mock_report_schedule.force_screenshot = False
mock_report_schedule.type = "report_type"
mock_report_schedule.report_format = "report_format"
mock_report_schedule.owners = [1, 2]
mock_report_schedule.recipients = []
mock_report_schedule.extra = {
"dashboard": {
"anchor": "",
"dataMask": None,
"activeTabs": None,
"urlParams": None,
}
}
mock_report_schedule.get_native_filters_params.return_value = ( # type: ignore
"()",
[],
)
mock_dashboard = mocker.MagicMock()
mock_dashboard.uuid = UUID("12345678-1234-1234-1234-123456789abc")
mock_report_schedule.dashboard = mock_dashboard
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
result: list[str] = class_instance.get_dashboard_urls()
assert len(result) == 1
assert "/dashboard/p/" not in result[0]
assert "12345678-1234-1234-1234-123456789abc" in result[0]
@patch("superset.commands.report.execute.CreateDashboardPermalinkCommand")
@with_feature_flags(ALERT_REPORT_TABS=True)
def test_get_dashboard_urls_empty_dashboard_state_skips_permalink(
mock_permalink_cls,
mocker: MockerFixture,
) -> None:
"""When both ALERT_REPORT_TABS and ALERT_REPORTS_FILTER are enabled but the
report has no tab or filter configured, get_dashboard_urls() must return
a plain dashboard URL and must not create a permalink. A permalink with
nothing to encode causes a server-side redirect that fails the Playwright
screenshot (domcontentloaded timeout)."""
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.force_screenshot = False
mock_report_schedule.extra = {"dashboard": {}}
mock_report_schedule.get_native_filters_params.return_value = ("()", []) # type: ignore
mock_dashboard = mocker.MagicMock()
mock_dashboard.uuid = UUID("12345678-1234-1234-1234-123456789abc")
mock_report_schedule.dashboard = mock_dashboard
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
result: list[str] = class_instance.get_dashboard_urls()
mock_permalink_cls.assert_not_called()
assert len(result) == 1
assert "/dashboard/p/" not in result[0]
assert "12345678-1234-1234-1234-123456789abc" in result[0]
@patch("superset.commands.report.execute.CreateDashboardPermalinkCommand")
@with_feature_flags(ALERT_REPORT_TABS=True)
def test_get_dashboard_urls_url_params_only_creates_permalink(
mock_permalink_cls,
mocker: MockerFixture,
) -> None:
"""When the dashboard state carries no anchor and no native filters but
does carry meaningful urlParams (e.g. standalone=true), get_dashboard_urls()
must still build a permalink so that state survives in the screenshot,
rather than falling through to the plain dashboard URL."""
mock_permalink_cls.return_value.run.return_value = "key1"
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.force_screenshot = False
mock_report_schedule.extra = {
"dashboard": {
"anchor": "",
"dataMask": None,
"activeTabs": None,
"urlParams": [["standalone", "true"]],
}
}
mock_report_schedule.get_native_filters_params.return_value = ("()", []) # type: ignore
mock_dashboard = mocker.MagicMock()
mock_dashboard.uuid = UUID("12345678-1234-1234-1234-123456789abc")
mock_report_schedule.dashboard = mock_dashboard
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
result: list[str] = class_instance.get_dashboard_urls()
mock_permalink_cls.assert_called_once()
state = mock_permalink_cls.call_args.kwargs["state"]
assert ["standalone", "true"] in state["urlParams"]
assert len(result) == 1
assert "/dashboard/p/" in result[0]
@patch("superset.commands.report.execute.CreateDashboardPermalinkCommand")
@with_feature_flags(ALERT_REPORT_TABS=True)
def test_get_dashboard_urls_with_filters_and_tabs(
mock_permalink_cls,
mocker: MockerFixture,
app,
) -> None:
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = 123
mock_report_schedule.type = "report_type"
mock_report_schedule.report_format = "report_format"
mock_report_schedule.owners = [1, 2]
mock_report_schedule.recipients = []
native_filter_rison = "(NATIVE_FILTER-1:(filterType:filter_select))"
mock_report_schedule.extra = {
"dashboard": {
"anchor": json.dumps(["TAB-1", "TAB-2"]),
"dataMask": {"NATIVE_FILTER-1": {"filterState": {"value": ["Sales"]}}},
"activeTabs": ["TAB-1", "TAB-2"],
"urlParams": None,
"nativeFilters": [ # type: ignore[typeddict-unknown-key]
{
"nativeFilterId": "NATIVE_FILTER-1",
"filterType": "filter_select",
"columnName": "department",
"filterValues": ["Sales"],
}
],
}
}
mock_report_schedule.get_native_filters_params.return_value = ( # type: ignore[attr-defined]
native_filter_rison,
[],
)
mock_permalink_cls.return_value.run.side_effect = ["key1", "key2"]
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
result: list[str] = class_instance.get_dashboard_urls()
import urllib.parse
base_url = app.config.get("WEBDRIVER_BASEURL", "http://0.0.0.0:8080/")
assert result == [
urllib.parse.urljoin(base_url, "superset/dashboard/p/key1/"),
urllib.parse.urljoin(base_url, "superset/dashboard/p/key2/"),
]
mock_report_schedule.get_native_filters_params.assert_called_once() # type: ignore[attr-defined]
assert mock_permalink_cls.call_count == 2
for call in mock_permalink_cls.call_args_list:
state = call.kwargs["state"]
assert state["urlParams"] == [["native_filters", native_filter_rison]]
assert mock_permalink_cls.call_args_list[0].kwargs["state"]["anchor"] == "TAB-1"
assert mock_permalink_cls.call_args_list[1].kwargs["state"]["anchor"] == "TAB-2"
@patch("superset.commands.report.execute.CreateDashboardPermalinkCommand")
@with_feature_flags(ALERT_REPORT_TABS=True)
def test_get_dashboard_urls_with_filters_and_tabs_preserves_existing_url_params(
mock_permalink_cls,
mocker: MockerFixture,
) -> None:
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = 123
mock_report_schedule.type = "report_type"
mock_report_schedule.report_format = "report_format"
mock_report_schedule.owners = [1, 2]
mock_report_schedule.recipients = []
native_filter_rison = "(NATIVE_FILTER-1:(filterType:filter_select))"
mock_report_schedule.extra = {
"dashboard": {
"anchor": json.dumps(["TAB-1", "TAB-2"]),
"dataMask": {"NATIVE_FILTER-1": {"filterState": {"value": ["Sales"]}}},
"activeTabs": ["TAB-1", "TAB-2"],
"urlParams": [("standalone", "true"), ("show_filters", "0")],
"nativeFilters": [ # type: ignore[typeddict-unknown-key]
{
"nativeFilterId": "NATIVE_FILTER-1",
"filterType": "filter_select",
"columnName": "department",
"filterValues": ["Sales"],
}
],
}
}
mock_report_schedule.get_native_filters_params.return_value = ( # type: ignore[attr-defined]
native_filter_rison,
[],
)
mock_permalink_cls.return_value.run.side_effect = ["key1", "key2"]
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
class_instance.get_dashboard_urls()
for call in mock_permalink_cls.call_args_list:
state = call.kwargs["state"]
assert state["urlParams"] == [
["standalone", "true"],
["show_filters", "0"],
["native_filters", native_filter_rison],
]
@patch("superset.commands.report.execute.CreateDashboardPermalinkCommand")
@with_feature_flags(ALERT_REPORT_TABS=True)
def test_get_dashboard_urls_with_filters_and_tabs_deduplicates_stale_native_filters(
mock_permalink_cls,
mocker: MockerFixture,
) -> None:
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = 123
mock_report_schedule.type = "report_type"
mock_report_schedule.report_format = "report_format"
mock_report_schedule.owners = [1, 2]
mock_report_schedule.recipients = []
native_filter_rison = "(NATIVE_FILTER-1:(new:value))"
mock_report_schedule.extra = {
"dashboard": {
"anchor": json.dumps(["TAB-1", "TAB-2"]),
"dataMask": {},
"activeTabs": ["TAB-1", "TAB-2"],
"urlParams": [
("standalone", "true"),
("native_filters", "(old:stale_value)"),
],
"nativeFilters": [], # type: ignore[typeddict-unknown-key]
}
}
mock_report_schedule.get_native_filters_params.return_value = ( # type: ignore[attr-defined]
native_filter_rison,
[],
)
mock_permalink_cls.return_value.run.side_effect = ["key1", "key2"]
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
class_instance.get_dashboard_urls()
for call in mock_permalink_cls.call_args_list:
state = call.kwargs["state"]
assert state["urlParams"] == [
["standalone", "true"],
["native_filters", native_filter_rison],
]
@patch("superset.commands.report.execute.CreateDashboardPermalinkCommand")
@with_feature_flags(ALERT_REPORT_TABS=True)
def test_get_dashboard_urls_with_filters_no_tabs(
mock_permalink_cls,
mocker: MockerFixture,
app,
) -> None:
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = 123
mock_report_schedule.type = "report_type"
mock_report_schedule.report_format = "report_format"
mock_report_schedule.owners = [1, 2]
mock_report_schedule.recipients = []
native_filter_rison = "(NATIVE_FILTER-1:(filterType:filter_select))"
mock_report_schedule.extra = {
"dashboard": {
"anchor": "",
"dataMask": {"NATIVE_FILTER-1": {"filterState": {"value": ["Sales"]}}},
"activeTabs": None,
"urlParams": None,
"nativeFilters": [ # type: ignore[typeddict-unknown-key]
{
"nativeFilterId": "NATIVE_FILTER-1",
"filterType": "filter_select",
"columnName": "department",
"filterValues": ["Sales"],
}
],
}
}
mock_report_schedule.get_native_filters_params.return_value = ( # type: ignore[attr-defined]
native_filter_rison,
[],
)
mock_permalink_cls.return_value.run.return_value = "key1"
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
result: list[str] = class_instance.get_dashboard_urls()
import urllib.parse
base_url = app.config.get("WEBDRIVER_BASEURL", "http://0.0.0.0:8080/")
assert result == [
urllib.parse.urljoin(base_url, "superset/dashboard/p/key1/"),
]
mock_report_schedule.get_native_filters_params.assert_called_once() # type: ignore[attr-defined]
assert mock_permalink_cls.call_count == 1
state = mock_permalink_cls.call_args_list[0].kwargs["state"]
assert state["urlParams"] == [["native_filters", native_filter_rison]]
@patch("superset.commands.report.execute.CreateDashboardPermalinkCommand")
@with_feature_flags(ALERT_REPORT_TABS=True)
def test_get_dashboard_urls_preserves_existing_url_params(
mock_permalink_cls,
mocker: MockerFixture,
app,
) -> None:
"""Existing urlParams (e.g. standalone) must survive native_filters merge."""
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = 123
mock_report_schedule.type = "report_type"
mock_report_schedule.report_format = "report_format"
mock_report_schedule.owners = [1, 2]
mock_report_schedule.recipients = []
native_filter_rison = "(NATIVE_FILTER-1:(filterType:filter_select))"
mock_report_schedule.extra = {
"dashboard": {
"anchor": "",
"dataMask": {},
"activeTabs": None,
"urlParams": [("standalone", "true"), ("show_filters", "0")],
"nativeFilters": [ # type: ignore[typeddict-unknown-key]
{
"nativeFilterId": "NATIVE_FILTER-1",
"filterType": "filter_select",
"columnName": "dept",
"filterValues": ["Sales"],
}
],
}
}
mock_report_schedule.get_native_filters_params.return_value = ( # type: ignore[attr-defined]
native_filter_rison,
[],
)
mock_permalink_cls.return_value.run.return_value = "key1"
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
class_instance.get_dashboard_urls()
state = mock_permalink_cls.call_args_list[0].kwargs["state"]
assert state["urlParams"] == [
["standalone", "true"],
["show_filters", "0"],
["native_filters", native_filter_rison],
]
@patch("superset.commands.report.execute.CreateDashboardPermalinkCommand")
@with_feature_flags(ALERT_REPORT_TABS=True)
def test_get_dashboard_urls_deduplicates_stale_native_filters(
mock_permalink_cls,
mocker: MockerFixture,
app,
) -> None:
"""A stale native_filters entry in urlParams is replaced, not duplicated."""
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = 123
mock_report_schedule.type = "report_type"
mock_report_schedule.report_format = "report_format"
mock_report_schedule.owners = [1, 2]
mock_report_schedule.recipients = []
native_filter_rison = "(NATIVE_FILTER-1:(new:value))"
mock_report_schedule.extra = {
"dashboard": {
"anchor": "",
"dataMask": {},
"activeTabs": None,
"urlParams": [
("standalone", "true"),
("native_filters", "(old:stale_value)"),
],
"nativeFilters": [], # type: ignore[typeddict-unknown-key]
}
}
mock_report_schedule.get_native_filters_params.return_value = ( # type: ignore[attr-defined]
native_filter_rison,
[],
)
mock_permalink_cls.return_value.run.return_value = "key1"
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
class_instance.get_dashboard_urls()
state = mock_permalink_cls.call_args_list[0].kwargs["state"]
assert state["urlParams"] == [
["standalone", "true"],
["native_filters", native_filter_rison],
]
@patch(
"superset.commands.dashboard.permalink.create.CreateDashboardPermalinkCommand.run"
)
def test_get_tab_urls(
mock_run,
mocker: MockerFixture,
app,
) -> None:
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.dashboard_id = 123
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
mock_run.side_effect = ["uri1", "uri2"]
tab_anchors = ["1", "2"]
result: list[str] = class_instance._get_tabs_urls(tab_anchors)
import urllib.parse
base_url = app.config.get("WEBDRIVER_BASEURL", "http://0.0.0.0:8080/")
assert result == [
urllib.parse.urljoin(base_url, "superset/dashboard/p/uri1/"),
urllib.parse.urljoin(base_url, "superset/dashboard/p/uri2/"),
]
@patch("superset.commands.report.execute.CreateDashboardPermalinkCommand")
@with_feature_flags(ALERT_REPORT_TABS=True)
def test_get_dashboard_urls_multitab_preserves_url_params(
mock_permalink_cls,
mocker: MockerFixture,
app,
) -> None:
"""Multi-tab fan-out must preserve dashboard_state.urlParams (e.g. standalone)
and replace any pre-existing native_filters entry with the report's value —
matching the single-tab branch's merge semantics."""
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = 123
mock_report_schedule.type = "report_type"
mock_report_schedule.report_format = "report_format"
mock_report_schedule.owners = [1, 2]
mock_report_schedule.recipients = []
native_filter_rison = "(NATIVE_FILTER-1:(filterType:filter_select))"
# Use list-of-lists (not tuples) — extra_json deserializes urlParams from
# JSON arrays. Includes a stale native_filters entry to exercise the
# dedup-then-append step in the merge.
mock_report_schedule.extra = {
"dashboard": {
"anchor": json.dumps(["TAB-1", "TAB-2"]),
"urlParams": [
["standalone", "true"],
["native_filters", "(STALE_FILTER:(filterType:filter_select))"],
["show_filters", "0"],
],
}
}
mock_report_schedule.get_native_filters_params.return_value = ( # type: ignore[attr-defined]
native_filter_rison,
[],
)
mock_permalink_cls.return_value.run.side_effect = ["key1", "key2"]
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
class_instance.get_dashboard_urls()
assert mock_permalink_cls.call_count == 2
for idx, expected_anchor in enumerate(["TAB-1", "TAB-2"]):
state = mock_permalink_cls.call_args_list[idx].kwargs["state"]
# Stale native_filters is replaced (not duplicated); other params
# survive in their original order; report's native_filters appended.
assert state["urlParams"] == [
["standalone", "true"],
["show_filters", "0"],
["native_filters", native_filter_rison],
]
# Each per-tab permalink targets exactly that tab.
assert state["anchor"] == expected_anchor
@patch(
"superset.commands.dashboard.permalink.create.CreateDashboardPermalinkCommand.run"
)
def test_get_tab_url(
mock_run,
mocker: MockerFixture,
app,
) -> None:
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.dashboard_id = 123
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
mock_run.return_value = "uri"
dashboard_state = DashboardPermalinkState(
anchor="1",
dataMask=None,
activeTabs=None,
urlParams=None,
)
result: str = class_instance._get_tab_url(dashboard_state)
import urllib.parse
base_url = app.config.get("WEBDRIVER_BASEURL", "http://0.0.0.0:8080/")
assert result == urllib.parse.urljoin(base_url, "superset/dashboard/p/uri/")
@patch(
"superset.commands.dashboard.permalink.create.CreateDashboardPermalinkCommand.run"
)
@with_feature_flags(ALERT_REPORT_TABS=False)
def test_get_dashboard_urls_native_filters_without_tabs(
mock_run,
mocker: MockerFixture,
app,
) -> None:
"""Native filters should be applied even when ALERT_REPORT_TABS is disabled."""
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = 123
mock_report_schedule.force_screenshot = False
extra = {
"dashboard": {
"nativeFilters": [
{
"nativeFilterId": "NATIVE_FILTER-abc",
"filterType": "filter_select",
"columnName": "col1",
"filterValues": ["val1"],
}
]
}
}
mock_report_schedule.extra = extra # type: ignore[assignment]
mock_report_schedule.get_native_filters_params.return_value = ( # type: ignore
"(NATIVE_FILTER-abc:!(val1))",
[],
)
mock_dashboard = mocker.MagicMock()
mock_dashboard.uuid = UUID("12345678-1234-1234-1234-123456789abc")
mock_report_schedule.dashboard = mock_dashboard
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
mock_run.return_value = "permalink_key"
result: list[str] = class_instance.get_dashboard_urls()
assert len(result) == 1
assert "permalink_key" in result[0]
@patch("superset.commands.report.execute.CreateDashboardPermalinkCommand")
@with_feature_flags(ALERT_REPORT_TABS=False)
def test_get_dashboard_urls_flag_off_preserves_url_params(
mock_permalink_cls,
mocker: MockerFixture,
app,
) -> None:
"""The post-``if``-block fall-through in ``get_dashboard_urls`` must
honor any urlParams set in ``extra.dashboard`` (e.g. via API) — same
merge semantics as the protected branch.
Reachability: only when ``dashboard_state`` is falsy OR
``ALERT_REPORT_TABS=False``. The flag-on / no-anchor case lands in
the single-tab merge at L290-306, not here.
"""
mock_report_schedule: ReportSchedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.chart_id = None
mock_report_schedule.dashboard_id = 123
native_filter_rison = "(NATIVE_FILTER-abc:!(val1))"
mock_report_schedule.extra = {
"dashboard": {
"urlParams": [
["standalone", "true"],
["native_filters", "(STALE_FILTER:!(stale))"],
["show_filters", "0"],
],
}
}
mock_report_schedule.get_native_filters_params.return_value = ( # type: ignore[attr-defined]
native_filter_rison,
[],
)
class_instance: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
class_instance._report_schedule = mock_report_schedule
mock_permalink_cls.return_value.run.return_value = "permalink_key"
class_instance.get_dashboard_urls()
state = mock_permalink_cls.call_args_list[0].kwargs["state"]
# Stale native_filters replaced; existing params survive in order;
# report's native_filters appended.
assert state["urlParams"] == [
["standalone", "true"],
["show_filters", "0"],
["native_filters", native_filter_rison],
]
def create_report_schedule(
mocker: MockerFixture,
custom_width: int | None = None,
custom_height: int | None = None,
) -> ReportSchedule:
"""Helper function to create a ReportSchedule instance with specified dimensions."""
schedule = ReportSchedule()
schedule.type = ReportScheduleType.REPORT
schedule.name = "Test Report"
schedule.description = "Test Description"
schedule.chart = mocker.MagicMock()
schedule.chart.id = 1
schedule.dashboard = None
schedule.database = None
schedule.custom_width = custom_width
schedule.custom_height = custom_height
return schedule
@pytest.mark.parametrize(
"test_id,custom_width,max_width,window_width,expected_width",
[
# Test when custom width exceeds max width
("exceeds_max", 2000, 1600, 800, 1600),
# Test when custom width is less than max width
("under_max", 1200, 1600, 800, 1200),
# Test when custom width is None (should use window width)
("no_custom", None, 1600, 800, 800),
# Test when custom width equals max width
("equals_max", 1600, 1600, 800, 1600),
],
)
def test_screenshot_width_calculation(
app: SupersetApp,
mocker: MockerFixture,
test_id: str,
custom_width: int | None,
max_width: int,
window_width: int,
expected_width: int,
) -> None:
"""
Test that screenshot width is correctly calculated.
The width should be:
- Limited by max_width when custom_width exceeds it
- Equal to custom_width when it's less than max_width
- Equal to window_width when custom_width is None
"""
from superset.commands.report.execute import BaseReportState
# Mock configuration
app.config.update(
{
"ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH": max_width,
"WEBDRIVER_WINDOW": {
"slice": (window_width, 600),
"dashboard": (window_width, 600),
},
"ALERT_REPORTS_EXECUTORS": {},
}
)
# Create report schedule with specified custom width
report_schedule = create_report_schedule(mocker, custom_width=custom_width)
# Initialize BaseReportState
report_state = BaseReportState(
report_schedule=report_schedule,
scheduled_dttm=datetime.now(),
execution_id=UUID("084e7ee6-5557-4ecd-9632-b7f39c9ec524"),
)
# Mock security manager and screenshot
with (
patch(
"superset.commands.report.execute.security_manager"
) as mock_security_manager,
patch(
"superset.utils.screenshots.ChartScreenshot.get_screenshot"
) as mock_get_screenshot,
):
# Mock user
mock_user = mocker.MagicMock()
mock_security_manager.find_user.return_value = mock_user
mock_get_screenshot.return_value = b"screenshot bytes"
# Mock get_executor to avoid database lookups
with patch(
"superset.commands.report.execute.get_executor"
) as mock_get_executor:
mock_get_executor.return_value = ("executor", "username")
# Capture the ChartScreenshot instantiation
with patch(
"superset.commands.report.execute.ChartScreenshot",
wraps=ChartScreenshot,
) as mock_chart_screenshot:
# Call the method that triggers screenshot creation
report_state._get_screenshots()
# Verify ChartScreenshot was created with correct window_size
mock_chart_screenshot.assert_called_once()
_, kwargs = mock_chart_screenshot.call_args
assert kwargs["window_size"][0] == expected_width, (
f"Test {test_id}: Expected width {expected_width}, "
f"but got {kwargs['window_size'][0]}"
)
def _executor_report_state(mocker: MockerFixture) -> BaseReportState:
report_schedule = create_report_schedule(mocker)
# _get_csv_data/_get_embedded_data build a chart-data URL from chart_id
# before resolving the executor; give it a concrete value so URL building
# succeeds and the executor resolution is actually reached.
report_schedule.chart_id = 1
report_schedule.force_screenshot = False
return BaseReportState(
report_schedule=report_schedule,
scheduled_dttm=datetime.now(),
execution_id=UUID("084e7ee6-5557-4ecd-9632-b7f39c9ec524"),
)
@pytest.mark.parametrize(
"method_name",
["_get_screenshots", "_get_csv_data", "_get_embedded_data"],
)
def test_get_content_raises_when_executor_user_missing(
app: SupersetApp, mocker: MockerFixture, method_name: str
) -> None:
"""
When the configured executor user cannot be resolved
(``security_manager.find_user`` returns ``None``), each content path raises a
dedicated ``ReportScheduleExecutorNotFoundError`` naming the username, rather
than passing ``None`` downstream and failing with an opaque NoneType error.
"""
app.config.update(
{
"ALERT_REPORTS_MAX_CUSTOM_SCREENSHOT_WIDTH": 1600,
"WEBDRIVER_WINDOW": {"slice": (800, 600), "dashboard": (800, 600)},
"ALERT_REPORTS_EXECUTORS": {},
}
)
report_state = _executor_report_state(mocker)
with (
patch("superset.commands.report.execute.security_manager") as mock_sm,
patch("superset.commands.report.execute.get_executor") as mock_get_executor,
patch("superset.commands.report.execute.machine_auth_provider_factory"),
):
mock_get_executor.return_value = ("executor", "ghost_user")
mock_sm.find_user = mocker.MagicMock(return_value=None)
with pytest.raises(ReportScheduleExecutorNotFoundError, match="ghost_user"):
getattr(report_state, method_name)()
def test_resolve_executor_user_returns_user_and_username(
app: SupersetApp, mocker: MockerFixture
) -> None:
"""
Happy path: when the executor user exists, the helper returns the
``(user, username)`` tuple unchanged — locking the no-behavior-change exit
criterion for the four call sites.
"""
from superset.commands.report.execute import resolve_executor_user
app.config.update({"ALERT_REPORTS_EXECUTORS": {}})
report_schedule = create_report_schedule(mocker)
mock_user = mocker.MagicMock()
with (
patch("superset.commands.report.execute.security_manager") as mock_sm,
patch("superset.commands.report.execute.get_executor") as mock_get_executor,
):
mock_get_executor.return_value = ("executor", "real_user")
mock_sm.find_user = mocker.MagicMock(return_value=mock_user)
user, username = resolve_executor_user(report_schedule)
assert user is mock_user
assert username == "real_user"
def test_update_recipient_to_slack_v2(mocker: MockerFixture):
"""
Test converting a Slack recipient to Slack v2 format.
"""
mocker.patch(
"superset.commands.report.execute.get_channels_with_search",
return_value=[
{
"id": "abc124f",
"name": "channel-1",
"is_member": True,
"is_private": False,
},
{
"id": "blah_!channel_2",
"name": "Channel_2",
"is_member": True,
"is_private": False,
},
],
)
mock_report_schedule = ReportSchedule(
recipients=[
ReportRecipients(
type=ReportRecipientType.SLACK,
recipient_config_json=json.dumps({"target": "Channel-1, Channel_2"}),
),
],
)
mock_cmmd: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
mock_cmmd.update_report_schedule_slack_v2()
assert (
mock_cmmd._report_schedule.recipients[0].recipient_config_json
== '{"target": "abc124f,blah_!channel_2"}'
)
assert mock_cmmd._report_schedule.recipients[0].type == ReportRecipientType.SLACKV2
def test_update_recipient_to_slack_v2_missing_channels(mocker: MockerFixture):
"""
Test converting a Slack recipient to Slack v2 format raises an error
in case it can't find all channels.
"""
mocker.patch(
"superset.commands.report.execute.get_channels_with_search",
return_value=[
{
"id": "blah_!channel_2",
"name": "Channel 2",
"is_member": True,
"is_private": False,
},
],
)
mock_report_schedule = ReportSchedule(
name="Test Report",
recipients=[
ReportRecipients(
type=ReportRecipientType.SLACK,
recipient_config_json=json.dumps({"target": "Channel 1, Channel 2"}),
),
],
)
mock_cmmd: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
with pytest.raises(UpdateFailedError):
mock_cmmd.update_report_schedule_slack_v2()
def test_update_recipient_to_slack_v2_reverts_all_on_partial_failure(
mocker: MockerFixture,
) -> None:
"""
When the second of two Slack recipients fails channel resolution, BOTH
recipients are fully reverted — type AND exact original
``recipient_config_json`` string — not just the loop variable's type. This
prevents the intervening ``create_log`` commit from flushing a half-migrated,
inconsistent state.
"""
def channels_side_effect(search_string, types, exact_match):
if search_string == "Channel-1":
return [
{
"id": "id_channel_1",
"name": "Channel-1",
"is_member": True,
"is_private": False,
}
]
# Second recipient: no channel found → length mismatch → UpdateFailedError
return []
mocker.patch(
"superset.commands.report.execute.get_channels_with_search",
side_effect=channels_side_effect,
)
original_config_1 = json.dumps({"target": "Channel-1"})
original_config_2 = json.dumps({"target": "Channel-2"})
mock_report_schedule = ReportSchedule(
name="Test Report",
recipients=[
ReportRecipients(
type=ReportRecipientType.SLACK,
recipient_config_json=original_config_1,
),
ReportRecipients(
type=ReportRecipientType.SLACK,
recipient_config_json=original_config_2,
),
],
)
mock_cmmd = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
with pytest.raises(UpdateFailedError):
mock_cmmd.update_report_schedule_slack_v2()
first, second = mock_report_schedule.recipients
# The first recipient was mutated to v2 before the second failed; it must be
# reverted to its exact original type AND config string.
assert first.type == ReportRecipientType.SLACK
assert first.recipient_config_json == original_config_1
assert second.type == ReportRecipientType.SLACK
assert second.recipient_config_json == original_config_2
def test_update_recipient_to_slack_v2_pre_iteration_failure(
mocker: MockerFixture,
) -> None:
"""
A failure raised while accessing/iterating the recipients (before the loop
variable is bound) surfaces as ``UpdateFailedError``, not a ``NameError``
that would mask the real error.
"""
class _ExplodingRecipients:
def __iter__(self):
raise RuntimeError("recipients exploded")
mock_report_schedule = mocker.MagicMock()
mock_report_schedule.recipients = _ExplodingRecipients()
mock_cmmd = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
with pytest.raises(UpdateFailedError):
mock_cmmd.update_report_schedule_slack_v2()
def test_update_recipient_to_slack_v2_no_slack_recipients_is_noop(
mocker: MockerFixture,
) -> None:
"""
With no SLACK recipients there is nothing to migrate: the method returns
without raising and leaves the non-Slack recipients untouched.
"""
mock_search = mocker.patch(
"superset.commands.report.execute.get_channels_with_search",
)
mock_report_schedule = ReportSchedule(
recipients=[
ReportRecipients(
type=ReportRecipientType.EMAIL,
recipient_config_json=json.dumps({"target": "user@example.com"}),
),
],
)
mock_cmmd: BaseReportState = BaseReportState(
mock_report_schedule, "January 1, 2021", "execution_id_example"
)
mock_cmmd.update_report_schedule_slack_v2()
assert mock_cmmd._report_schedule.recipients[0].type == ReportRecipientType.EMAIL
assert (
mock_cmmd._report_schedule.recipients[0].recipient_config_json
== '{"target": "user@example.com"}'
)
mock_search.assert_not_called()
# ---------------------------------------------------------------------------
# Tier 1: _update_query_context + create_log
# ---------------------------------------------------------------------------
def test_update_query_context_wraps_screenshot_failure(mocker: MockerFixture) -> None:
"""_update_query_context wraps ScreenshotFailedError as CsvFailedError."""
schedule = mocker.Mock(spec=ReportSchedule)
state = BaseReportState(schedule, datetime.utcnow(), uuid4())
state._report_schedule = schedule
mocker.patch.object(
state,
"_get_screenshots",
side_effect=ReportScheduleScreenshotFailedError("boom"),
)
with pytest.raises(ReportScheduleCsvFailedError, match="query context"):
state._update_query_context()
def test_update_query_context_wraps_screenshot_timeout(mocker: MockerFixture) -> None:
"""_update_query_context wraps ScreenshotTimeout as CsvFailedError."""
schedule = mocker.Mock(spec=ReportSchedule)
state = BaseReportState(schedule, datetime.utcnow(), uuid4())
state._report_schedule = schedule
mocker.patch.object(
state,
"_get_screenshots",
side_effect=ReportScheduleScreenshotTimeout(),
)
with pytest.raises(ReportScheduleCsvFailedError, match="query context"):
state._update_query_context()
def test_create_log_stale_data_raises_unexpected_error(mocker: MockerFixture) -> None:
"""StaleDataError during create_log should rollback and raise UnexpectedError."""
from sqlalchemy.orm.exc import StaleDataError
schedule = mocker.Mock(spec=ReportSchedule)
schedule.last_value = None
schedule.last_value_row_json = None
schedule.last_state = ReportState.WORKING
state = BaseReportState(schedule, datetime.utcnow(), uuid4())
state._report_schedule = schedule
mock_db = mocker.patch("superset.commands.report.execute.db")
mock_db.session.commit.side_effect = StaleDataError("stale")
# Prevent SQLAlchemy from inspecting the mock schedule during log creation
mocker.patch(
"superset.commands.report.execute.ReportExecutionLog",
return_value=mocker.Mock(),
)
with pytest.raises(ReportScheduleUnexpectedError):
state.create_log()
mock_db.session.rollback.assert_called_once()
# ---------------------------------------------------------------------------
# Tier 2: _get_notification_content branches
# ---------------------------------------------------------------------------
def _make_notification_state(
mocker: MockerFixture,
*,
report_format: ReportDataFormat = ReportDataFormat.PNG,
schedule_type: ReportScheduleType = ReportScheduleType.REPORT,
has_chart: bool = True,
email_subject: str | None = None,
chart_name: str = "My Chart",
dashboard_title: str = "My Dashboard",
) -> BaseReportState:
"""Build a BaseReportState with a mock schedule for notification tests."""
schedule = mocker.Mock(spec=ReportSchedule)
schedule.type = schedule_type
schedule.report_format = report_format
schedule.name = "Test Schedule"
schedule.description = "desc"
schedule.email_subject = email_subject
schedule.force_screenshot = False
schedule.recipients = []
schedule.owners = []
if has_chart:
schedule.chart = mocker.Mock()
schedule.chart.slice_name = chart_name
schedule.dashboard = None
else:
schedule.chart = None
schedule.dashboard = mocker.Mock()
schedule.dashboard.dashboard_title = dashboard_title
schedule.dashboard.uuid = "dash-uuid"
schedule.dashboard.id = 1
schedule.extra = {}
state = BaseReportState(schedule, datetime.utcnow(), uuid4())
state._report_schedule = schedule
# Stub helpers that _get_notification_content calls
mocker.patch.object(state, "_get_log_data", return_value={})
mocker.patch.object(state, "_get_url", return_value="http://example.com")
return state
@patch("superset.commands.report.execute.feature_flag_manager")
def test_get_notification_content_png_screenshot(
mock_ff, mocker: MockerFixture
) -> None:
mock_ff.is_feature_enabled.return_value = False
state = _make_notification_state(mocker, report_format=ReportDataFormat.PNG)
mocker.patch.object(state, "_get_screenshots", return_value=[b"img1", b"img2"])
content = state._get_notification_content()
assert content.screenshots == [b"img1", b"img2"]
assert content.text is None
@patch("superset.commands.report.execute.feature_flag_manager")
def test_get_notification_content_png_empty_returns_error(
mock_ff, mocker: MockerFixture
) -> None:
mock_ff.is_feature_enabled.return_value = False
state = _make_notification_state(mocker, report_format=ReportDataFormat.PNG)
mocker.patch.object(state, "_get_screenshots", return_value=[])
content = state._get_notification_content()
assert content.text == "Unexpected missing screenshot"
@patch("superset.commands.report.execute.feature_flag_manager")
def test_get_notification_content_csv_format(mock_ff, mocker: MockerFixture) -> None:
mock_ff.is_feature_enabled.return_value = False
state = _make_notification_state(
mocker, report_format=ReportDataFormat.CSV, has_chart=True
)
mocker.patch.object(state, "_get_csv_data", return_value=b"col1,col2\n1,2")
content = state._get_notification_content()
assert content.csv == b"col1,col2\n1,2"
@patch("superset.commands.report.execute.feature_flag_manager")
def test_get_notification_content_text_format(mock_ff, mocker: MockerFixture) -> None:
import pandas as pd
mock_ff.is_feature_enabled.return_value = False
state = _make_notification_state(
mocker, report_format=ReportDataFormat.TEXT, has_chart=True
)
df = pd.DataFrame({"a": [1]})
mocker.patch.object(state, "_get_embedded_data", return_value=df)
content = state._get_notification_content()
assert content.embedded_data is not None
assert list(content.embedded_data.columns) == ["a"]
@pytest.mark.parametrize(
"email_subject,has_chart,expected_name",
[
("Custom Subject", True, "Custom Subject"),
(None, True, "Test Schedule: My Chart"),
(None, False, "Test Schedule: My Dashboard"),
],
ids=["email_subject", "chart_name", "dashboard_name"],
)
@patch("superset.commands.report.execute.feature_flag_manager")
def test_get_notification_content_name(
mock_ff,
mocker: MockerFixture,
email_subject: str | None,
has_chart: bool,
expected_name: str,
) -> None:
"""Notification name comes from email_subject, chart, or dashboard."""
mock_ff.is_feature_enabled.return_value = False
state = _make_notification_state(
mocker,
report_format=ReportDataFormat.PNG,
email_subject=email_subject,
has_chart=has_chart,
)
mocker.patch.object(state, "_get_screenshots", return_value=[b"img"])
content = state._get_notification_content()
assert content.name == expected_name
# ---------------------------------------------------------------------------
# Tier 3: State machine top-level branches
# ---------------------------------------------------------------------------
def _make_state_instance(
mocker: MockerFixture,
cls: type,
*,
schedule_type: ReportScheduleType = ReportScheduleType.ALERT,
last_state: ReportState = ReportState.NOOP,
grace_period: int = 3600,
working_timeout: int = 3600,
) -> BaseReportState:
"""Create a state-machine state instance with a mocked schedule."""
schedule = mocker.Mock(spec=ReportSchedule)
schedule.type = schedule_type
schedule.last_state = last_state
schedule.grace_period = grace_period
schedule.working_timeout = working_timeout
schedule.last_eval_dttm = datetime.utcnow()
schedule.name = "Test"
schedule.owners = []
schedule.recipients = []
schedule.force_screenshot = False
schedule.extra = {}
instance = cls(schedule, datetime.utcnow(), uuid4())
instance._report_schedule = schedule
return instance
def test_working_state_timeout_raises_timeout_error(mocker: MockerFixture) -> None:
"""Working state past timeout should raise WorkingTimeoutError and log ERROR."""
state = _make_state_instance(mocker, ReportWorkingState)
mocker.patch.object(state, "is_on_working_timeout", return_value=True)
mock_log = mocker.Mock()
mock_log.end_dttm = datetime.utcnow() - timedelta(hours=2)
mocker.patch(
"superset.commands.report.execute.ReportScheduleDAO.find_last_entered_working_log",
return_value=mock_log,
)
mocker.patch.object(state, "update_report_schedule_and_log")
with pytest.raises(ReportScheduleWorkingTimeoutError):
state.next()
state.update_report_schedule_and_log.assert_called_once_with( # type: ignore[attr-defined]
ReportState.ERROR,
error_message=str(ReportScheduleWorkingTimeoutError()),
)
def test_working_state_still_working_raises_previous_working(
mocker: MockerFixture,
) -> None:
"""Working state not yet timed out should raise PreviousWorkingError."""
state = _make_state_instance(mocker, ReportWorkingState)
mocker.patch.object(state, "is_on_working_timeout", return_value=False)
mocker.patch.object(state, "update_report_schedule_and_log")
with pytest.raises(ReportSchedulePreviousWorkingError):
state.next()
state.update_report_schedule_and_log.assert_called_once_with( # type: ignore[attr-defined]
ReportState.WORKING,
error_message=str(ReportSchedulePreviousWorkingError()),
)
def test_success_state_grace_period_returns_without_sending(
mocker: MockerFixture,
) -> None:
"""Alert in grace period should set GRACE state and not send."""
state = _make_state_instance(
mocker,
ReportSuccessState,
schedule_type=ReportScheduleType.ALERT,
)
mocker.patch.object(state, "is_in_grace_period", return_value=True)
mocker.patch.object(state, "update_report_schedule_and_log")
mock_send = mocker.patch.object(state, "send")
state.next()
mock_send.assert_not_called()
state.update_report_schedule_and_log.assert_called_once_with( # type: ignore[attr-defined]
ReportState.GRACE,
error_message=str(ReportScheduleAlertGracePeriodError()),
)
def test_not_triggered_error_state_send_failure_logs_error_and_reraises(
mocker: MockerFixture,
) -> None:
"""When send() fails in NOOP/ERROR state, error should be logged and re-raised."""
state = _make_state_instance(
mocker,
ReportNotTriggeredErrorState,
schedule_type=ReportScheduleType.REPORT,
)
send_error = RuntimeError("send failed")
mocker.patch.object(state, "send", side_effect=send_error)
mocker.patch.object(state, "update_report_schedule_and_log")
mocker.patch.object(state, "is_in_error_grace_period", return_value=True)
with pytest.raises(RuntimeError, match="send failed"):
state.next()
# Should have logged WORKING, then ERROR
calls = state.update_report_schedule_and_log.call_args_list # type: ignore[attr-defined]
assert calls[0].args[0] == ReportState.WORKING
assert calls[1].args[0] == ReportState.ERROR
error_msg = calls[1].kwargs.get("error_message") or (
calls[1].args[1] if len(calls[1].args) > 1 else ""
)
assert "send failed" in error_msg
# ---------------------------------------------------------------------------
# Phase 1 remaining gaps
# ---------------------------------------------------------------------------
def test_get_dashboard_urls_no_state_fallback(
mocker: MockerFixture, app: SupersetApp
) -> None:
"""No dashboard state in extra -> standard dashboard URL, not permalink."""
mock_report_schedule = mocker.Mock(spec=ReportSchedule)
mock_report_schedule.chart = False
mock_report_schedule.force_screenshot = False
mock_report_schedule.extra = {} # no dashboard state
mock_report_schedule.dashboard = mocker.Mock()
mock_report_schedule.dashboard.uuid = "dash-uuid-123"
mock_report_schedule.dashboard.id = 42
mock_report_schedule.recipients = []
mock_report_schedule.get_native_filters_params.return_value = ("", [])
state = BaseReportState(mock_report_schedule, "Jan 1", "exec_id")
state._report_schedule = mock_report_schedule
result = state.get_dashboard_urls()
assert len(result) == 1
assert "superset/dashboard/" in result[0]
assert "dashboard/p/" not in result[0] # not a permalink
def test_success_state_alert_command_error_sends_error_and_reraises(
mocker: MockerFixture,
) -> None:
"""AlertCommand exception -> send_error + ERROR state with marker."""
state = _make_state_instance(
mocker, ReportSuccessState, schedule_type=ReportScheduleType.ALERT
)
mocker.patch.object(state, "is_in_grace_period", return_value=False)
mocker.patch.object(state, "update_report_schedule_and_log")
mocker.patch.object(state, "send_error")
mocker.patch(
"superset.commands.report.execute.AlertCommand"
).return_value.run.side_effect = RuntimeError("alert boom")
with pytest.raises(RuntimeError, match="alert boom"):
state.next()
state.send_error.assert_called_once() # type: ignore[attr-defined]
calls = state.update_report_schedule_and_log.call_args_list # type: ignore[attr-defined]
# First call: WORKING, second call: ERROR with marker
assert calls[0].args[0] == ReportState.WORKING
assert calls[1].args[0] == ReportState.ERROR
assert (
calls[1].kwargs.get("error_message")
== REPORT_SCHEDULE_ERROR_NOTIFICATION_MARKER
)
def test_success_state_send_error_logs_and_reraises(
mocker: MockerFixture,
) -> None:
"""send() exception for REPORT type -> ERROR state + re-raise."""
state = _make_state_instance(
mocker, ReportSuccessState, schedule_type=ReportScheduleType.REPORT
)
mocker.patch.object(state, "send", side_effect=RuntimeError("send boom"))
mocker.patch.object(state, "update_report_schedule_and_log")
with pytest.raises(RuntimeError, match="send boom"):
state.next()
calls = state.update_report_schedule_and_log.call_args_list # type: ignore[attr-defined]
assert calls[-1].args[0] == ReportState.ERROR
@patch("superset.commands.report.execute.feature_flag_manager")
def test_get_notification_content_pdf_format(mock_ff, mocker: MockerFixture) -> None:
"""PDF report format branch produces pdf content."""
mock_ff.is_feature_enabled.return_value = False
state = _make_notification_state(mocker, report_format=ReportDataFormat.PDF)
mocker.patch.object(state, "_get_pdf", return_value=b"%PDF-fake")
content = state._get_notification_content()
assert content.pdf == b"%PDF-fake"
assert content.text is None
# ---------------------------------------------------------------------------
# Phase 1 gap closure: state machine, feature flag, create_log, success path
# ---------------------------------------------------------------------------
def test_state_machine_unknown_state_raises_not_found(
mocker: MockerFixture,
) -> None:
"""State machine raises StateNotFoundError when last_state matches no class."""
schedule = mocker.Mock(spec=ReportSchedule)
# Use a string that isn't in any state class's current_states
schedule.last_state = "NONEXISTENT_STATE"
sm = ReportScheduleStateMachine(uuid4(), schedule, datetime.utcnow())
with pytest.raises(ReportScheduleStateNotFoundError):
sm.run()
@patch("superset.commands.report.execute.feature_flag_manager")
def test_get_notification_content_alert_no_flag_skips_attachment(
mock_ff, mocker: MockerFixture
) -> None:
"""Alert with ALERTS_ATTACH_REPORTS=False skips screenshot/pdf/csv attachment."""
mock_ff.is_feature_enabled.return_value = False
state = _make_notification_state(
mocker,
report_format=ReportDataFormat.PNG,
schedule_type=ReportScheduleType.ALERT,
has_chart=True,
)
mock_screenshots = mocker.patch.object(state, "_get_screenshots")
content = state._get_notification_content()
# _get_screenshots should NOT be called — the attachment block is skipped
mock_screenshots.assert_not_called()
assert content.screenshots == []
assert content.text is None
def test_create_log_success_commits(mocker: MockerFixture) -> None:
"""Successful create_log creates a log entry and commits."""
schedule = mocker.Mock(spec=ReportSchedule)
schedule.last_value = "42"
schedule.last_value_row_json = '{"col": 42}'
schedule.last_state = ReportState.SUCCESS
state = BaseReportState(schedule, datetime.utcnow(), uuid4())
state._report_schedule = schedule
mock_db = mocker.patch("superset.commands.report.execute.db")
mock_log_cls = mocker.patch(
"superset.commands.report.execute.ReportExecutionLog",
return_value=mocker.Mock(),
)
state.create_log(error_message=None)
mock_log_cls.assert_called_once()
mock_db.session.add.assert_called_once()
mock_db.session.commit.assert_called_once()
mock_db.session.rollback.assert_not_called()
def test_success_state_report_sends_and_logs_success(
mocker: MockerFixture,
) -> None:
"""REPORT type success path: send() + update state to SUCCESS."""
state = _make_state_instance(
mocker,
ReportSuccessState,
schedule_type=ReportScheduleType.REPORT,
)
mock_send = mocker.patch.object(state, "send")
mock_update = mocker.patch.object(state, "update_report_schedule_and_log")
state.next()
mock_send.assert_called_once()
# WORKING is set before send() (concurrency guard against duplicate sends),
# then SUCCESS after.
assert mock_update.call_args_list == [
mocker.call(ReportState.WORKING),
mocker.call(ReportState.SUCCESS, error_message=None),
]
def test_success_state_error_logged_when_send_error_raises(
mocker: MockerFixture,
) -> None:
"""If send_error() itself raises, the schedule must still transition to
ERROR (not stay stuck in WORKING)."""
state = _make_state_instance(
mocker, ReportSuccessState, schedule_type=ReportScheduleType.ALERT
)
mocker.patch.object(state, "is_in_grace_period", return_value=False)
mock_update = mocker.patch.object(state, "update_report_schedule_and_log")
mocker.patch.object(
state, "send_error", side_effect=RuntimeError("notification boom")
)
mocker.patch(
"superset.commands.report.execute.AlertCommand"
).return_value.run.side_effect = RuntimeError("alert boom")
# The original alert error propagates...
with pytest.raises(RuntimeError, match="alert boom"):
state.next()
# ...but ERROR was still logged despite send_error() failing.
states = [call.args[0] for call in mock_update.call_args_list]
assert ReportState.ERROR in states
def test_get_url_for_csv_uses_post_processed_type(
app: SupersetApp,
mocker: MockerFixture,
) -> None:
"""Regression for #25538: when an alert/report generates a CSV for a
chart, the URL must request type=POST_PROCESSED so the chart's saved
filters (including time-range filters) are applied. The original report
described a chart with a "last 30 days" filter that returned only 14
rows in the UI, but the alert CSV came back with 219 rows (the entire
unfiltered table).
POST_PROCESSED is the marker that propagates the chart's query_context
-- including its time filter -- through to the CSV renderer. A
regression that switched to FULL or RAW would replicate the original
bug.
"""
from datetime import datetime
from uuid import UUID
from superset.commands.report.execute import BaseReportState
from superset.common.chart_data import ChartDataResultFormat
app.config.update({"ALERT_REPORTS_EXECUTORS": {}})
report_schedule = create_report_schedule(mocker)
report_schedule.force_screenshot = False
report_schedule.chart_id = report_schedule.chart.id
state = BaseReportState(
report_schedule=report_schedule,
scheduled_dttm=datetime.now(),
execution_id=UUID("084e7ee6-5557-4ecd-9632-b7f39c9ec524"),
)
url = state._get_url(result_format=ChartDataResultFormat.CSV)
assert "format=csv" in url.lower(), f"expected csv format in URL: {url}"
assert "type=post_processed" in url.lower(), (
f"CSV report URL must use type=post_processed so chart filters "
f"(incl. time filters) are applied; got: {url}; see issue #25538"
)