music on atproto
plyr.fm
1"""tests for track like api endpoints and error handling."""
2
3from collections.abc import Generator
4from unittest.mock import patch
5
6import pytest
7from fastapi import FastAPI
8from httpx import ASGITransport, AsyncClient
9from sqlalchemy import select
10from sqlalchemy.ext.asyncio import AsyncSession
11
12from backend._internal import Session, require_auth
13from backend.main import app
14from backend.models import Artist, Track, TrackLike
15
16
17class MockSession(Session):
18 """mock session for auth bypass in tests."""
19
20 def __init__(self, did: str = "did:test:user123"):
21 self.did = did
22 self.handle = "testuser.bsky.social"
23 self.session_id = "test_session_id"
24 self.access_token = "test_token"
25 self.refresh_token = "test_refresh"
26 self.oauth_session = {
27 "did": did,
28 "handle": "testuser.bsky.social",
29 "pds_url": "https://test.pds",
30 "authserver_iss": "https://auth.test",
31 "scope": "atproto transition:generic",
32 "access_token": "test_token",
33 "refresh_token": "test_refresh",
34 "dpop_private_key_pem": "fake_key",
35 "dpop_authserver_nonce": "",
36 "dpop_pds_nonce": "",
37 }
38
39
40@pytest.fixture
41async def test_track(db_session: AsyncSession) -> Track:
42 """create a test track with artist."""
43 # create artist
44 artist = Artist(
45 did="did:plc:artist123",
46 handle="artist.bsky.social",
47 display_name="Test Artist",
48 )
49 db_session.add(artist)
50 await db_session.flush()
51
52 # create track
53 track = Track(
54 title="Test Track",
55 artist_did=artist.did,
56 file_id="test123",
57 file_type="mp3",
58 extra={"duration": 180},
59 atproto_record_uri="at://did:plc:artist123/fm.plyr.track/test123",
60 atproto_record_cid="bafytest123",
61 )
62 db_session.add(track)
63 await db_session.commit()
64 await db_session.refresh(track)
65
66 return track
67
68
69@pytest.fixture
70def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]:
71 """create test app with mocked auth."""
72
73 async def mock_require_auth() -> Session:
74 return MockSession()
75
76 app.dependency_overrides[require_auth] = mock_require_auth
77
78 yield app
79
80 app.dependency_overrides.clear()
81
82
83async def test_like_track_success(
84 test_app: FastAPI, db_session: AsyncSession, test_track: Track
85):
86 """test successful track like creates DB entry and schedules PDS record creation."""
87 with patch("backend.api.tracks.likes.schedule_pds_create_like") as mock_schedule:
88 async with AsyncClient(
89 transport=ASGITransport(app=test_app), base_url="http://test"
90 ) as client:
91 response = await client.post(f"/tracks/{test_track.id}/like")
92
93 assert response.status_code == 200
94 assert response.json()["liked"] is True
95
96 # verify background task was scheduled
97 mock_schedule.assert_called_once()
98 call_kwargs = mock_schedule.call_args.kwargs
99 assert call_kwargs["subject_uri"] == test_track.atproto_record_uri
100 assert call_kwargs["subject_cid"] == test_track.atproto_record_cid
101
102 # verify DB entry exists (created immediately, before PDS)
103 result = await db_session.execute(
104 select(TrackLike).where(
105 TrackLike.track_id == test_track.id,
106 TrackLike.user_did == "did:test:user123",
107 )
108 )
109 like = result.scalar_one_or_none()
110 assert like is not None
111 # atproto_like_uri is None initially - will be set by background task
112 assert like.atproto_like_uri is None
113
114
115async def test_like_track_db_entry_has_correct_like_id(
116 test_app: FastAPI, db_session: AsyncSession, test_track: Track
117):
118 """test that the like_id passed to background task matches the DB record."""
119 with patch("backend.api.tracks.likes.schedule_pds_create_like") as mock_schedule:
120 async with AsyncClient(
121 transport=ASGITransport(app=test_app), base_url="http://test"
122 ) as client:
123 await client.post(f"/tracks/{test_track.id}/like")
124
125 # get the like_id from the scheduled call
126 call_kwargs = mock_schedule.call_args.kwargs
127 scheduled_like_id = call_kwargs["like_id"]
128
129 # verify it matches the DB record
130 result = await db_session.execute(
131 select(TrackLike).where(
132 TrackLike.track_id == test_track.id,
133 TrackLike.user_did == "did:test:user123",
134 )
135 )
136 like = result.scalar_one()
137 assert like.id == scheduled_like_id
138
139
140async def test_unlike_track_success(
141 test_app: FastAPI, db_session: AsyncSession, test_track: Track
142):
143 """test successful track unlike removes DB entry and schedules PDS record deletion."""
144 # create existing like
145 like = TrackLike(
146 track_id=test_track.id,
147 user_did="did:test:user123",
148 atproto_like_uri="at://did:test:user123/fm.plyr.like/abc123",
149 )
150 db_session.add(like)
151 await db_session.commit()
152
153 with patch("backend.api.tracks.likes.schedule_pds_delete_like") as mock_schedule:
154 async with AsyncClient(
155 transport=ASGITransport(app=test_app), base_url="http://test"
156 ) as client:
157 response = await client.delete(f"/tracks/{test_track.id}/like")
158
159 assert response.status_code == 200
160 assert response.json()["liked"] is False
161
162 # verify background task was scheduled with correct URI
163 mock_schedule.assert_called_once()
164 call_kwargs = mock_schedule.call_args.kwargs
165 assert call_kwargs["like_uri"] == "at://did:test:user123/fm.plyr.like/abc123"
166
167 # verify DB entry is gone (deleted immediately, before PDS)
168 result = await db_session.execute(
169 select(TrackLike).where(
170 TrackLike.track_id == test_track.id,
171 TrackLike.user_did == "did:test:user123",
172 )
173 )
174 assert result.scalar_one_or_none() is None
175
176
177async def test_unlike_track_without_atproto_uri(
178 test_app: FastAPI, db_session: AsyncSession, test_track: Track
179):
180 """test that unliking a track without ATProto URI doesn't schedule deletion."""
181 # create like without ATProto URI (e.g., background task hasn't run yet)
182 like = TrackLike(
183 track_id=test_track.id,
184 user_did="did:test:user123",
185 atproto_like_uri=None,
186 )
187 db_session.add(like)
188 await db_session.commit()
189
190 with patch("backend.api.tracks.likes.schedule_pds_delete_like") as mock_schedule:
191 async with AsyncClient(
192 transport=ASGITransport(app=test_app), base_url="http://test"
193 ) as client:
194 response = await client.delete(f"/tracks/{test_track.id}/like")
195
196 assert response.status_code == 200
197 assert response.json()["liked"] is False
198
199 # no PDS deletion should be scheduled since there's no ATProto record
200 mock_schedule.assert_not_called()
201
202 # verify DB entry is still gone
203 result = await db_session.execute(
204 select(TrackLike).where(
205 TrackLike.track_id == test_track.id,
206 TrackLike.user_did == "did:test:user123",
207 )
208 )
209 assert result.scalar_one_or_none() is None
210
211
212async def test_like_already_liked_track_idempotent(
213 test_app: FastAPI, db_session: AsyncSession, test_track: Track
214):
215 """test that liking an already-liked track is idempotent."""
216 # create existing like
217 like = TrackLike(
218 track_id=test_track.id,
219 user_did="did:test:user123",
220 atproto_like_uri="at://did:test:user123/fm.plyr.like/abc123",
221 )
222 db_session.add(like)
223 await db_session.commit()
224
225 with patch("backend.api.tracks.likes.schedule_pds_create_like") as mock_schedule:
226 async with AsyncClient(
227 transport=ASGITransport(app=test_app), base_url="http://test"
228 ) as client:
229 response = await client.post(f"/tracks/{test_track.id}/like")
230
231 assert response.status_code == 200
232 assert response.json()["liked"] is True
233
234 # verify no new background task was scheduled
235 mock_schedule.assert_not_called()
236
237
238async def test_unlike_not_liked_track_idempotent(
239 test_app: FastAPI, db_session: AsyncSession, test_track: Track
240):
241 """test that unliking a not-liked track is idempotent."""
242 with patch("backend.api.tracks.likes.schedule_pds_delete_like") as mock_schedule:
243 async with AsyncClient(
244 transport=ASGITransport(app=test_app), base_url="http://test"
245 ) as client:
246 response = await client.delete(f"/tracks/{test_track.id}/like")
247
248 assert response.status_code == 200
249 assert response.json()["liked"] is False
250
251 # verify no background task was scheduled
252 mock_schedule.assert_not_called()
253
254
255async def test_like_track_missing_atproto_record(
256 test_app: FastAPI, db_session: AsyncSession
257):
258 """test that liking a track without ATProto record returns 422."""
259 # create artist
260 artist = Artist(
261 did="did:plc:artist456",
262 handle="artist2.bsky.social",
263 display_name="Test Artist 2",
264 )
265 db_session.add(artist)
266 await db_session.flush()
267
268 # create track WITHOUT ATProto record
269 track = Track(
270 title="No ATProto Track",
271 artist_did=artist.did,
272 file_id="noatproto123",
273 file_type="mp3",
274 atproto_record_uri=None,
275 atproto_record_cid=None,
276 )
277 db_session.add(track)
278 await db_session.commit()
279 await db_session.refresh(track)
280
281 async with AsyncClient(
282 transport=ASGITransport(app=test_app), base_url="http://test"
283 ) as client:
284 response = await client.post(f"/tracks/{track.id}/like")
285
286 assert response.status_code == 422
287 detail = response.json()["detail"]
288 assert detail["error"] == "missing_atproto_record"
289
290
291async def test_like_nonexistent_track(test_app: FastAPI):
292 """test that liking a nonexistent track returns 404."""
293 async with AsyncClient(
294 transport=ASGITransport(app=test_app), base_url="http://test"
295 ) as client:
296 response = await client.post("/tracks/99999/like")
297
298 assert response.status_code == 404
299 assert response.json()["detail"] == "track not found"