"""Unit tests for the bulk streams logic layer.""" import datetime from unittest.mock import patch import pytest from analytics.api import app from analytics.logic import streams_bulk SOURCES = [ {"id": 286, "name": "Spotify"}, {"id": 1, "name": "Apple Music"}, ] @pytest.fixture(autouse=True) def mock_context(): """Run in Flask request context.""" with app.test_request_context(): yield @pytest.fixture(autouse=True) def no_cache(): """Mock out caching of response.""" with patch("analytics.connectors.redis.client.get") as get: get.return_value = None yield get @pytest.fixture(autouse=True) def mock_sources_and_store_availability(mocker): """Mock sources / store_availability utilities.""" mocker.patch( "analytics.logic.streams_bulk.add_outage_error_to_stores", return_value=SOURCES, ) mocker.patch( "analytics.logic.streams_bulk.store_availability.get_sources", return_value=SOURCES, ) mocker.patch( "analytics.logic.streams_bulk.store_availability.get_store_ids", return_value=[1, 286], ) mocker.patch( "analytics.logic.streams_bulk.store_availability.get_store_names", return_value={1: "Apple Music", 286: "Spotify"}, ) @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], } @pytest.fixture def mock_get_max_available_date(mocker): mocker.patch( "analytics.logic.data_availability.get_max_available_date", return_value=datetime.date(2023, 1, 7), ) def _query_params( isrcs=("ISRC1",), countries=None, store_ids=None, start_date=None, end_date=None, ): return { "isrcs": list(isrcs), "country_ids": countries or [], "store_ids": store_ids or [], "start_date": start_date, "end_date": end_date, "distributors": ["theorchard", "sme", "awal"], } class TestGetStreamsBulkEmptyStoreIds: def test_empty_store_ids_skips_queries_and_returns_empty_bodies( self, mocker, permissions ): """Unavailable store_ids → empty bodies, no queries dispatched.""" daily = mocker.patch("analytics.logic.streams_bulk.StreamsBulkDaily.execute") all_time = mocker.patch( "analytics.logic.streams_bulk.StreamsBulkAllTime.execute" ) response = streams_bulk.get_streams_bulk( _query_params( isrcs=("ISRC1", "ISRC2"), store_ids=[99999], start_date=datetime.date(2023, 1, 1), end_date=datetime.date(2023, 1, 7), ), permissions, ) assert response.status == 200 assert set(response.message.keys()) == {"ISRC1", "ISRC2"} for body in response.message.values(): assert body["stores"] == [] assert body["sources"] == SOURCES assert body["aggregate"]["all_time"] == 0 assert body["aggregate"]["items"] == [] daily.assert_not_called() all_time.assert_not_called() class TestGetStreamsBulkEmptyResult: def test_empty_rows_yield_empty_aggregate(self, mocker, permissions): """When queries return nothing, each ISRC gets an empty body.""" mocker.patch( "analytics.logic.streams_bulk.StreamsBulkDaily.execute", return_value=[], ) mocker.patch( "analytics.logic.streams_bulk.StreamsBulkAllTime.execute", return_value=[], ) response = streams_bulk.get_streams_bulk( _query_params( isrcs=("ISRC1",), start_date=datetime.date(2023, 1, 1), end_date=datetime.date(2023, 1, 7), ), permissions, ) assert response.status == 200 body = response.message["ISRC1"] assert body["isrc"] == "ISRC1" assert body["stores"] == [] assert body["aggregate"]["all_time"] == 0 assert body["aggregate"]["items"] == [] class TestGetStreamsBulkDefaultDateRange: def test_no_dates_falls_back_to_highwatermark( self, mocker, permissions, mock_get_max_available_date ): """No start/end date → HIGHWATERMARK − 28 days fallback.""" get_date_range = mocker.patch( "analytics.logic.streams_bulk.data_availability.get_date_range", return_value=(datetime.date(2022, 12, 10), datetime.date(2023, 1, 7)), ) mocker.patch( "analytics.logic.streams_bulk.StreamsBulkDaily.execute", return_value=[], ) mocker.patch( "analytics.logic.streams_bulk.StreamsBulkAllTime.execute", return_value=[], ) response = streams_bulk.get_streams_bulk( _query_params(isrcs=("ISRC1",)), permissions ) assert response.status == 200 get_date_range.assert_called_once() def test_all_time_start_date_falls_back_to_highwatermark( self, mocker, permissions, mock_get_max_available_date ): """start_date='ALL_TIME' triggers the 28-day fallback. The graphql ?days=0&start_date=HIGHWATERMARK shape resolves to start_date='ALL_TIME' in `_get_global_filters`. Marshmallow's fields.Date rejects the string, so the logic strips it and the daily portion falls back to the 28-day window (the rollup portion already covers all-time). """ get_date_range = mocker.patch( "analytics.logic.streams_bulk.data_availability.get_date_range", return_value=(datetime.date(2022, 12, 10), datetime.date(2023, 1, 7)), ) DailyMock = mocker.patch("analytics.logic.streams_bulk.StreamsBulkDaily") DailyMock.return_value.execute.return_value = [] mocker.patch( "analytics.logic.streams_bulk.StreamsBulkAllTime.execute", return_value=[], ) response = streams_bulk.get_streams_bulk( _query_params( isrcs=("ISRC1",), start_date="ALL_TIME", end_date=datetime.date(2023, 1, 7), ), permissions, ) assert response.status == 200 get_date_range.assert_called_once() daily_input = DailyMock.call_args[0][0] assert daily_input["start_date"] == "2022-12-10" assert daily_input["end_date"] == "2023-01-07" class TestGetStreamsBulkHappyPath: def test_populates_stores_aggregate_and_sources( self, mocker, permissions, mock_get_max_available_date ): """Non-empty rows produce populated per-ISRC bodies.""" daily_row = { "store_id": 286, "date": datetime.date(2023, 1, 1), "isrc": "ISRC1", "streams": 100, "streams_with_skips": 80, "skips": 5, "saves": 10, } all_time_row = { "store_id": 286, "isrc": "ISRC1", "streams_all_time": 5000, "growth_percentage": 0.1, } mocker.patch( "analytics.logic.streams_bulk.StreamsBulkDaily.execute", return_value=[daily_row], ) mocker.patch( "analytics.logic.streams_bulk.StreamsBulkAllTime.execute", return_value=[all_time_row], ) response = streams_bulk.get_streams_bulk( _query_params( isrcs=("ISRC1",), start_date=datetime.date(2023, 1, 1), end_date=datetime.date(2023, 1, 7), ), permissions, ) assert response.status == 200 body = response.message["ISRC1"] assert body["isrc"] == "ISRC1" assert body["aggregate"]["all_time"] == 5000 assert body["sources"] == SOURCES assert any(store["id"] == 286 for store in body["stores"]) class TestGetStreamsBulkCountryFilter: def test_forwards_country_ids_to_query( self, mocker, permissions, mock_get_max_available_date ): """country_ids forwarded in query_input.""" DailyMock = mocker.patch("analytics.logic.streams_bulk.StreamsBulkDaily") DailyMock.return_value.execute.return_value = [] AllTimeMock = mocker.patch("analytics.logic.streams_bulk.StreamsBulkAllTime") AllTimeMock.return_value.execute.return_value = [] streams_bulk.get_streams_bulk( _query_params( isrcs=("ISRC1",), countries=["US", "GB"], start_date=datetime.date(2023, 1, 1), end_date=datetime.date(2023, 1, 7), ), permissions, ) daily_input = DailyMock.call_args[0][0] assert daily_input["country_ids"] == ["US", "GB"] all_time_input = AllTimeMock.call_args[0][0] assert all_time_input["country_ids"] == ["US", "GB"] class TestGetStreamsBulkHistoricalOwnershipFlag: @pytest.mark.parametrize("enabled", [False, True]) def test_flag_threaded_into_both_branches( self, mocker, permissions, mock_get_max_available_date, enabled ): """Flag flows into both DAILY and rollup all-time query inputs. The rollup branch needs the flag so the SQL templates can swap to the ``_PRODUCT_TRANSFER`` rollup variant and select the ``rollup_grain``-aware branch of ``permissions_filter``. """ DailyMock = mocker.patch("analytics.logic.streams_bulk.StreamsBulkDaily") DailyMock.return_value.execute.return_value = [] AllTimeMock = mocker.patch("analytics.logic.streams_bulk.StreamsBulkAllTime") AllTimeMock.return_value.execute.return_value = [] params = { **_query_params( isrcs=("ISRC1",), start_date=datetime.date(2023, 1, 1), end_date=datetime.date(2023, 1, 7), ), "transfer_product_ownership_enabled": enabled, } streams_bulk.get_streams_bulk(params, permissions) daily_input = DailyMock.call_args[0][0] assert daily_input["transfer_product_ownership_enabled"] is enabled all_time_input = AllTimeMock.call_args[0][0] assert all_time_input["transfer_product_ownership_enabled"] is enabled