This is an automated email from the ASF dual-hosted git repository.
arivero pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 5edaed2e5b fix(alerts): wrong alert trigger with custom query (#35871)
5edaed2e5b is described below
commit 5edaed2e5b6e0467d0a37f77fa73a77e4ee9c167
Author: Gabriel Torres Ruiz <[email protected]>
AuthorDate: Wed Jan 7 13:06:18 2026 -0400
fix(alerts): wrong alert trigger with custom query (#35871)
---
superset/commands/report/alert.py | 66 +++-
superset/commands/report/execute.py | 18 +-
tests/integration_tests/reports/alert_tests.py | 8 +-
tests/unit_tests/commands/report/alert_test.py | 496 +++++++++++++++++++++++++
4 files changed, 570 insertions(+), 18 deletions(-)
diff --git a/superset/commands/report/alert.py
b/superset/commands/report/alert.py
index 36cc540a85..0d67a231e7 100644
--- a/superset/commands/report/alert.py
+++ b/superset/commands/report/alert.py
@@ -59,14 +59,17 @@ class AlertCommand(BaseCommand):
self._report_schedule = report_schedule
self._execution_id = execution_id
self._result: float | None = None
+ self._info_message: str | None = None
- def run(self) -> bool:
+ def run(self) -> tuple[bool, str | None]:
"""
Executes an alert SQL query and validates it.
Will set the report_schedule.last_value or last_value_row_json
with the query result
- :return: bool, if the alert triggered or not
+ :return: tuple[bool, str | None] - (triggered, message)
+ - triggered: True if alert condition was met
+ - message: Optional info message explaining why alert didn't
trigger
:raises AlertQueryError: SQL query is not valid
:raises AlertQueryInvalidTypeError: The output from the SQL query
is not an allowed type
@@ -79,20 +82,47 @@ class AlertCommand(BaseCommand):
if self._is_validator_not_null:
self._report_schedule.last_value_row_json = str(self._result)
- return self._result not in (0, None, np.nan)
+ is_null_or_nan = self._result is None or pd.isna(self._result)
+ triggered = bool(self._result != 0 and not is_null_or_nan)
+ if not triggered:
+ logger.debug(
+ "Alert not triggered for report_schedule_id=%s, "
+ "execution_id=%s, result=%s, message=%s",
+ self._report_schedule.id,
+ self._execution_id,
+ self._result,
+ self._info_message,
+ )
+ return (triggered, self._info_message)
self._report_schedule.last_value = self._result
try:
- operator =
json.loads(self._report_schedule.validator_config_json)["op"]
- threshold =
json.loads(self._report_schedule.validator_config_json)[
- "threshold"
- ]
- return OPERATOR_FUNCTIONS[operator](self._result, threshold) #
type: ignore
+ config = json.loads(self._report_schedule.validator_config_json)
+ operator = config["op"]
+ threshold = config["threshold"]
+ # Return False for None results to prevent false alert triggers
+ if self._result is None:
+ logger.debug(
+ "Alert not triggered (NULL result) for
report_schedule_id=%s, "
+ "execution_id=%s, message=%s",
+ self._report_schedule.id,
+ self._execution_id,
+ self._info_message,
+ )
+ return (False, self._info_message)
+ triggered = OPERATOR_FUNCTIONS[operator](self._result, threshold)
+ return (triggered, None)
except (KeyError, json.JSONDecodeError) as ex:
raise AlertValidatorConfigError() from ex
def _validate_not_null(self, rows: np.recarray[Any, Any]) -> None:
self._validate_result(rows)
- self._result = rows[0][1]
+ value = rows[0][1]
+ # Normalize NULL/NaN to None for consistency with _validate_operator
+ if value is None or pd.isna(value):
+ self._result = None
+ self._info_message = "Query returned NULL value"
+ return
+ self._result = value
@staticmethod
def _validate_result(rows: np.recarray[Any, Any]) -> None:
@@ -117,11 +147,16 @@ class AlertCommand(BaseCommand):
def _validate_operator(self, rows: np.recarray[Any, Any]) -> None:
self._validate_result(rows)
- if rows[0][1] in (0, None, np.nan):
+
+ if rows[0][1] is None or pd.isna(rows[0][1]):
+ self._result = None
+ self._info_message = "Query returned NULL value"
+ return
+
+ if rows[0][1] == 0:
self._result = 0.0
return
try:
- # Check if it's float or if we can convert it
self._result = float(rows[0][1])
return
except (AssertionError, TypeError, ValueError) as ex:
@@ -212,7 +247,14 @@ class AlertCommand(BaseCommand):
self._result = None
return
if df.empty and self._is_validator_operator:
- self._result = 0.0
+ logger.debug(
+ "Alert query returned empty result for report_schedule_id=%s, "
+ "execution_id=%s.",
+ self._report_schedule.id,
+ self._execution_id,
+ )
+ self._result = None
+ self._info_message = "Query returned no rows (empty result set)"
return
rows = df.to_records()
if self._is_validator_not_null:
diff --git a/superset/commands/report/execute.py
b/superset/commands/report/execute.py
index 17203e2339..1159364981 100644
--- a/superset/commands/report/execute.py
+++ b/superset/commands/report/execute.py
@@ -822,8 +822,13 @@ class ReportNotTriggeredErrorState(BaseReportState):
try:
# If it's an alert check if the alert is triggered
if self._report_schedule.type == ReportScheduleType.ALERT:
- if not AlertCommand(self._report_schedule,
self._execution_id).run():
- self.update_report_schedule_and_log(ReportState.NOOP)
+ triggered, message = AlertCommand(
+ self._report_schedule, self._execution_id
+ ).run()
+ if not triggered:
+ self.update_report_schedule_and_log(
+ ReportState.NOOP, error_message=message
+ )
return
self.send()
self.update_report_schedule_and_log(ReportState.SUCCESS)
@@ -949,8 +954,13 @@ class ReportSuccessState(BaseReportState):
return
self.update_report_schedule_and_log(ReportState.WORKING)
try:
- if not AlertCommand(self._report_schedule,
self._execution_id).run():
- self.update_report_schedule_and_log(ReportState.NOOP)
+ triggered, message = AlertCommand(
+ self._report_schedule, self._execution_id
+ ).run()
+ if not triggered:
+ self.update_report_schedule_and_log(
+ ReportState.NOOP, error_message=message
+ )
return
except Exception as ex:
self.send_error(
diff --git a/tests/integration_tests/reports/alert_tests.py
b/tests/integration_tests/reports/alert_tests.py
index 0e1b215693..2b483fb718 100644
--- a/tests/integration_tests/reports/alert_tests.py
+++ b/tests/integration_tests/reports/alert_tests.py
@@ -131,7 +131,9 @@ def test_execute_query_mutate_query_enabled(
database=mock_database,
validator_config_json='{"op": "==", "threshold": 1}',
)
- AlertCommand(report_schedule=report_schedule,
execution_id=uuid.uuid4()).run()
+ triggered, message = AlertCommand(
+ report_schedule=report_schedule, execution_id=uuid.uuid4()
+ ).run()
mock_mutate_call.assert_called_once_with(mock_limited_sql.return_value)
mock_get_df.assert_called_once_with(sql=mock_mutate_call.return_value)
@@ -166,7 +168,9 @@ def test_execute_query_mutate_query_disabled(
database=mock_database,
validator_config_json='{"op": "==", "threshold": 1}',
)
- AlertCommand(report_schedule=report_schedule,
execution_id=uuid.uuid4()).run()
+ triggered, message = AlertCommand(
+ report_schedule=report_schedule, execution_id=uuid.uuid4()
+ ).run()
mock_database.mutate_sql_based_on_config.assert_not_called()
mock_database.get_df.assert_called_once_with(
diff --git a/tests/unit_tests/commands/report/alert_test.py
b/tests/unit_tests/commands/report/alert_test.py
new file mode 100644
index 0000000000..1043dd2500
--- /dev/null
+++ b/tests/unit_tests/commands/report/alert_test.py
@@ -0,0 +1,496 @@
+# 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.
+
+from uuid import uuid4
+
+import numpy as np
+import pandas as pd
+import pytest
+from pytest_mock import MockerFixture
+
+from superset.commands.report.alert import AlertCommand
+from superset.commands.report.exceptions import AlertValidatorConfigError
+from superset.reports.models import ReportScheduleValidatorType, ReportState
+
+
+def test_empty_query_result_with_operator_validator_returns_false_with_message(
+ mocker: MockerFixture,
+) -> None:
+ """Test that empty results with operator validator returns (False,
message)"""
+ mocker.patch(
+ "superset.commands.report.alert.retry_call",
+ side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
+ )
+ mocker.patch(
+ "superset.commands.report.alert.AlertCommand._execute_query",
+ return_value=pd.DataFrame(),
+ )
+
+ report_schedule_mock = mocker.Mock()
+ report_schedule_mock.validator_type = ReportScheduleValidatorType.OPERATOR
+ report_schedule_mock.validator_config_json = '{"op": "<", "threshold":
0.75}'
+ report_schedule_mock.id = 1
+ report_schedule_mock.sql = "SELECT value FROM metrics WHERE value < 0.75"
+
+ command = AlertCommand(
+ report_schedule=report_schedule_mock,
+ execution_id=uuid4(),
+ )
+
+ triggered, message = command.run()
+
+ assert triggered is False
+ assert command._result is None
+ assert message == "Query returned no rows (empty result set)"
+
+
[email protected](
+ "operator,threshold",
+ [
+ ("<", 0.75),
+ ("<=", 100),
+ (">", 50),
+ (">=", 0),
+ ("==", 42),
+ ("!=", 0),
+ ],
+)
+def test_empty_result_prevents_false_alerts_for_all_operators(
+ mocker: MockerFixture,
+ operator: str,
+ threshold: float,
+) -> None:
+ """Test that empty results return (False, message) for any operator type"""
+ mocker.patch(
+ "superset.commands.report.alert.retry_call",
+ side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
+ )
+ mocker.patch(
+ "superset.commands.report.alert.AlertCommand._execute_query",
+ return_value=pd.DataFrame(),
+ )
+
+ report_schedule_mock = mocker.Mock()
+ report_schedule_mock.validator_type = ReportScheduleValidatorType.OPERATOR
+ report_schedule_mock.validator_config_json = (
+ f'{{"op": "{operator}", "threshold": {threshold}}}'
+ )
+ report_schedule_mock.id = 1
+
+ command = AlertCommand(
+ report_schedule=report_schedule_mock,
+ execution_id=uuid4(),
+ )
+
+ triggered, message = command.run()
+
+ assert triggered is False, (
+ f"Alert with operator '{operator}' should not trigger on empty results"
+ )
+ assert message == "Query returned no rows (empty result set)"
+
+
+def test_empty_result_flow_sets_noop_state(mocker: MockerFixture) -> None:
+ """Test that empty results lead to NOOP state with info message"""
+ from superset.commands.report.execute import ReportNotTriggeredErrorState
+
+ mocker.patch(
+ "superset.commands.report.alert.retry_call",
+ side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
+ )
+ mocker.patch(
+ "superset.commands.report.alert.AlertCommand._execute_query",
+ return_value=pd.DataFrame(),
+ )
+
+ report_schedule_mock = mocker.Mock()
+ report_schedule_mock.type = "Alert"
+ report_schedule_mock.validator_type = ReportScheduleValidatorType.OPERATOR
+ report_schedule_mock.validator_config_json = '{"op": "<", "threshold":
0.75}'
+ report_schedule_mock.id = 1
+ report_schedule_mock.last_state = ReportState.NOOP
+
+ state = ReportNotTriggeredErrorState(
+ report_schedule=report_schedule_mock,
+ scheduled_dttm=mocker.Mock(),
+ execution_id=uuid4(),
+ )
+
+ send_mock = mocker.patch.object(state, "send")
+ update_mock = mocker.patch.object(state, "update_report_schedule_and_log")
+
+ state.next()
+
+ update_mock.assert_any_call(
+ ReportState.NOOP, error_message="Query returned no rows (empty result
set)"
+ )
+ send_mock.assert_not_called()
+
+
+def test_malformed_config_raises_error_with_valid_result(
+ mocker: MockerFixture,
+) -> None:
+ """Test that malformed config is detected when result is valid"""
+ mocker.patch(
+ "superset.commands.report.alert.retry_call",
+ side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
+ )
+ mocker.patch(
+ "superset.commands.report.alert.AlertCommand._execute_query",
+ return_value=pd.DataFrame({"value": [0.5]}),
+ )
+
+ report_schedule_mock = mocker.Mock()
+ report_schedule_mock.validator_type = ReportScheduleValidatorType.OPERATOR
+ report_schedule_mock.validator_config_json = "invalid json"
+ report_schedule_mock.id = 1
+
+ command = AlertCommand(
+ report_schedule=report_schedule_mock,
+ execution_id=uuid4(),
+ )
+
+ with pytest.raises(AlertValidatorConfigError):
+ command.run()
+
+
+def test_query_returning_null_value_returns_false_with_message(
+ mocker: MockerFixture,
+) -> None:
+ """Test that query returning NULL value returns (False, message)"""
+ mocker.patch(
+ "superset.commands.report.alert.retry_call",
+ side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
+ )
+ mocker.patch(
+ "superset.commands.report.alert.AlertCommand._execute_query",
+ return_value=pd.DataFrame({"value": [None]}),
+ )
+
+ report_schedule_mock = mocker.Mock()
+ report_schedule_mock.validator_type = ReportScheduleValidatorType.OPERATOR
+ report_schedule_mock.validator_config_json = '{"op": "<", "threshold":
0.75}'
+ report_schedule_mock.id = 1
+
+ command = AlertCommand(
+ report_schedule=report_schedule_mock,
+ execution_id=uuid4(),
+ )
+
+ triggered, message = command.run()
+
+ assert triggered is False
+ assert command._result is None, "Query returning NULL should set result to
None"
+ assert message == "Query returned NULL value"
+
+
+def test_query_returning_zero_value_sets_result_to_zero(
+ mocker: MockerFixture,
+) -> None:
+ """Test that query returning 0 value sets result to 0.0 (not None)"""
+ mocker.patch(
+ "superset.commands.report.alert.retry_call",
+ side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
+ )
+ mocker.patch(
+ "superset.commands.report.alert.AlertCommand._execute_query",
+ return_value=pd.DataFrame({"value": [0]}),
+ )
+
+ report_schedule_mock = mocker.Mock()
+ report_schedule_mock.validator_type = ReportScheduleValidatorType.OPERATOR
+ report_schedule_mock.validator_config_json = '{"op": "<", "threshold":
0.75}'
+ report_schedule_mock.id = 1
+
+ command = AlertCommand(
+ report_schedule=report_schedule_mock,
+ execution_id=uuid4(),
+ )
+
+ triggered, message = command.run()
+
+ assert command._result == 0.0, "Query returning 0 should set result to 0.0"
+ assert triggered is True, "0 < 0.75 should trigger alert"
+ assert message is None
+ assert report_schedule_mock.last_value == 0.0
+
+
+def test_query_returning_nan_value_returns_false_with_message(
+ mocker: MockerFixture,
+) -> None:
+ """Test that query returning NaN value returns (False, message)"""
+ mocker.patch(
+ "superset.commands.report.alert.retry_call",
+ side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
+ )
+ mocker.patch(
+ "superset.commands.report.alert.AlertCommand._execute_query",
+ return_value=pd.DataFrame({"value": [np.nan]}),
+ )
+
+ report_schedule_mock = mocker.Mock()
+ report_schedule_mock.validator_type = ReportScheduleValidatorType.OPERATOR
+ report_schedule_mock.validator_config_json = '{"op": ">", "threshold": 5}'
+ report_schedule_mock.id = 1
+
+ command = AlertCommand(
+ report_schedule=report_schedule_mock,
+ execution_id=uuid4(),
+ )
+
+ triggered, message = command.run()
+
+ assert triggered is False
+ assert command._result is None, "Query returning NaN should set result to
None"
+ assert message == "Query returned NULL value"
+
+
[email protected](
+ "value,expected_result,operator,threshold,should_trigger",
+ [
+ (0, 0.0, "<", 0.75, True),
+ (0.5, 0.5, "<", 0.75, True),
+ (1.0, 1.0, "<", 0.75, False),
+ (0, 0.0, ">=", 0, True),
+ ],
+)
+def test_value_handling_with_valid_numbers(
+ mocker: MockerFixture,
+ value: float,
+ expected_result: float,
+ operator: str,
+ threshold: float,
+ should_trigger: bool,
+) -> None:
+ """Test proper handling of valid numeric values including 0"""
+ mocker.patch(
+ "superset.commands.report.alert.retry_call",
+ side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
+ )
+ mocker.patch(
+ "superset.commands.report.alert.AlertCommand._execute_query",
+ return_value=pd.DataFrame({"value": [value]}),
+ )
+
+ report_schedule_mock = mocker.Mock()
+ report_schedule_mock.validator_type = ReportScheduleValidatorType.OPERATOR
+ report_schedule_mock.validator_config_json = (
+ f'{{"op": "{operator}", "threshold": {threshold}}}'
+ )
+ report_schedule_mock.id = 1
+
+ command = AlertCommand(
+ report_schedule=report_schedule_mock,
+ execution_id=uuid4(),
+ )
+
+ triggered, message = command.run()
+ assert command._result == expected_result, (
+ f"Value {value} should result in {expected_result}, got
{command._result}"
+ )
+ assert triggered is should_trigger, (
+ f"Value {value} with {operator} {threshold} should "
+ f"{'trigger' if should_trigger else 'not trigger'} alert"
+ )
+ assert report_schedule_mock.last_value == expected_result
+ if should_trigger:
+ assert message is None
+ else:
+ assert message is None # Non-trigger due to threshold, not NULL
+
+
[email protected](
+ "value,expected_message",
+ [
+ (None, "Query returned NULL value"),
+ (np.nan, "Query returned NULL value"),
+ ],
+)
+def test_value_handling_with_null_and_nan(
+ mocker: MockerFixture,
+ value: float | None,
+ expected_message: str,
+) -> None:
+ """Test that NULL and NaN values return (False, message)"""
+ mocker.patch(
+ "superset.commands.report.alert.retry_call",
+ side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
+ )
+ mocker.patch(
+ "superset.commands.report.alert.AlertCommand._execute_query",
+ return_value=pd.DataFrame({"value": [value]}),
+ )
+
+ report_schedule_mock = mocker.Mock()
+ report_schedule_mock.validator_type = ReportScheduleValidatorType.OPERATOR
+ report_schedule_mock.validator_config_json = '{"op": "<", "threshold":
0.75}'
+ report_schedule_mock.id = 1
+
+ command = AlertCommand(
+ report_schedule=report_schedule_mock,
+ execution_id=uuid4(),
+ )
+
+ triggered, message = command.run()
+
+ assert triggered is False
+ assert message == expected_message
+ assert command._result is None
+
+
+def test_not_null_validator_with_valid_value_triggers_alert(
+ mocker: MockerFixture,
+) -> None:
+ """Test NOT_NULL validator triggers with valid non-null value"""
+ mocker.patch(
+ "superset.commands.report.alert.retry_call",
+ side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
+ )
+ mocker.patch(
+ "superset.commands.report.alert.AlertCommand._execute_query",
+ return_value=pd.DataFrame({"value": [42]}),
+ )
+
+ report_schedule_mock = mocker.Mock()
+ report_schedule_mock.validator_type = ReportScheduleValidatorType.NOT_NULL
+ report_schedule_mock.id = 1
+
+ command = AlertCommand(
+ report_schedule=report_schedule_mock,
+ execution_id=uuid4(),
+ )
+
+ triggered, message = command.run()
+
+ assert triggered is True, "NOT_NULL with valid value should trigger alert"
+ assert command._result == 42
+ assert message is None
+ assert report_schedule_mock.last_value_row_json == "42"
+
+
+def test_not_null_validator_with_null_value_does_not_trigger(
+ mocker: MockerFixture,
+) -> None:
+ """Test NOT_NULL validator normalizes NULL to None and doesn't trigger"""
+ mocker.patch(
+ "superset.commands.report.alert.retry_call",
+ side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
+ )
+ mocker.patch(
+ "superset.commands.report.alert.AlertCommand._execute_query",
+ return_value=pd.DataFrame({"value": [None]}),
+ )
+
+ report_schedule_mock = mocker.Mock()
+ report_schedule_mock.validator_type = ReportScheduleValidatorType.NOT_NULL
+ report_schedule_mock.id = 1
+
+ command = AlertCommand(
+ report_schedule=report_schedule_mock,
+ execution_id=uuid4(),
+ )
+
+ triggered, message = command.run()
+
+ assert triggered is False, "NOT_NULL with NULL value should not trigger"
+ assert command._result is None
+ assert message == "Query returned NULL value"
+
+
+def test_not_null_validator_with_nan_value_does_not_trigger(
+ mocker: MockerFixture,
+) -> None:
+ """Test NOT_NULL validator normalizes NaN to None and doesn't trigger"""
+ mocker.patch(
+ "superset.commands.report.alert.retry_call",
+ side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
+ )
+ mocker.patch(
+ "superset.commands.report.alert.AlertCommand._execute_query",
+ return_value=pd.DataFrame({"value": [np.nan]}),
+ )
+
+ report_schedule_mock = mocker.Mock()
+ report_schedule_mock.validator_type = ReportScheduleValidatorType.NOT_NULL
+ report_schedule_mock.id = 1
+
+ command = AlertCommand(
+ report_schedule=report_schedule_mock,
+ execution_id=uuid4(),
+ )
+
+ triggered, message = command.run()
+
+ assert triggered is False, "NOT_NULL with NaN value should not trigger"
+ assert command._result is None
+ assert message == "Query returned NULL value"
+
+
+def test_not_null_validator_with_zero_value_does_not_trigger(
+ mocker: MockerFixture,
+) -> None:
+ """Test NOT_NULL validator with 0 value does not trigger (0 is falsy)"""
+ mocker.patch(
+ "superset.commands.report.alert.retry_call",
+ side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
+ )
+ mocker.patch(
+ "superset.commands.report.alert.AlertCommand._execute_query",
+ return_value=pd.DataFrame({"value": [0]}),
+ )
+
+ report_schedule_mock = mocker.Mock()
+ report_schedule_mock.validator_type = ReportScheduleValidatorType.NOT_NULL
+ report_schedule_mock.id = 1
+
+ command = AlertCommand(
+ report_schedule=report_schedule_mock,
+ execution_id=uuid4(),
+ )
+
+ triggered, message = command.run()
+
+ assert triggered is False, "NOT_NULL with 0 value should not trigger"
+ assert command._result == 0
+
+
+def test_not_null_validator_with_empty_result_does_not_trigger(
+ mocker: MockerFixture,
+) -> None:
+ """Test NOT_NULL validator with empty result does not trigger"""
+ mocker.patch(
+ "superset.commands.report.alert.retry_call",
+ side_effect=lambda func, *args, **kwargs: func(*args, **kwargs),
+ )
+ mocker.patch(
+ "superset.commands.report.alert.AlertCommand._execute_query",
+ return_value=pd.DataFrame(),
+ )
+
+ report_schedule_mock = mocker.Mock()
+ report_schedule_mock.validator_type = ReportScheduleValidatorType.NOT_NULL
+ report_schedule_mock.id = 1
+
+ command = AlertCommand(
+ report_schedule=report_schedule_mock,
+ execution_id=uuid4(),
+ )
+
+ triggered, message = command.run()
+
+ assert triggered is False, "NOT_NULL with empty result should not trigger"
+ assert command._result is None