audio streaming app plyr.fm
at main 379 lines 13 kB view raw
1"""tests for queue api 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, queue_service 11from backend.main import app 12from backend.models import Album, Artist, Track 13 14 15# create a mock session object 16class MockSession(Session): 17 """mock session for auth bypass in tests.""" 18 19 def __init__(self, did: str = "did:test:user123"): 20 self.did = did 21 self.access_token = "test_token" 22 self.refresh_token = "test_refresh" 23 24 25@pytest.fixture 26def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]: 27 """create test app with mocked auth.""" 28 from backend._internal import require_auth 29 30 # mock the auth dependency to return a mock session 31 async def mock_require_auth() -> Session: 32 return MockSession() 33 34 # override the auth dependency 35 app.dependency_overrides[require_auth] = mock_require_auth 36 37 # clear the queue service cache before each test 38 # the queue_service is a singleton that persists state across tests 39 queue_service.cache.clear() 40 41 yield app 42 43 # cleanup 44 app.dependency_overrides.clear() 45 # clear cache after test too 46 queue_service.cache.clear() 47 48 49async def test_get_queue_empty_state(test_app: FastAPI, db_session: AsyncSession): 50 """test GET /queue returns empty state for new user.""" 51 async with AsyncClient( 52 transport=ASGITransport(app=test_app), base_url="http://test" 53 ) as client: 54 response = await client.get("/queue/") 55 56 assert response.status_code == 200 57 data = response.json() 58 59 # verify empty queue structure 60 assert data["state"]["track_ids"] == [] 61 assert data["state"]["current_index"] == 0 62 assert data["state"]["current_track_id"] is None 63 assert data["state"]["shuffle"] is False 64 assert data["state"]["auto_advance"] is True 65 assert data["state"]["original_order_ids"] == [] 66 assert data["revision"] == 0 67 assert data["tracks"] == [] 68 69 # verify ETag header 70 assert response.headers["etag"] == '"0"' 71 72 73async def test_put_queue_creates_new_state(test_app: FastAPI, db_session: AsyncSession): 74 """test PUT /queue creates new queue state.""" 75 new_state = { 76 "track_ids": ["track1", "track2", "track3"], 77 "current_index": 1, 78 "current_track_id": "track2", 79 "shuffle": True, 80 "original_order_ids": ["track1", "track2", "track3"], 81 } 82 83 async with AsyncClient( 84 transport=ASGITransport(app=test_app), base_url="http://test" 85 ) as client: 86 response = await client.put("/queue/", json={"state": new_state}) 87 88 assert response.status_code == 200 89 data = response.json() 90 91 # auto_advance comes from user preferences, not queue state 92 assert data["state"]["track_ids"] == new_state["track_ids"] 93 assert data["state"]["current_index"] == new_state["current_index"] 94 assert data["state"]["current_track_id"] == new_state["current_track_id"] 95 assert data["state"]["shuffle"] == new_state["shuffle"] 96 assert data["state"]["original_order_ids"] == new_state["original_order_ids"] 97 assert "auto_advance" in data["state"] # present but from preferences 98 assert data["revision"] == 1 # first update should be revision 1 99 assert data["tracks"] == [] 100 101 102async def test_get_queue_returns_updated_state( 103 test_app: FastAPI, db_session: AsyncSession 104): 105 """test GET /queue returns previously saved state.""" 106 # first, create a queue 107 new_state = { 108 "track_ids": ["track1", "track2"], 109 "current_index": 0, 110 "current_track_id": "track1", 111 "shuffle": False, 112 "original_order_ids": ["track1", "track2"], 113 } 114 115 async with AsyncClient( 116 transport=ASGITransport(app=test_app), base_url="http://test" 117 ) as client: 118 put_response = await client.put("/queue/", json={"state": new_state}) 119 assert put_response.status_code == 200 120 put_revision = put_response.json()["revision"] 121 122 # now get it back 123 get_response = await client.get("/queue/") 124 125 assert get_response.status_code == 200 126 data = get_response.json() 127 128 # verify queue state fields (auto_advance comes from preferences) 129 assert data["state"]["track_ids"] == new_state["track_ids"] 130 assert data["state"]["current_index"] == new_state["current_index"] 131 assert data["state"]["current_track_id"] == new_state["current_track_id"] 132 assert data["state"]["shuffle"] == new_state["shuffle"] 133 assert data["state"]["original_order_ids"] == new_state["original_order_ids"] 134 assert "auto_advance" in data["state"] 135 assert data["revision"] == put_revision 136 assert data["tracks"] == [] 137 138 # verify ETag matches revision 139 assert get_response.headers["etag"] == f'"{put_revision}"' 140 141 142async def test_put_queue_with_matching_revision_succeeds( 143 test_app: FastAPI, db_session: AsyncSession 144): 145 """test PUT /queue with correct If-Match header succeeds.""" 146 # create initial state 147 initial_state = { 148 "track_ids": ["track1"], 149 "current_index": 0, 150 "current_track_id": "track1", 151 "shuffle": False, 152 "original_order_ids": ["track1"], 153 } 154 155 async with AsyncClient( 156 transport=ASGITransport(app=test_app), base_url="http://test" 157 ) as client: 158 # create initial state 159 response1 = await client.put("/queue/", json={"state": initial_state}) 160 assert response1.status_code == 200 161 revision1 = response1.json()["revision"] 162 163 # update with matching revision 164 updated_state = { 165 **initial_state, 166 "track_ids": ["track1", "track2"], 167 } 168 response2 = await client.put( 169 "/queue/", 170 json={"state": updated_state}, 171 headers={"If-Match": f'"{revision1}"'}, 172 ) 173 174 assert response2.status_code == 200 175 data = response2.json() 176 assert data["state"]["track_ids"] == ["track1", "track2"] 177 assert data["revision"] == revision1 + 1 178 assert data["tracks"] == [] 179 180 181async def test_put_queue_with_mismatched_revision_fails( 182 test_app: FastAPI, db_session: AsyncSession 183): 184 """test PUT /queue with wrong If-Match header returns 409 conflict.""" 185 # create initial state 186 initial_state = { 187 "track_ids": ["track1"], 188 "current_index": 0, 189 "current_track_id": "track1", 190 "shuffle": False, 191 "original_order_ids": ["track1"], 192 } 193 194 async with AsyncClient( 195 transport=ASGITransport(app=test_app), base_url="http://test" 196 ) as client: 197 # create initial state 198 response1 = await client.put("/queue/", json={"state": initial_state}) 199 assert response1.status_code == 200 200 201 # try to update with wrong revision 202 updated_state = { 203 **initial_state, 204 "track_ids": ["track1", "track2"], 205 } 206 response2 = await client.put( 207 "/queue/", 208 json={"state": updated_state}, 209 headers={"If-Match": '"999"'}, # wrong revision 210 ) 211 212 assert response2.status_code == 409 213 assert "conflict" in response2.json()["detail"].lower() 214 215 216async def test_put_queue_without_if_match_always_succeeds( 217 test_app: FastAPI, db_session: AsyncSession 218): 219 """test PUT /queue without If-Match header always updates (no conflict check).""" 220 initial_state = { 221 "track_ids": ["track1"], 222 "current_index": 0, 223 "current_track_id": "track1", 224 "shuffle": False, 225 "original_order_ids": ["track1"], 226 } 227 228 async with AsyncClient( 229 transport=ASGITransport(app=test_app), base_url="http://test" 230 ) as client: 231 # create initial state 232 response1 = await client.put("/queue/", json={"state": initial_state}) 233 assert response1.status_code == 200 234 235 # update without If-Match (should always succeed) 236 updated_state = { 237 **initial_state, 238 "track_ids": ["track1", "track2", "track3"], 239 } 240 response2 = await client.put("/queue/", json={"state": updated_state}) 241 242 assert response2.status_code == 200 243 data = response2.json() 244 assert data["state"]["track_ids"] == ["track1", "track2", "track3"] 245 246 247async def test_queue_state_isolated_by_did(test_app: FastAPI, db_session: AsyncSession): 248 """test that different users have isolated queue states.""" 249 from backend._internal import require_auth 250 251 # user 1 252 async def mock_user1_auth() -> Session: 253 return MockSession(did="did:test:user1") 254 255 app.dependency_overrides[require_auth] = mock_user1_auth 256 257 user1_state = { 258 "track_ids": ["user1_track1"], 259 "current_index": 0, 260 "current_track_id": "user1_track1", 261 "shuffle": False, 262 "original_order_ids": ["user1_track1"], 263 } 264 265 async with AsyncClient( 266 transport=ASGITransport(app=test_app), base_url="http://test" 267 ) as client: 268 response1 = await client.put("/queue/", json={"state": user1_state}) 269 assert response1.status_code == 200 270 271 # user 2 272 async def mock_user2_auth() -> Session: 273 return MockSession(did="did:test:user2") 274 275 app.dependency_overrides[require_auth] = mock_user2_auth 276 277 user2_state = { 278 "track_ids": ["user2_track1"], 279 "current_index": 0, 280 "current_track_id": "user2_track1", 281 "shuffle": False, 282 "original_order_ids": ["user2_track1"], 283 } 284 285 async with AsyncClient( 286 transport=ASGITransport(app=test_app), base_url="http://test" 287 ) as client: 288 response2 = await client.put("/queue/", json={"state": user2_state}) 289 assert response2.status_code == 200 290 291 # verify user2 sees only their state 292 get_response = await client.get("/queue/") 293 assert get_response.json()["state"]["track_ids"] == ["user2_track1"] 294 295 # switch back to user 1 and verify their state persisted 296 app.dependency_overrides[require_auth] = mock_user1_auth 297 298 async with AsyncClient( 299 transport=ASGITransport(app=test_app), base_url="http://test" 300 ) as client: 301 get_response = await client.get("/queue/") 302 assert get_response.json()["state"]["track_ids"] == ["user1_track1"] 303 304 305async def test_queue_hydrates_track_with_album_data( 306 test_app: FastAPI, db_session: AsyncSession 307): 308 """test that queue properly serializes tracks with album relationships.""" 309 # create artist 310 artist = Artist( 311 did="did:test:user123", 312 handle="test.artist", 313 display_name="Test Artist", 314 ) 315 db_session.add(artist) 316 await db_session.flush() 317 318 # create album 319 album = Album( 320 artist_did=artist.did, 321 slug="test-album", 322 title="Test Album", 323 image_url="https://example.com/album.jpg", 324 ) 325 db_session.add(album) 326 await db_session.flush() 327 328 # create track linked to album 329 track = Track( 330 title="Test Track", 331 file_id="test-file-123", 332 file_type="audio/mpeg", 333 artist_did=artist.did, 334 album_id=album.id, 335 play_count=0, 336 ) 337 db_session.add(track) 338 await db_session.commit() 339 340 # add track to queue 341 queue_state = { 342 "track_ids": [track.file_id], 343 "current_index": 0, 344 "current_track_id": track.file_id, 345 "shuffle": False, 346 "original_order_ids": [track.file_id], 347 } 348 349 async with AsyncClient( 350 transport=ASGITransport(app=test_app), base_url="http://test" 351 ) as client: 352 # put track in queue 353 put_response = await client.put("/queue/", json={"state": queue_state}) 354 assert put_response.status_code == 200 355 356 # get queue and verify track serialization 357 get_response = await client.get("/queue/") 358 assert get_response.status_code == 200 359 360 data = get_response.json() 361 assert len(data["tracks"]) == 1 362 363 track_data = data["tracks"][0] 364 assert track_data["id"] == track.id 365 assert track_data["title"] == "Test Track" 366 assert track_data["artist"] == "Test Artist" 367 assert track_data["artist_handle"] == "test.artist" 368 assert track_data["file_id"] == "test-file-123" 369 370 # verify album data is properly serialized 371 assert track_data["album"] is not None 372 assert track_data["album"]["id"] == album.id 373 assert track_data["album"]["slug"] == "test-album" 374 assert track_data["album"]["title"] == "Test Album" 375 assert track_data["album"]["image_url"] == "https://example.com/album.jpg" 376 377 # verify like status defaults (queue doesn't include these) 378 assert track_data["is_liked"] is False 379 assert track_data["like_count"] == 0