"""Unit tests for the participant summary/timeseries logic layer.""" from datetime import date from http import HTTPStatus from unittest.mock import patch import pytest import analytics.logic.participant as undertest from analytics.api import app @pytest.fixture(autouse=True) def mock_request_context(): with app.test_request_context(): yield @pytest.fixture(autouse=True) def no_cache(): """Force cache miss on every call so the wrapped function actually runs.""" with patch("analytics.connectors.redis.client.get") as get: get.return_value = None yield get @pytest.fixture def mock_max_available_date(): fixed = date(2023, 8, 11) with patch( "analytics.logic.participant.data_availability.get_max_available_date", return_value=fixed, ) as m: yield m @pytest.fixture def permissions(): return { "permission_label_ids": [7123], "permission_artist_ids": None, "permission_subaccount_ids": None, "permission_label_participant_ids": None, "permission_feed_ids": [1, 2], } # ──────────────────────────────────────────────────────────────────────── # Summary # ──────────────────────────────────────────────────────────────────────── class TestGetParticipantSummary: """Each query_type dispatches to the expected query class.""" GP_ID = "f67b0892-f0bc-4575-b23e-59566ccb44bc" def _params(self, **overrides): return { "global_participant_id": self.GP_ID, "query_type": "TOTAL", "start_date": date(2023, 4, 30), "end_date": date(2023, 8, 10), "distributors": ["theorchard", "sme", "awal"], "country_ids": [], "store_ids": [], "stream_sources": [], "order_by": "streams", "order_dir": "desc", "limit": 100, "offset": 0, **overrides, } @pytest.mark.parametrize( "query_type,expected_class", [ ("TOTAL", "ParticipantSummary"), ("COUNTRY", "ParticipantSummary"), ("STORE", "ParticipantSummary"), ("PRODUCT", "ParticipantSummary"), ("SOUND_RECORDING", "ParticipantSummary"), ("SOS", "ParticipantSummaryBySos"), ], ) def test_dispatches_to_correct_query_class( self, query_type, expected_class, mocker, mock_max_available_date, permissions ): execute_mock = mocker.patch( f"analytics.logic.participant.{expected_class}.execute", return_value=[], ) # Patch siblings so we can detect mis-dispatch other_classes = {"ParticipantSummary", "ParticipantSummaryBySos"} - { expected_class } siblings = { cls: mocker.patch( f"analytics.logic.participant.{cls}.execute", return_value=[] ) for cls in other_classes } response = undertest.get_summary( self._params(query_type=query_type), permissions ) assert response.status == HTTPStatus.OK assert execute_mock.called for cls, mock in siblings.items(): assert not mock.called, f"unexpected call to {cls}.execute" def test_default_short_circuit(self, mocker, mock_max_available_date, permissions): """27-day range ending at max_available_date with default ordering uses the rollup.""" params = self._params( query_type="TOTAL", start_date=date(2023, 7, 15), end_date=date(2023, 8, 11), order_by="streams", order_dir="DESC", limit=50, offset=0, ) default_mock = mocker.patch( "analytics.logic.participant.ParticipantSummaryDefault.execute", return_value=[], ) regular_mock = mocker.patch( "analytics.logic.participant.ParticipantSummary.execute", return_value=[], ) undertest.get_summary(params, permissions) assert default_mock.called assert not regular_mock.called def test_sos_detailed_resolves_columns( self, mocker, mock_max_available_date, permissions ): """SOS_DETAILED resolves sos_columns from store_ids before executing.""" # Patch the constructor to capture init args ctor = mocker.patch( "analytics.logic.participant.ParticipantSummaryBySos", autospec=False, ) ctor.return_value.execute.return_value = [] undertest.get_summary( self._params( query_type="SOS_DETAILED", store_ids=[286], stream_sources=["collection"], ), permissions, ) args, _ = ctor.call_args assert args[0]["is_detailed"] is True assert "streams_sos_spotify_collection" in args[0]["sos_columns"] def test_invalid_order_by_raises(self, mock_max_available_date, permissions): with pytest.raises(Exception, match="invalid order field"): undertest.get_summary(self._params(order_by="invalid_field"), permissions) def test_invalid_order_dir_raises(self, mock_max_available_date, permissions): with pytest.raises(Exception, match="invalid order direction"): undertest.get_summary(self._params(order_dir="sideways"), permissions) def test_invalid_query_type_raises(self, mock_max_available_date, permissions): with pytest.raises(Exception, match="invalid query type"): undertest.get_summary(self._params(query_type="UNKNOWN"), permissions) def test_returns_normalized_response( self, mocker, mock_max_available_date, permissions ): mocker.patch( "analytics.logic.participant.ParticipantSummary.execute", return_value=[ { "id": "USJMZ1800051", "streams": 100, "skips": 5, "saves": 3, "skip_rate": 0.05, "streams_start_date": date(2023, 5, 1), "streams_end_date": date(2023, 8, 1), "downloads": 10, "track_downloads": 10, "album_downloads": 4, "downloads_start_date": date(2023, 6, 1), "downloads_end_date": date(2023, 7, 1), } ], ) response = undertest.get_summary(self._params(query_type="TOTAL"), permissions) assert response.status == HTTPStatus.OK assert response.message["items"][0]["id"] == "USJMZ1800051" # ──────────────────────────────────────────────────────────────────────── # Timeseries # ──────────────────────────────────────────────────────────────────────── class TestGetParticipantTimeseries: """Each query_type dispatches to the expected query class.""" GP_ID = "f67b0892-f0bc-4575-b23e-59566ccb44bc" def _params(self, **overrides): return { "global_participant_id": self.GP_ID, "query_type": "TRACK_STREAMS", "ids": [], "start_date": date(2023, 8, 20), "end_date": date(2023, 9, 15), "days_back": None, "distributors": ["theorchard", "awal"], "country_ids": [], "store_ids": [], "stream_sources": [], "resolution": "mid", **overrides, } @pytest.mark.parametrize( "query_type,expected_class", [ ("TRACK_STREAMS", "ParticipantTimeseriesStreams"), ("TRACK_STREAMS_BY_COUNTRY", "ParticipantTimeseriesStreams"), ("TRACK_STREAMS_BY_STORE", "ParticipantTimeseriesStreams"), ("TRACK_STREAMS_BY_PRODUCT", "ParticipantTimeseriesStreams"), ("TRACK_STREAMS_BY_SOUND_RECORDING", "ParticipantTimeseriesStreams"), ("TRACK_STREAMS_BY_SOS", "ParticipantTimeseriesStreamsBySos"), ("TRACK_DOWNLOADS", "ParticipantTimeseriesDownloads"), ("TRACK_DOWNLOADS_BY_COUNTRY", "ParticipantTimeseriesDownloads"), ("TRACK_DOWNLOADS_BY_STORE", "ParticipantTimeseriesDownloads"), ("TRACK_DOWNLOADS_BY_PRODUCT", "ParticipantTimeseriesDownloads"), ("TRACK_DOWNLOADS_BY_SOUND_RECORDING", "ParticipantTimeseriesDownloads"), ("ALBUM_DOWNLOADS", "ParticipantTimeseriesDownloads"), ("ALBUM_DOWNLOADS_BY_COUNTRY", "ParticipantTimeseriesDownloads"), ("ALBUM_DOWNLOADS_BY_STORE", "ParticipantTimeseriesDownloads"), ("ALBUM_DOWNLOADS_BY_PRODUCT", "ParticipantTimeseriesDownloads"), ], ) def test_dispatches_to_correct_query_class( self, query_type, expected_class, mocker, mock_max_available_date, permissions, ): execute_mock = mocker.patch( f"analytics.logic.participant.{expected_class}.execute", return_value=[], ) other_classes = { "ParticipantTimeseriesStreams", "ParticipantTimeseriesStreamsBySos", "ParticipantTimeseriesDownloads", } - {expected_class} siblings = { cls: mocker.patch( f"analytics.logic.participant.{cls}.execute", return_value=[] ) for cls in other_classes } response = undertest.get_timeseries( self._params(query_type=query_type), permissions ) assert response.status == HTTPStatus.OK assert execute_mock.called for cls, mock in siblings.items(): assert not mock.called, f"unexpected call to {cls}.execute" def test_sos_detailed_resolves_columns( self, mocker, mock_max_available_date, permissions ): ctor = mocker.patch( "analytics.logic.participant.ParticipantTimeseriesStreamsBySos", autospec=False, ) ctor.return_value.execute.return_value = [] undertest.get_timeseries( self._params( query_type="TRACK_STREAMS_BY_SOS_DETAILED", store_ids=[286], stream_sources=["collection"], ), permissions, ) args, _ = ctor.call_args assert args[0]["is_detailed"] is True assert "streams_sos_spotify_collection" in args[0]["sos_columns"] def test_invalid_query_type_raises(self, mock_max_available_date, permissions): with pytest.raises(Exception, match="invalid query type"): undertest.get_timeseries(self._params(query_type="UNKNOWN"), permissions) def test_passes_max_available_date( self, mocker, mock_max_available_date, permissions ): captured = {} def fake_init(self, params): captured["params"] = params mocker.patch.object( undertest.ParticipantTimeseriesStreams, "__init__", fake_init ) mocker.patch.object( undertest.ParticipantTimeseriesStreams, "execute", return_value=[] ) undertest.get_timeseries(self._params(), permissions) assert captured["params"]["max_available_date"] == date(2023, 8, 11) def test_returns_normalized_response( self, mocker, mock_max_available_date, permissions ): mocker.patch( "analytics.logic.participant.ParticipantTimeseriesStreams.execute", return_value=[ { "id": "f67b0892", "date": date(2023, 8, 20), "value": 50, "skip_rate": 0.1, "skips": 2, "saves": 1, } ], ) response = undertest.get_timeseries(self._params(), permissions) assert response.status == HTTPStatus.OK assert response.message["items"][0]["value"] == 50