audio streaming app
plyr.fm
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