audio streaming app plyr.fm
at main 278 lines 8.3 kB view raw
1"""tests for artist analytics endpoints.""" 2 3from collections.abc import Generator 4 5import pytest 6from fastapi import FastAPI 7from httpx import ASGITransport, AsyncClient 8from sqlalchemy.ext.asyncio import AsyncSession 9 10from backend._internal import Session, require_auth 11from backend.main import app 12from backend.models import Artist, Track, TrackLike 13 14 15class MockSession(Session): 16 """mock session for auth bypass in tests.""" 17 18 def __init__(self, did: str = "did:test:user123"): 19 self.did = did 20 self.handle = "testuser.bsky.social" 21 self.session_id = "test_session_id" 22 self.access_token = "test_token" 23 self.refresh_token = "test_refresh" 24 self.oauth_session = { 25 "did": did, 26 "handle": "testuser.bsky.social", 27 "pds_url": "https://test.pds", 28 "authserver_iss": "https://auth.test", 29 "scope": "atproto transition:generic", 30 "access_token": "test_token", 31 "refresh_token": "test_refresh", 32 "dpop_private_key_pem": "fake_key", 33 "dpop_authserver_nonce": "", 34 "dpop_pds_nonce": "", 35 } 36 37 38@pytest.fixture 39def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]: 40 """create test app with mocked auth.""" 41 42 async def mock_require_auth() -> Session: 43 return MockSession() 44 45 app.dependency_overrides[require_auth] = mock_require_auth 46 47 yield app 48 49 app.dependency_overrides.clear() 50 51 52@pytest.fixture 53async def artist_with_tracks(db_session: AsyncSession) -> Artist: 54 """create artist with tracks having different play counts and likes.""" 55 artist = Artist( 56 did="did:plc:artist123", 57 handle="artist.bsky.social", 58 display_name="Test Artist", 59 ) 60 db_session.add(artist) 61 await db_session.flush() 62 63 # create tracks with varying play counts 64 tracks = [ 65 Track( 66 title="Most Played", 67 artist_did=artist.did, 68 file_id="file_1", 69 file_type="mp3", 70 play_count=100, 71 atproto_record_uri="at://did:plc:artist123/fm.plyr.track/1", 72 atproto_record_cid="cid_1", 73 ), 74 Track( 75 title="Most Liked", 76 artist_did=artist.did, 77 file_id="file_2", 78 file_type="mp3", 79 play_count=50, 80 atproto_record_uri="at://did:plc:artist123/fm.plyr.track/2", 81 atproto_record_cid="cid_2", 82 ), 83 Track( 84 title="Least Popular", 85 artist_did=artist.did, 86 file_id="file_3", 87 file_type="mp3", 88 play_count=10, 89 atproto_record_uri="at://did:plc:artist123/fm.plyr.track/3", 90 atproto_record_cid="cid_3", 91 ), 92 ] 93 94 for track in tracks: 95 db_session.add(track) 96 97 await db_session.commit() 98 99 # refresh to get IDs 100 for track in tracks: 101 await db_session.refresh(track) 102 103 # add likes: "Most Liked" gets 5 likes, "Most Played" gets 2 likes 104 likes = [] 105 for i in range(5): 106 likes.append( 107 TrackLike( 108 track_id=tracks[1].id, # Most Liked 109 user_did=f"did:test:user{i}", 110 atproto_like_uri=f"at://did:test:user{i}/fm.plyr.like/1", 111 ) 112 ) 113 114 for i in range(2): 115 likes.append( 116 TrackLike( 117 track_id=tracks[0].id, # Most Played 118 user_did=f"did:test:user{i + 10}", 119 atproto_like_uri=f"at://did:test:user{i + 10}/fm.plyr.like/1", 120 ) 121 ) 122 123 for like in likes: 124 db_session.add(like) 125 126 await db_session.commit() 127 128 return artist 129 130 131async def test_get_artist_analytics_with_likes( 132 test_app: FastAPI, db_session: AsyncSession, artist_with_tracks: Artist 133): 134 """test analytics returns both top played and top liked tracks.""" 135 async with AsyncClient( 136 transport=ASGITransport(app=test_app), base_url="http://test" 137 ) as client: 138 response = await client.get(f"/artists/{artist_with_tracks.did}/analytics") 139 140 assert response.status_code == 200 141 data = response.json() 142 143 # verify total metrics 144 assert data["total_plays"] == 160 # 100 + 50 + 10 145 assert data["total_items"] == 3 146 assert data["total_duration_seconds"] == 0 # no duration set on tracks 147 148 # verify top played track 149 assert data["top_item"]["title"] == "Most Played" 150 assert data["top_item"]["play_count"] == 100 151 152 # verify top liked track 153 assert data["top_liked"]["title"] == "Most Liked" 154 assert data["top_liked"]["play_count"] == 5 # like count 155 156 157async def test_get_artist_analytics_no_tracks( 158 test_app: FastAPI, db_session: AsyncSession 159): 160 """test analytics returns zeros for artist with no tracks.""" 161 # create artist with no tracks 162 artist = Artist( 163 did="did:plc:newartist", 164 handle="newartist.bsky.social", 165 display_name="New Artist", 166 ) 167 db_session.add(artist) 168 await db_session.commit() 169 170 async with AsyncClient( 171 transport=ASGITransport(app=test_app), base_url="http://test" 172 ) as client: 173 response = await client.get(f"/artists/{artist.did}/analytics") 174 175 assert response.status_code == 200 176 data = response.json() 177 178 assert data["total_plays"] == 0 179 assert data["total_items"] == 0 180 assert data["total_duration_seconds"] == 0 181 assert data["top_item"] is None 182 assert data["top_liked"] is None 183 184 185async def test_get_artist_analytics_no_likes( 186 test_app: FastAPI, db_session: AsyncSession 187): 188 """test analytics when artist has tracks but no likes.""" 189 artist = Artist( 190 did="did:plc:artist456", 191 handle="artist456.bsky.social", 192 display_name="Test Artist 2", 193 ) 194 db_session.add(artist) 195 await db_session.flush() 196 197 track = Track( 198 title="Unloved Track", 199 artist_did=artist.did, 200 file_id="file_1", 201 file_type="mp3", 202 play_count=50, 203 atproto_record_uri="at://did:plc:artist456/fm.plyr.track/1", 204 atproto_record_cid="cid_1", 205 ) 206 db_session.add(track) 207 await db_session.commit() 208 209 async with AsyncClient( 210 transport=ASGITransport(app=test_app), base_url="http://test" 211 ) as client: 212 response = await client.get(f"/artists/{artist.did}/analytics") 213 214 assert response.status_code == 200 215 data = response.json() 216 217 assert data["total_plays"] == 50 218 assert data["total_items"] == 1 219 assert data["total_duration_seconds"] == 0 # no duration set 220 assert data["top_item"]["title"] == "Unloved Track" 221 assert data["top_liked"] is None # no likes 222 223 224async def test_get_artist_analytics_with_duration( 225 test_app: FastAPI, db_session: AsyncSession 226): 227 """test analytics returns total duration from tracks.""" 228 artist = Artist( 229 did="did:plc:artist_duration", 230 handle="duration-artist.bsky.social", 231 display_name="Duration Artist", 232 ) 233 db_session.add(artist) 234 await db_session.flush() 235 236 # create tracks with duration in extra field 237 tracks = [ 238 Track( 239 title="Short Track", 240 artist_did=artist.did, 241 file_id="short_1", 242 file_type="mp3", 243 play_count=10, 244 extra={"duration": 180}, # 3 minutes 245 ), 246 Track( 247 title="Long Track", 248 artist_did=artist.did, 249 file_id="long_1", 250 file_type="mp3", 251 play_count=5, 252 extra={"duration": 3600}, # 1 hour 253 ), 254 Track( 255 title="No Duration Track", 256 artist_did=artist.did, 257 file_id="no_dur_1", 258 file_type="mp3", 259 play_count=3, 260 extra={}, # no duration 261 ), 262 ] 263 for track in tracks: 264 db_session.add(track) 265 await db_session.commit() 266 267 async with AsyncClient( 268 transport=ASGITransport(app=test_app), base_url="http://test" 269 ) as client: 270 response = await client.get(f"/artists/{artist.did}/analytics") 271 272 assert response.status_code == 200 273 data = response.json() 274 275 assert data["total_plays"] == 18 # 10 + 5 + 3 276 assert data["total_items"] == 3 277 # duration should sum only tracks that have it: 180 + 3600 = 3780 278 assert data["total_duration_seconds"] == 3780