"""Unit tests for handler state machine logic.""" from datetime import date, datetime, timezone import pytest import config from src import handler # Fixed reference points used across tests TODAY = date(2026, 6, 30) YESTERDAY = '2026-06-29' DAY_BEFORE = '2026-06-28' # NOW = 10:20 UTC; gives a controllable "current time" for lag calculations NOW = datetime(2026, 6, 30, 10, 20, tzinfo=timezone.utc) @pytest.fixture(autouse=True) def freeze_today(mocker): """Freeze date.today() to TODAY so 'yesterday' is always YESTERDAY.""" mock = mocker.patch('src.handler.date') mock.today.return_value = TODAY mock.fromisoformat = date.fromisoformat # preserve for step-0 date validation return mock @pytest.fixture(autouse=True) def freeze_now(mocker): """Freeze datetime.now() to NOW; preserve fromisoformat for state parsing.""" mock = mocker.patch('src.handler.datetime') mock.now.return_value = NOW mock.fromisoformat = datetime.fromisoformat return mock @pytest.fixture(autouse=True) def mock_metric(mocker): return mocker.patch('src.handler.lambda_metric') @pytest.fixture() def mock_state(mocker): return mocker.patch('src.handler.state') @pytest.fixture() def mock_spotify(mocker): return mocker.patch('src.handler.spotify') @pytest.fixture() def mock_snowflake(mocker): return mocker.patch('src.handler.snowflake') def _metric_calls_for(mock_metric, metric_name): return [c for c in mock_metric.call_args_list if c[0][0] == metric_name] # ── Step 0: short-circuit ───────────────────────────────────────────────────── def test_skip_when_fact_charts_has_yesterday( mock_state, mock_spotify, mock_snowflake, mock_metric ): """If last_fact_date == yesterday, skip Spotify and Snowflake entirely.""" mock_state.load.return_value = {'last_fact_date': YESTERDAY} handler._run() mock_spotify.get_latest_chart_date.assert_not_called() mock_snowflake.get_latest_fact_chart_date.assert_not_called() # spotify_up is NOT emitted here — Spotify was not called so we can't report it spotify_up_calls = _metric_calls_for(mock_metric, config.DD_SPOTIFY_UP_METRIC) assert spotify_up_calls == [] mock_metric.assert_any_call(config.DD_LAG_METRIC, 0, tags=config.DD_TAGS) mock_metric.assert_any_call(config.DD_ALERT_METRIC, 0, tags=config.DD_TAGS) def test_skip_when_fact_charts_has_today( mock_state, mock_spotify, mock_snowflake ): """Same-day charts (rare but possible) also trigger skip.""" mock_state.load.return_value = {'last_fact_date': TODAY.isoformat()} handler._run() mock_spotify.get_latest_chart_date.assert_not_called() mock_snowflake.get_latest_fact_chart_date.assert_not_called() def test_no_skip_when_state_is_empty(mock_state, mock_spotify, mock_snowflake): """First run with no state → proceed to check Spotify.""" mock_state.load.return_value = {} mock_spotify.get_latest_chart_date.return_value = None handler._run() mock_spotify.get_latest_chart_date.assert_called_once() def test_no_skip_when_last_fact_date_is_stale( mock_state, mock_spotify, mock_snowflake ): """last_fact_date older than yesterday → resume checks.""" mock_state.load.return_value = {'last_fact_date': DAY_BEFORE} mock_spotify.get_latest_chart_date.return_value = YESTERDAY mock_snowflake.get_latest_fact_chart_date.return_value = DAY_BEFORE handler._run() mock_spotify.get_latest_chart_date.assert_called_once() def test_corrupted_last_fact_date_does_not_skip( mock_state, mock_spotify, mock_snowflake ): """Non-ISO last_fact_date (e.g. manual SSM edit) must not short-circuit checks.""" mock_state.load.return_value = {'last_fact_date': 'garbage'} mock_spotify.get_latest_chart_date.return_value = None handler._run() mock_spotify.get_latest_chart_date.assert_called_once() def test_future_last_fact_date_does_not_skip( mock_state, mock_spotify, mock_snowflake ): """A future last_fact_date must not permanently disable monitoring.""" mock_state.load.return_value = {'last_fact_date': '2099-12-31'} mock_spotify.get_latest_chart_date.return_value = None handler._run() mock_spotify.get_latest_chart_date.assert_called_once() # ── Step 1: Spotify API unavailable ─────────────────────────────────────────── def test_spotify_api_down_no_pending_skips_snowflake( mock_state, mock_spotify, mock_snowflake, mock_metric ): """Spotify down and no pending date → emit all three metrics; skip Snowflake.""" mock_state.load.return_value = {} mock_spotify.get_latest_chart_date.return_value = None handler._run() mock_snowflake.get_latest_fact_chart_date.assert_not_called() mock_state.save.assert_not_called() mock_metric.assert_any_call(config.DD_SPOTIFY_UP_METRIC, 0, tags=config.DD_TAGS) mock_metric.assert_any_call(config.DD_LAG_METRIC, 0, tags=config.DD_TAGS) mock_metric.assert_any_call(config.DD_ALERT_METRIC, 0, tags=config.DD_TAGS) def test_spotify_api_down_with_pending_still_checks_snowflake( mock_state, mock_spotify, mock_snowflake, mock_metric ): """Spotify down but pending date exists → still query Snowflake for auto-resolve.""" detected_at = datetime(2026, 6, 30, 10, 0, tzinfo=timezone.utc).isoformat() mock_state.load.return_value = { 'pending_date': YESTERDAY, 'detected_at': detected_at, 'alerted': False, } mock_spotify.get_latest_chart_date.return_value = None mock_snowflake.get_latest_fact_chart_date.return_value = DAY_BEFORE handler._run() mock_snowflake.get_latest_fact_chart_date.assert_called_once() down_calls = _metric_calls_for(mock_metric, config.DD_SPOTIFY_UP_METRIC) assert down_calls[0][0][1] == 0 def test_spotify_api_down_with_pending_auto_resolves( mock_state, mock_spotify, mock_snowflake ): """Spotify down but Snowflake has caught up → save last_fact_date and resolve.""" detected_at = datetime(2026, 6, 30, 10, 10, tzinfo=timezone.utc).isoformat() mock_state.load.return_value = { 'pending_date': YESTERDAY, 'detected_at': detected_at, 'alerted': False, } mock_spotify.get_latest_chart_date.return_value = None mock_snowflake.get_latest_fact_chart_date.return_value = YESTERDAY handler._run() mock_state.save.assert_called_once_with({'last_fact_date': YESTERDAY}) # ── Step 2: New date detected ───────────────────────────────────────────────── def test_new_date_detected_saves_pending_state( mock_state, mock_spotify, mock_snowflake ): """When Spotify has a date not yet in SSM, save pending state with detected_at.""" mock_state.load.return_value = {} mock_spotify.get_latest_chart_date.return_value = YESTERDAY mock_snowflake.get_latest_fact_chart_date.return_value = DAY_BEFORE handler._run() first_save = mock_state.save.call_args_list[0][0][0] assert first_save['pending_date'] == YESTERDAY assert first_save['alerted'] is False assert 'detected_at' in first_save def test_corrupted_detected_at_falls_back_to_now( mock_state, mock_spotify, mock_snowflake, mock_metric ): """Corrupted or missing detected_at → fall back to NOW, persist repaired state.""" mock_state.load.return_value = { 'pending_date': YESTERDAY, 'detected_at': 'not-a-date', 'alerted': False, } mock_spotify.get_latest_chart_date.return_value = YESTERDAY mock_snowflake.get_latest_fact_chart_date.return_value = DAY_BEFORE handler._run() # must not raise lag_calls = _metric_calls_for(mock_metric, config.DD_LAG_METRIC) assert lag_calls[0][0][1] == pytest.approx(0.0, abs=0.1) # Repaired detected_at must be saved so lag accumulates on next invocation save_calls = mock_state.save.call_args_list repair_saves = [c for c in save_calls if 'detected_at' in c[0][0]] assert len(repair_saves) >= 1 assert repair_saves[0][0][0]['detected_at'] == NOW.isoformat() def test_future_detected_at_clamped_to_now( mock_state, mock_spotify, mock_snowflake, mock_metric ): """detected_at in the future → clamped to now, persisted, lag stays non-negative.""" future_time = datetime(2026, 6, 30, 11, 0, tzinfo=timezone.utc).isoformat() mock_state.load.return_value = { 'pending_date': YESTERDAY, 'detected_at': future_time, 'alerted': False, } mock_spotify.get_latest_chart_date.return_value = YESTERDAY mock_snowflake.get_latest_fact_chart_date.return_value = DAY_BEFORE handler._run() lag_calls = _metric_calls_for(mock_metric, config.DD_LAG_METRIC) assert lag_calls[0][0][1] == pytest.approx(0.0, abs=0.1) save_calls = mock_state.save.call_args_list repair_saves = [c for c in save_calls if 'detected_at' in c[0][0]] assert len(repair_saves) >= 1 assert repair_saves[0][0][0]['detected_at'] == NOW.isoformat() def test_naive_detected_at_normalised_to_utc( mock_state, mock_spotify, mock_snowflake, mock_metric ): """Timezone-naive detected_at is normalized to UTC and persisted.""" # Store without a timezone offset (e.g. a manual SSM edit) mock_state.load.return_value = { 'pending_date': YESTERDAY, 'detected_at': '2026-06-30T10:10:00', # naive — no +00:00 'alerted': False, } mock_spotify.get_latest_chart_date.return_value = YESTERDAY mock_snowflake.get_latest_fact_chart_date.return_value = DAY_BEFORE handler._run() # must not raise TypeError lag_calls = _metric_calls_for(mock_metric, config.DD_LAG_METRIC) assert lag_calls[0][0][1] == pytest.approx(10.0, abs=0.1) save_calls = mock_state.save.call_args_list repair_saves = [c for c in save_calls if 'detected_at' in c[0][0]] assert len(repair_saves) >= 1 assert '+00:00' in repair_saves[0][0][0]['detected_at'] # ── Step 3: FACT_CHARTS catches up ──────────────────────────────────────────── def test_sync_saves_last_fact_date( mock_state, mock_spotify, mock_snowflake, mock_metric ): """When FACT_CHARTS reaches pending_date, save last_fact_date and emit lag=0.""" detected_at = datetime(2026, 6, 30, 10, 10, tzinfo=timezone.utc).isoformat() mock_state.load.return_value = { 'pending_date': YESTERDAY, 'detected_at': detected_at, 'alerted': False, } mock_spotify.get_latest_chart_date.return_value = YESTERDAY mock_snowflake.get_latest_fact_chart_date.return_value = YESTERDAY handler._run() mock_state.save.assert_called_once_with({'last_fact_date': YESTERDAY}) mock_metric.assert_any_call(config.DD_LAG_METRIC, 0, tags=config.DD_TAGS) mock_metric.assert_any_call(config.DD_ALERT_METRIC, 0, tags=config.DD_TAGS) def test_sync_after_alert_also_saves_last_fact_date( mock_state, mock_spotify, mock_snowflake ): """Auto-resolve works even when we previously alerted.""" detected_at = datetime(2026, 6, 30, 10, 0, tzinfo=timezone.utc).isoformat() mock_state.load.return_value = { 'pending_date': YESTERDAY, 'detected_at': detected_at, 'alerted': True, } mock_spotify.get_latest_chart_date.return_value = YESTERDAY mock_snowflake.get_latest_fact_chart_date.return_value = YESTERDAY handler._run() mock_state.save.assert_called_once_with({'last_fact_date': YESTERDAY}) # ── Step 4: Lag computation and alerting ───────────────────────────────────── def test_within_grace_period_emits_lag_but_no_alert( mock_state, mock_spotify, mock_snowflake, mock_metric ): """Lag < LAG_ALERT_MINUTES → emit lag metric, alert metric = 0.""" # NOW=10:20, detected 10 min ago → 10 min lag (< 15 threshold) detected_at = datetime(2026, 6, 30, 10, 10, tzinfo=timezone.utc).isoformat() mock_state.load.return_value = { 'pending_date': YESTERDAY, 'detected_at': detected_at, 'alerted': False, } mock_spotify.get_latest_chart_date.return_value = YESTERDAY mock_snowflake.get_latest_fact_chart_date.return_value = DAY_BEFORE handler._run() lag_calls = _metric_calls_for(mock_metric, config.DD_LAG_METRIC) assert lag_calls[0][0][1] == pytest.approx(10.0, abs=0.1) alert_calls = _metric_calls_for(mock_metric, config.DD_ALERT_METRIC) assert alert_calls[0][0][1] == 0 def test_lag_exceeds_threshold_triggers_alert( mock_state, mock_spotify, mock_snowflake, mock_metric ): """Lag > LAG_ALERT_MINUTES and not yet alerted → emit alert=1, save alerted=True.""" # NOW=10:20, detected 20 min ago → 20 min lag (> 15 threshold) detected_at = datetime(2026, 6, 30, 10, 0, tzinfo=timezone.utc).isoformat() mock_state.load.return_value = { 'pending_date': YESTERDAY, 'detected_at': detected_at, 'alerted': False, } mock_spotify.get_latest_chart_date.return_value = YESTERDAY mock_snowflake.get_latest_fact_chart_date.return_value = DAY_BEFORE handler._run() alert_calls = _metric_calls_for(mock_metric, config.DD_ALERT_METRIC) assert alert_calls[0][0][1] == 1 last_save = mock_state.save.call_args_list[-1][0][0] assert last_save['alerted'] is True def test_already_alerted_continues_emitting_alert_metric( mock_state, mock_spotify, mock_snowflake, mock_metric ): """alert=1 is emitted on every run while alert is active; state is not re-saved.""" detected_at = datetime(2026, 6, 30, 10, 0, tzinfo=timezone.utc).isoformat() mock_state.load.return_value = { 'pending_date': YESTERDAY, 'detected_at': detected_at, 'alerted': True, } mock_spotify.get_latest_chart_date.return_value = YESTERDAY mock_snowflake.get_latest_fact_chart_date.return_value = DAY_BEFORE handler._run() alert_calls = _metric_calls_for(mock_metric, config.DD_ALERT_METRIC) assert alert_calls[0][0][1] == 1 # alerted flag already set — state must not be re-saved mock_state.save.assert_not_called() # ── handler() error propagation ─────────────────────────────────────────────── def test_handler_propagates_exceptions(mock_state, mock_spotify): """Unhandled exceptions inside _run() should surface from handler().""" mock_state.load.side_effect = RuntimeError('SSM unavailable') with pytest.raises(RuntimeError, match='SSM unavailable'): handler.handler(event={}, context={})