mirror of
https://github.com/apache/superset.git
synced 2026-04-11 20:37:16 +00:00
1320 lines
47 KiB
Python
1320 lines
47 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,
|
|
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
|
|
|
|
|
|
@patch(
|
|
"superset.commands.dashboard.permalink.create.CreateDashboardPermalinkCommand.run"
|
|
)
|
|
@with_feature_flags(ALERT_REPORT_TABS=True)
|
|
def test_get_dashboard_urls_with_exporting_dashboard_only(
|
|
mock_run,
|
|
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 = []
|
|
mock_report_schedule.extra = {
|
|
"dashboard": {
|
|
"anchor": "",
|
|
"dataMask": None,
|
|
"activeTabs": None,
|
|
"urlParams": None,
|
|
}
|
|
}
|
|
mock_report_schedule.get_native_filters_params.return_value = ( # type: ignore
|
|
"()",
|
|
[],
|
|
)
|
|
mock_run.return_value = "url1"
|
|
|
|
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/")
|
|
expected_url = urllib.parse.urljoin(base_url, "superset/dashboard/p/url1/")
|
|
assert expected_url == 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_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.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/")
|
|
|
|
|
|
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 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()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 = []
|
|
|
|
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")
|
|
mocker.patch.object(state, "update_report_schedule_and_log")
|
|
|
|
state.next()
|
|
|
|
mock_send.assert_called_once()
|
|
state.update_report_schedule_and_log.assert_called_once_with( # type: ignore[attr-defined]
|
|
ReportState.SUCCESS,
|
|
error_message=None,
|
|
)
|