"""Unit tests for lambda handler in src.app.""" from unittest.mock import MagicMock import pytest from datadog_api_client import Configuration from datadog_api_client.v2.model.state import State from pytest_mock import MockerFixture from src import app, constants from src.aggregate_logs import AggregateLogsConfig @pytest.fixture def mock_env(monkeypatch: pytest.MonkeyPatch) -> None: """Set default config values and reset module state for each test.""" monkeypatch.setattr(app.config, "DD_API_KEY", "api-key") monkeypatch.setattr(app.config, "DD_APP_KEY", "app-key") monkeypatch.setattr(app.config, "SENTRY_DSN", None) monkeypatch.setattr(app.config, "ENVIRONMENT", "test") monkeypatch.setattr(app.config, "SCORECARD_RULE_ID", "rule-123") monkeypatch.setattr(app.config, "AGGREGATE_LOGS_FROM", "now-7d") monkeypatch.setattr(app.config, "AGGREGATE_LOGS_TO", "now") monkeypatch.setitem(app.__dict__, "_SENTRY_INITIALIZED", False) @pytest.fixture def mock_handler_dependencies( mocker: MockerFixture, ) -> tuple[MagicMock, MagicMock, MagicMock, MagicMock]: """Mock handler collaborators used to query and update Datadog.""" m_list_services = mocker.patch("src.app.list_services_with_details") m_aggregate_logs = mocker.patch("src.app.aggregate_logs") m_update_outcomes = mocker.patch( "src.app.update_scorecard_service_outcomes_batch" ) m_logger = mocker.patch.object(app, "logger") return ( m_list_services, m_aggregate_logs, m_update_outcomes, m_logger, ) @pytest.mark.parametrize( "dd_api_key, dd_app_key", [ pytest.param(None, "app-key", id="missing apiKey"), pytest.param("api-key", "", id="missing appKey"), pytest.param(None, None, id="both creds missing"), ], ) def test_handler_raises_when_datadog_credentials_missing( mock_env: None, mock_handler_dependencies: tuple[MagicMock, MagicMock, MagicMock, MagicMock], monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, dd_api_key: str | None, dd_app_key: str | None, ) -> None: """Raises when API or app key is missing and does not call downstream APIs.""" del mocker ( list_services_mock, _aggregate_logs_mock, _update_outcomes_mock, logger_mock, ) = mock_handler_dependencies monkeypatch.setattr(app.config, "DD_API_KEY", dd_api_key) monkeypatch.setattr(app.config, "DD_APP_KEY", dd_app_key) with pytest.raises(ValueError, match="Missing DD_API_KEY or DD_APP_KEY"): app.handler({}, object()) logger_mock.error.assert_called_once_with("Missing DD_API_KEY or DD_APP_KEY") list_services_mock.assert_not_called() def test_handler_updates_outcomes_based_on_aggregated_logs( mock_env: None, mock_handler_dependencies: tuple[MagicMock, MagicMock, MagicMock, MagicMock], ) -> None: """Builds expected aggregate request and updates PASS/FAIL outcomes by service.""" # noqa: E501 ( list_services_mock, aggregate_logs_mock, update_outcomes_mock, _logger_mock, ) = mock_handler_dependencies list_services_mock.return_value = [{"name": "svc-a"}, {"name": "svc-b"}] aggregate_logs_mock.return_value = { "svc-a": {"count": 5}, "svc-yolo": {"count": 100}, # svc-yolo will not be part of the outcomes } app.handler({}, object()) aggregate_call_kwargs = aggregate_logs_mock.call_args.kwargs dd_config = aggregate_call_kwargs["configuration"] assert isinstance(dd_config, Configuration) assert dd_config.api_key["apiKeyAuth"] == "api-key" assert dd_config.api_key["appKeyAuth"] == "app-key" aggregate_config = aggregate_call_kwargs["aggregate_config"] assert isinstance(aggregate_config, AggregateLogsConfig) assert aggregate_config == AggregateLogsConfig( query=constants.LOG_QUERY, logs_from="now-7d", logs_to="now", facet="@apollographql-client-name", group_by_limit=1000, ) update_outcomes_mock.assert_called_once_with( "api-key", "app-key", [ { "rule_id": "rule-123", "state": State.FAIL, "service_name": "svc-a", }, { "rule_id": "rule-123", "state": State.PASS, "service_name": "svc-b", }, ], ) def test_import_initializes_sentry_when_dsn_present( mock_env: None, monkeypatch: pytest.MonkeyPatch, mocker: MockerFixture, ) -> None: """Initializes sentry integration when SENTRY_DSN is configured.""" monkeypatch.setattr(app.config, "SENTRY_DSN", "https://sentry.example/123") sentry_init_mock = mocker.patch.object(app.sentry_sdk, "init") aws_integration_mock = mocker.patch.object( app, "AwsLambdaIntegration", return_value="aws-integration", ) app.init_sentry_if_configured(app.load_app_config()) aws_integration_mock.assert_called_once_with(timeout_warning=True) sentry_init_mock.assert_called_once() sentry_init_mock.assert_called_once_with( dsn="https://sentry.example/123", environment="test", integrations=["aws-integration"], ) def test_import_skips_sentry_initialization_when_dsn_missing( mock_env: None, mocker: MockerFixture, ) -> None: """Does not initialize sentry when SENTRY_DSN is absent.""" sentry_init_mock = mocker.patch.object(app.sentry_sdk, "init") aws_integration_mock = mocker.patch.object(app, "AwsLambdaIntegration") app.init_sentry_if_configured(app.load_app_config()) aws_integration_mock.assert_not_called() sentry_init_mock.assert_not_called() def test_load_app_config_reads_values_from_config_module( mock_env: None, monkeypatch: pytest.MonkeyPatch, ) -> None: """Loads app config values from the config module.""" monkeypatch.setattr(app.config, "DD_API_KEY", "k") monkeypatch.setattr(app.config, "DD_APP_KEY", "a") monkeypatch.setattr(app.config, "SENTRY_DSN", "dsn") monkeypatch.setattr(app.config, "ENVIRONMENT", "test") monkeypatch.setattr(app.config, "SCORECARD_RULE_ID", "rule") monkeypatch.setattr(app.config, "AGGREGATE_LOGS_FROM", "now-1d") monkeypatch.setattr(app.config, "AGGREGATE_LOGS_TO", "now") loaded = app.load_app_config() assert loaded == app.AppConfig( dd_api_key="k", dd_app_key="a", sentry_dsn="dsn", environment="test", scorecard_rule_id="rule", aggregate_logs_from="now-1d", aggregate_logs_to="now", ) def test_get_logger_reads_logger_from_common_config( mock_env: None, mocker: MockerFixture, ) -> None: """Returns module logger used by the handler.""" logger = mocker.Mock() mocker.patch.object(app, "logger", logger) assert app.get_logger() is logger def test_handler_logs_when_aggregate_not_in_services( mock_env: None, mock_handler_dependencies: tuple[MagicMock, MagicMock, MagicMock, MagicMock], ) -> None: """Logs info when an aggregate from logs is not in the known services.""" ( list_services_mock, aggregate_logs_mock, _update_outcomes_mock, logger_mock, ) = mock_handler_dependencies list_services_mock.return_value = [{"name": "svc-a"}] aggregate_logs_mock.return_value = { "svc-unknown": {"count": 5}, } app.handler({}, object()) logger_mock.info.assert_any_call( "svc-unknown is not in the scorecard rule", ) def test_handler_logs_for_each_aggregate_not_in_services( mock_env: None, mock_handler_dependencies: tuple[MagicMock, MagicMock, MagicMock, MagicMock], ) -> None: """Logs info for each aggregate that is not in the known services.""" ( list_services_mock, aggregate_logs_mock, _update_outcomes_mock, logger_mock, ) = mock_handler_dependencies list_services_mock.return_value = [{"name": "svc-a"}] aggregate_logs_mock.return_value = { "svc-unknown-1": {"count": 5}, "svc-unknown-2": {"count": 10}, } app.handler({}, object()) logger_mock.info.assert_any_call( "svc-unknown-1 is not in the scorecard rule", ) logger_mock.info.assert_any_call( "svc-unknown-2 is not in the scorecard rule", ) def test_handler_does_not_log_when_all_aggregates_in_services( mock_env: None, mock_handler_dependencies: tuple[MagicMock, MagicMock, MagicMock, MagicMock], ) -> None: """Does not log when all aggregates from logs are in the known services.""" ( list_services_mock, aggregate_logs_mock, _update_outcomes_mock, logger_mock, ) = mock_handler_dependencies list_services_mock.return_value = [{"name": "svc-a"}, {"name": "svc-b"}] aggregate_logs_mock.return_value = { "svc-a": {"count": 5}, "svc-b": {"count": 10}, } app.handler({}, object()) # Should not log "not in the scorecard rule" message for call in logger_mock.info.call_args_list: assert "is not in the scorecard rule" not in str(call)