from datetime import UTC, datetime from unittest import mock import pytest from dirty_equals import IsDatetime, IsPartialDict, IsStr from pydantic import SecretStr from pytest_mock import MockerFixture from resonance_engine.dsp.enums import DSPClientName, DSPId from resonance_engine.dsp.exceptions import UnknownDSPClientError from resonance_engine.dsp.models import DSPClient from resonance_engine.fandata.enums import FanCollectionError, FanConnectionStatus from resonance_engine.fandata.exceptions import FanConnectionNotFoundError from resonance_engine.fandata.handlers import ( BatchCreateFanConnectionRequest, CollectFanRequest, CreateFanConnectionRequest, GetFansRequest, batch_create_fan_connections, collect_fan, create_fan_connection, get_fans, ) from resonance_engine.fandata.models import FanCollectionState, FanConnection from resonance_engine.fandata.utils import make_fan_id from tests.unit.helpers import create_model @pytest.mark.db class TestCreateFanConnection: def test_inserts_new_connection(self) -> None: dsp_client = create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_songwhip ) result = create_fan_connection( CreateFanConnectionRequest( email="fan@example.com", dsp_client_name=DSPClientName.spotify_songwhip, token=SecretStr("plaintext-token"), ) ) assert result.fan_id == make_fan_id("fan@example.com") assert result.dsp_id == DSPId.spotify assert result.dsp_client_id == dsp_client.id assert result.status == FanConnectionStatus.active assert result.token_encrypted == "plaintext-token" def test_upserts_existing_connection(self) -> None: dsp_client = create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_songwhip ) create_model( FanConnection, fan_id=make_fan_id("fan@example.com"), dsp_id=DSPId.spotify, dsp_client_id=dsp_client.id, status=FanConnectionStatus.revoked, token_encrypted="old-token", ) result = create_fan_connection( CreateFanConnectionRequest( email="fan@example.com", dsp_client_name=DSPClientName.spotify_songwhip, token=SecretStr("new-token"), ) ) assert result.status == FanConnectionStatus.active assert result.token_encrypted == "new-token" def test_raises_unknown_dsp_client_error(self) -> None: with pytest.raises(UnknownDSPClientError) as exc_info: create_fan_connection( CreateFanConnectionRequest( email="fan@example.com", dsp_client_name=DSPClientName.spotify_songwhip, token=SecretStr("token"), ) ) assert exc_info.value.dsp_client_name == DSPClientName.spotify_songwhip @pytest.mark.db class TestBatchCreateFanConnection: def test_skips_invalid_emails(self) -> None: dsp_client = create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_songwhip ) count = batch_create_fan_connections( BatchCreateFanConnectionRequest( items=[ CreateFanConnectionRequest( email="good@example.com", dsp_client_name=DSPClientName.spotify_songwhip, token=SecretStr("t1"), ), CreateFanConnectionRequest( email="not-an-email", dsp_client_name=DSPClientName.spotify_songwhip, token=SecretStr("t2"), ), ] ) ) assert count == 1 rows = FanConnection.query.where( FanConnection.dsp_client_id == dsp_client.id ).all() assert [r.fan_id for r in rows] == [make_fan_id("good@example.com")] def test_returns_zero_when_all_invalid(self) -> None: create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_songwhip ) count = batch_create_fan_connections( BatchCreateFanConnectionRequest( items=[ CreateFanConnectionRequest( email="nope", dsp_client_name=DSPClientName.spotify_songwhip, token=SecretStr("t"), ) ] ) ) assert count == 0 @pytest.mark.db class TestGetFans: def test_joins_connection_collection_and_client(self) -> None: dsp_client = create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_songwhip, display_name="Songwhip", ) create_model( FanConnection, fan_id="user-1", dsp_id=DSPId.spotify, dsp_client_id=dsp_client.id, ) create_model( FanCollectionState, fan_id="user-1", dsp_id=DSPId.spotify, last_dsp_client_id=dsp_client.id, last_collected_at=datetime(2026, 5, 1, tzinfo=UTC), last_collection_error=FanCollectionError.api_error, consecutive_failures=2, ) result = get_fans(GetFansRequest()) assert result.model_dump() == { "items": [ { "fan_id": "user-1", "dsp_id": DSPId.spotify, "status": FanConnectionStatus.active, "first_seen_at": IsDatetime(), "last_collected_at": datetime(2026, 5, 1, tzinfo=UTC), "last_collection_error": FanCollectionError.api_error, "consecutive_failures": 2, "dsp_client_id": dsp_client.id, "dsp_client_name": DSPClientName.spotify_songwhip, "dsp_client_display_name": "Songwhip", "profile_collected_at": None, "top_artists_collected_at": None, "top_tracks_collected_at": None, "recently_played_collected_at": None, "playlists_collected_at": None, "saved_albums_collected_at": None, "saved_tracks_collected_at": None, "followed_artists_collected_at": None, } ], "next_cursor": None, } def test_returns_zero_failures_for_connection_without_collection(self) -> None: dsp_client = create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_songwhip ) create_model( FanConnection, fan_id="user-1", dsp_id=DSPId.spotify, dsp_client_id=dsp_client.id, ) result = get_fans(GetFansRequest()) assert result.model_dump() == IsPartialDict( items=[ IsPartialDict( last_collected_at=None, last_collection_error=None, consecutive_failures=0, ) ], ) def test_filters_by_status(self) -> None: dsp_client = create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_songwhip ) create_model( FanConnection, fan_id="active", dsp_id=DSPId.spotify, dsp_client_id=dsp_client.id, status=FanConnectionStatus.active, ) create_model( FanConnection, fan_id="revoked", dsp_id=DSPId.spotify, dsp_client_id=dsp_client.id, status=FanConnectionStatus.revoked, ) result = get_fans(GetFansRequest(status=FanConnectionStatus.active)) assert result.model_dump() == IsPartialDict( items=[IsPartialDict(fan_id="active")], ) def test_filters_by_dsp_client_names_requires_all(self) -> None: smf = create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_smf_sme ) songwhip = create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_songwhip ) # "both" holds connections in both clients; "one" only in smf. for client in (smf, songwhip): create_model( FanConnection, fan_id="both", dsp_id=DSPId.spotify, dsp_client_id=client.id, ) create_model( FanConnection, fan_id="one", dsp_id=DSPId.spotify, dsp_client_id=smf.id ) result = get_fans( GetFansRequest( dsp_client_names=[ DSPClientName.spotify_smf_sme, DSPClientName.spotify_songwhip, ] ) ) # AND semantics: only the fan present in *every* listed client. assert {item.fan_id for item in result.items} == {"both"} def test_filters_by_search(self) -> None: dsp_client = create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_songwhip ) create_model( FanConnection, fan_id="alice", dsp_id=DSPId.spotify, dsp_client_id=dsp_client.id, ) create_model( FanConnection, fan_id="bob", dsp_id=DSPId.spotify, dsp_client_id=dsp_client.id, ) result = get_fans(GetFansRequest(search="alice")) assert result.model_dump() == IsPartialDict( items=[IsPartialDict(fan_id="alice")], ) def test_filters_by_search_email(self) -> None: dsp_client = create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_songwhip ) fan_id = make_fan_id("alice@example.com") create_model( FanConnection, fan_id=fan_id, dsp_id=DSPId.spotify, dsp_client_id=dsp_client.id, ) result = get_fans(GetFansRequest(search="alice@example.com")) assert result.model_dump() == IsPartialDict( items=[IsPartialDict(fan_id=fan_id)], ) def test_filters_by_is_collected(self) -> None: dsp_client = create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_songwhip ) create_model( FanConnection, fan_id="collected", dsp_id=DSPId.spotify, dsp_client_id=dsp_client.id, ) create_model( FanCollectionState, fan_id="collected", dsp_id=DSPId.spotify, last_dsp_client_id=dsp_client.id, last_collected_at=datetime(2026, 5, 1, tzinfo=UTC), last_collection_error=None, ) create_model( FanConnection, fan_id="never", dsp_id=DSPId.spotify, dsp_client_id=dsp_client.id, ) collected = get_fans(GetFansRequest(is_collected=True)) not_collected = get_fans(GetFansRequest(is_collected=False)) assert collected.items[0].fan_id == "collected" assert not_collected.items[0].fan_id == "never" def test_paginates_via_cursor(self) -> None: dsp_client = create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_songwhip ) create_model( FanConnection, fan_id="u1", dsp_id=DSPId.spotify, dsp_client_id=dsp_client.id, ) create_model( FanConnection, fan_id="u2", dsp_id=DSPId.spotify, dsp_client_id=dsp_client.id, ) create_model( FanConnection, fan_id="u3", dsp_id=DSPId.spotify, dsp_client_id=dsp_client.id, ) first = get_fans(GetFansRequest(limit=2)) assert first.model_dump() == IsPartialDict( items=[ IsPartialDict(fan_id="u1"), IsPartialDict(fan_id="u2"), ], next_cursor=IsStr(), ) second = get_fans(GetFansRequest(limit=2, cursor=first.next_cursor)) assert second.model_dump() == IsPartialDict( items=[IsPartialDict(fan_id="u3")], next_cursor=None, ) @pytest.mark.db class TestCollectFan: @pytest.fixture def gateway_mock(self, mocker: MockerFixture) -> mock.MagicMock: m = mocker.patch( "resonance_engine.fandata.collector.dsp_gateway", new_callable=mock.MagicMock, ) m.refresh_token.return_value = {"access_token": SecretStr("acc-tok")} m.get_profile.return_value = {} m.get_top_artists.return_value = [{"id": "art-1"}] return m @pytest.fixture(autouse=True) def data_sink_mock(self, mocker: MockerFixture) -> mock.MagicMock: return mocker.patch( "resonance_engine.fandata.collector.data_sink", new_callable=mock.MagicMock, ) def test_returns_updated_fan_view(self, gateway_mock: mock.MagicMock) -> None: dsp_client = create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_songwhip, display_name="Songwhip", ) create_model( FanConnection, fan_id="user-1", dsp_id=DSPId.spotify, dsp_client_id=dsp_client.id, token_encrypted="refresh-tok", ) result = collect_fan( CollectFanRequest( fan_id="user-1", dsp_client_name=DSPClientName.spotify_songwhip, ) ) assert result.model_dump() == { "fan_id": "user-1", "dsp_id": DSPId.spotify, "status": FanConnectionStatus.active, "first_seen_at": IsDatetime(), "last_collected_at": IsDatetime(), "last_collection_error": None, "consecutive_failures": 0, "dsp_client_id": dsp_client.id, "dsp_client_name": DSPClientName.spotify_songwhip, "dsp_client_display_name": "Songwhip", "profile_collected_at": IsDatetime(), "top_artists_collected_at": IsDatetime(), "top_tracks_collected_at": IsDatetime(), "recently_played_collected_at": IsDatetime(), "playlists_collected_at": IsDatetime(), "saved_albums_collected_at": IsDatetime(), "saved_tracks_collected_at": IsDatetime(), "followed_artists_collected_at": IsDatetime(), } def test_resolves_email_to_fan_id(self, gateway_mock: mock.MagicMock) -> None: dsp_client = create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_songwhip ) fan_id = make_fan_id("alice@example.com") create_model( FanConnection, fan_id=fan_id, dsp_id=DSPId.spotify, dsp_client_id=dsp_client.id, token_encrypted="refresh-tok", ) result = collect_fan( CollectFanRequest( fan_id="alice@example.com", dsp_client_name=DSPClientName.spotify_songwhip, ) ) assert result.fan_id == fan_id def test_raises_unknown_dsp_client_error(self) -> None: with pytest.raises(UnknownDSPClientError) as exc_info: collect_fan( CollectFanRequest( fan_id="user-1", dsp_client_name=DSPClientName.spotify_songwhip, ) ) assert exc_info.value.dsp_client_name == DSPClientName.spotify_songwhip def test_raises_fan_connection_not_found_error(self) -> None: dsp_client = create_model( DSPClient, dsp_id=DSPId.spotify, name=DSPClientName.spotify_songwhip, ) with pytest.raises(FanConnectionNotFoundError) as exc_info: collect_fan( CollectFanRequest( fan_id="ghost", dsp_client_name=DSPClientName.spotify_songwhip, ) ) assert exc_info.value.fan_id == "ghost" assert exc_info.value.dsp_client_id == dsp_client.id