music on atproto
plyr.fm
1"""tests for track comment api endpoints."""
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, TrackComment, UserPreferences
15
16
17class MockSession(Session):
18 """mock session for auth bypass in tests."""
19
20 def __init__(self, did: str = "did:test:commenter123"):
21 self.did = did
22 self.handle = "commenter.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": "commenter.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_artist(db_session: AsyncSession) -> Artist:
42 """create a test artist."""
43 artist = Artist(
44 did="did:plc:artist123",
45 handle="artist.bsky.social",
46 display_name="Test Artist",
47 )
48 db_session.add(artist)
49 await db_session.commit()
50 return artist
51
52
53@pytest.fixture
54async def test_track(db_session: AsyncSession, test_artist: Artist) -> Track:
55 """create a test track."""
56 track = Track(
57 title="Test Track",
58 artist_did=test_artist.did,
59 file_id="test123",
60 file_type="mp3",
61 extra={"duration": 180},
62 atproto_record_uri="at://did:plc:artist123/fm.plyr.track/test123",
63 atproto_record_cid="bafytest123",
64 )
65 db_session.add(track)
66 await db_session.commit()
67 await db_session.refresh(track)
68 return track
69
70
71@pytest.fixture
72async def artist_with_comments_enabled(
73 db_session: AsyncSession, test_artist: Artist
74) -> UserPreferences:
75 """create user preferences with comments enabled."""
76 prefs = UserPreferences(
77 did=test_artist.did,
78 allow_comments=True,
79 )
80 db_session.add(prefs)
81 await db_session.commit()
82 return prefs
83
84
85@pytest.fixture
86async def commenter_artist(db_session: AsyncSession) -> Artist:
87 """create the artist record for the commenter."""
88 artist = Artist(
89 did="did:test:commenter123",
90 handle="commenter.bsky.social",
91 display_name="Test Commenter",
92 )
93 db_session.add(artist)
94 await db_session.commit()
95 return artist
96
97
98@pytest.fixture
99def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]:
100 """create test app with mocked auth."""
101
102 async def mock_require_auth() -> Session:
103 return MockSession()
104
105 app.dependency_overrides[require_auth] = mock_require_auth
106 yield app
107 app.dependency_overrides.clear()
108
109
110async def test_get_comments_returns_empty_when_disabled(
111 test_app: FastAPI, db_session: AsyncSession, test_track: Track
112):
113 """test that comments endpoint returns empty list when artist has comments disabled."""
114 async with AsyncClient(
115 transport=ASGITransport(app=test_app), base_url="http://test"
116 ) as client:
117 response = await client.get(f"/tracks/{test_track.id}/comments")
118
119 assert response.status_code == 200
120 data = response.json()
121 assert data["comments"] == []
122 assert data["comments_enabled"] is False
123
124
125async def test_get_comments_returns_list_when_enabled(
126 test_app: FastAPI,
127 db_session: AsyncSession,
128 test_track: Track,
129 artist_with_comments_enabled: UserPreferences,
130):
131 """test that comments endpoint returns comments when enabled."""
132 # add a test comment directly to DB
133 comment = TrackComment(
134 track_id=test_track.id,
135 user_did="did:test:commenter123",
136 text="great track!",
137 timestamp_ms=45000,
138 atproto_comment_uri="at://did:test:commenter123/fm.plyr.comment/abc",
139 )
140 db_session.add(comment)
141 await db_session.commit()
142
143 async with AsyncClient(
144 transport=ASGITransport(app=test_app), base_url="http://test"
145 ) as client:
146 response = await client.get(f"/tracks/{test_track.id}/comments")
147
148 assert response.status_code == 200
149 data = response.json()
150 assert data["comments_enabled"] is True
151 assert len(data["comments"]) == 1
152 assert data["comments"][0]["text"] == "great track!"
153 assert data["comments"][0]["timestamp_ms"] == 45000
154
155
156async def test_create_comment_fails_when_comments_disabled(
157 test_app: FastAPI, db_session: AsyncSession, test_track: Track
158):
159 """test that creating a comment fails when artist has comments disabled."""
160 async with AsyncClient(
161 transport=ASGITransport(app=test_app), base_url="http://test"
162 ) as client:
163 response = await client.post(
164 f"/tracks/{test_track.id}/comments",
165 json={"text": "hello", "timestamp_ms": 1000},
166 )
167
168 assert response.status_code == 403
169 assert "disabled" in response.json()["detail"].lower()
170
171
172async def test_create_comment_success(
173 test_app: FastAPI,
174 db_session: AsyncSession,
175 test_track: Track,
176 artist_with_comments_enabled: UserPreferences,
177 commenter_artist: Artist,
178):
179 """test successful comment creation schedules background task."""
180 with patch(
181 "backend.api.tracks.comments.schedule_pds_create_comment"
182 ) as mock_schedule:
183 async with AsyncClient(
184 transport=ASGITransport(app=test_app), base_url="http://test"
185 ) as client:
186 response = await client.post(
187 f"/tracks/{test_track.id}/comments",
188 json={"text": "awesome drop at this moment!", "timestamp_ms": 30000},
189 )
190
191 assert response.status_code == 200
192 data = response.json()
193 assert data["text"] == "awesome drop at this moment!"
194 assert data["timestamp_ms"] == 30000
195 assert data["user_did"] == "did:test:commenter123"
196
197 # verify background task was scheduled
198 mock_schedule.assert_called_once()
199 call_kwargs = mock_schedule.call_args.kwargs
200 assert call_kwargs["subject_uri"] == test_track.atproto_record_uri
201 assert call_kwargs["subject_cid"] == test_track.atproto_record_cid
202 assert call_kwargs["text"] == "awesome drop at this moment!"
203 assert call_kwargs["timestamp_ms"] == 30000
204
205 # verify DB entry exists (created immediately, before PDS)
206 result = await db_session.execute(
207 select(TrackComment).where(TrackComment.track_id == test_track.id)
208 )
209 comment = result.scalar_one()
210 assert comment.text == "awesome drop at this moment!"
211 assert comment.timestamp_ms == 30000
212 # atproto_comment_uri is None initially - will be set by background task
213 assert comment.atproto_comment_uri is None
214
215
216async def test_create_comment_db_entry_has_correct_comment_id(
217 test_app: FastAPI,
218 db_session: AsyncSession,
219 test_track: Track,
220 artist_with_comments_enabled: UserPreferences,
221 commenter_artist: Artist,
222):
223 """test that the comment_id passed to background task matches the DB record."""
224 with patch(
225 "backend.api.tracks.comments.schedule_pds_create_comment"
226 ) as mock_schedule:
227 async with AsyncClient(
228 transport=ASGITransport(app=test_app), base_url="http://test"
229 ) as client:
230 await client.post(
231 f"/tracks/{test_track.id}/comments",
232 json={"text": "test comment", "timestamp_ms": 5000},
233 )
234
235 # get the comment_id from the scheduled call
236 call_kwargs = mock_schedule.call_args.kwargs
237 scheduled_comment_id = call_kwargs["comment_id"]
238
239 # verify it matches the DB record
240 result = await db_session.execute(
241 select(TrackComment).where(TrackComment.track_id == test_track.id)
242 )
243 comment = result.scalar_one()
244 assert comment.id == scheduled_comment_id
245
246
247async def test_create_comment_respects_limit(
248 test_app: FastAPI,
249 db_session: AsyncSession,
250 test_track: Track,
251 artist_with_comments_enabled: UserPreferences,
252):
253 """test that comment limit is enforced."""
254 # add 20 comments (the limit)
255 for i in range(20):
256 comment = TrackComment(
257 track_id=test_track.id,
258 user_did=f"did:test:user{i}",
259 text=f"comment {i}",
260 timestamp_ms=i * 1000,
261 atproto_comment_uri=f"at://did:test:user{i}/fm.plyr.comment/{i}",
262 )
263 db_session.add(comment)
264 await db_session.commit()
265
266 # try to add 21st comment
267 async with AsyncClient(
268 transport=ASGITransport(app=test_app), base_url="http://test"
269 ) as client:
270 response = await client.post(
271 f"/tracks/{test_track.id}/comments",
272 json={"text": "one more", "timestamp_ms": 21000},
273 )
274
275 assert response.status_code == 400
276 assert "maximum" in response.json()["detail"].lower()
277
278
279async def test_comments_ordered_by_timestamp(
280 test_app: FastAPI,
281 db_session: AsyncSession,
282 test_track: Track,
283 artist_with_comments_enabled: UserPreferences,
284):
285 """test that comments are returned ordered by timestamp."""
286 # add comments out of order
287 for timestamp in [30000, 10000, 50000, 20000]:
288 comment = TrackComment(
289 track_id=test_track.id,
290 user_did="did:test:user",
291 text=f"at {timestamp}",
292 timestamp_ms=timestamp,
293 atproto_comment_uri=f"at://did:test:user/fm.plyr.comment/{timestamp}",
294 )
295 db_session.add(comment)
296 await db_session.commit()
297
298 async with AsyncClient(
299 transport=ASGITransport(app=test_app), base_url="http://test"
300 ) as client:
301 response = await client.get(f"/tracks/{test_track.id}/comments")
302
303 assert response.status_code == 200
304 comments = response.json()["comments"]
305 timestamps = [c["timestamp_ms"] for c in comments]
306 assert timestamps == [10000, 20000, 30000, 50000]
307
308
309async def test_get_comments_track_not_found(test_app: FastAPI):
310 """test that 404 is returned for non-existent track."""
311 async with AsyncClient(
312 transport=ASGITransport(app=test_app), base_url="http://test"
313 ) as client:
314 response = await client.get("/tracks/99999/comments")
315
316 assert response.status_code == 404
317
318
319async def test_edit_comment_success(
320 test_app: FastAPI,
321 db_session: AsyncSession,
322 test_track: Track,
323 artist_with_comments_enabled: UserPreferences,
324 commenter_artist: Artist,
325):
326 """test that comment owner can edit their comment and background task is scheduled."""
327 comment = TrackComment(
328 track_id=test_track.id,
329 user_did="did:test:commenter123",
330 text="original text",
331 timestamp_ms=5000,
332 atproto_comment_uri="at://did:test:commenter123/fm.plyr.comment/edit1",
333 )
334 db_session.add(comment)
335 await db_session.commit()
336 await db_session.refresh(comment)
337
338 with patch(
339 "backend.api.tracks.comments.schedule_pds_update_comment"
340 ) as mock_schedule:
341 async with AsyncClient(
342 transport=ASGITransport(app=test_app), base_url="http://test"
343 ) as client:
344 response = await client.patch(
345 f"/tracks/comments/{comment.id}",
346 json={"text": "edited text"},
347 )
348
349 assert response.status_code == 200
350 data = response.json()
351 assert data["text"] == "edited text"
352 assert data["updated_at"] is not None
353
354 # verify background task was scheduled
355 mock_schedule.assert_called_once()
356 call_kwargs = mock_schedule.call_args.kwargs
357 assert call_kwargs["comment_uri"] == comment.atproto_comment_uri
358 assert call_kwargs["subject_uri"] == test_track.atproto_record_uri
359 assert call_kwargs["subject_cid"] == test_track.atproto_record_cid
360 assert call_kwargs["text"] == "edited text"
361 assert call_kwargs["timestamp_ms"] == 5000
362
363
364async def test_edit_comment_without_atproto_uri(
365 test_app: FastAPI,
366 db_session: AsyncSession,
367 test_track: Track,
368 artist_with_comments_enabled: UserPreferences,
369 commenter_artist: Artist,
370):
371 """test that editing a comment without ATProto URI doesn't schedule update."""
372 # comment without ATProto URI (e.g., background task hasn't run yet)
373 comment = TrackComment(
374 track_id=test_track.id,
375 user_did="did:test:commenter123",
376 text="original text",
377 timestamp_ms=5000,
378 atproto_comment_uri=None,
379 )
380 db_session.add(comment)
381 await db_session.commit()
382 await db_session.refresh(comment)
383
384 with patch(
385 "backend.api.tracks.comments.schedule_pds_update_comment"
386 ) as mock_schedule:
387 async with AsyncClient(
388 transport=ASGITransport(app=test_app), base_url="http://test"
389 ) as client:
390 response = await client.patch(
391 f"/tracks/comments/{comment.id}",
392 json={"text": "edited text"},
393 )
394
395 assert response.status_code == 200
396 data = response.json()
397 assert data["text"] == "edited text"
398
399 # no PDS update should be scheduled since there's no ATProto record
400 mock_schedule.assert_not_called()
401
402
403async def test_edit_comment_forbidden_for_other_user(
404 test_app: FastAPI,
405 db_session: AsyncSession,
406 test_track: Track,
407 artist_with_comments_enabled: UserPreferences,
408):
409 """test that non-owner cannot edit comment."""
410 comment = TrackComment(
411 track_id=test_track.id,
412 user_did="did:plc:other",
413 text="someone else's comment",
414 timestamp_ms=5000,
415 atproto_comment_uri="at://did:plc:other/fm.plyr.comment/other1",
416 )
417 db_session.add(comment)
418 await db_session.commit()
419 await db_session.refresh(comment)
420
421 async with AsyncClient(
422 transport=ASGITransport(app=test_app), base_url="http://test"
423 ) as client:
424 response = await client.patch(
425 f"/tracks/comments/{comment.id}",
426 json={"text": "trying to edit"},
427 )
428
429 assert response.status_code == 403
430 assert "own" in response.json()["detail"]
431
432
433async def test_delete_comment_success(
434 test_app: FastAPI,
435 db_session: AsyncSession,
436 test_track: Track,
437 artist_with_comments_enabled: UserPreferences,
438 commenter_artist: Artist,
439):
440 """test that comment owner can delete their comment and background task is scheduled."""
441 comment = TrackComment(
442 track_id=test_track.id,
443 user_did="did:test:commenter123",
444 text="to be deleted",
445 timestamp_ms=5000,
446 atproto_comment_uri="at://did:test:commenter123/fm.plyr.comment/del1",
447 )
448 db_session.add(comment)
449 await db_session.commit()
450 await db_session.refresh(comment)
451 comment_id = comment.id
452
453 with patch(
454 "backend.api.tracks.comments.schedule_pds_delete_comment"
455 ) as mock_schedule:
456 async with AsyncClient(
457 transport=ASGITransport(app=test_app), base_url="http://test"
458 ) as client:
459 response = await client.delete(f"/tracks/comments/{comment_id}")
460
461 assert response.status_code == 200
462 assert response.json()["deleted"] is True
463
464 # verify background task was scheduled with correct URI
465 mock_schedule.assert_called_once()
466 call_kwargs = mock_schedule.call_args.kwargs
467 assert (
468 call_kwargs["comment_uri"] == "at://did:test:commenter123/fm.plyr.comment/del1"
469 )
470
471 # verify DB entry is gone (deleted immediately, before PDS)
472 result = await db_session.execute(
473 select(TrackComment).where(TrackComment.id == comment_id)
474 )
475 assert result.scalar_one_or_none() is None
476
477
478async def test_delete_comment_without_atproto_uri(
479 test_app: FastAPI,
480 db_session: AsyncSession,
481 test_track: Track,
482 artist_with_comments_enabled: UserPreferences,
483 commenter_artist: Artist,
484):
485 """test that deleting a comment without ATProto URI doesn't schedule deletion."""
486 # comment without ATProto URI (e.g., background task hasn't run yet)
487 comment = TrackComment(
488 track_id=test_track.id,
489 user_did="did:test:commenter123",
490 text="to be deleted",
491 timestamp_ms=5000,
492 atproto_comment_uri=None,
493 )
494 db_session.add(comment)
495 await db_session.commit()
496 await db_session.refresh(comment)
497 comment_id = comment.id
498
499 with patch(
500 "backend.api.tracks.comments.schedule_pds_delete_comment"
501 ) as mock_schedule:
502 async with AsyncClient(
503 transport=ASGITransport(app=test_app), base_url="http://test"
504 ) as client:
505 response = await client.delete(f"/tracks/comments/{comment_id}")
506
507 assert response.status_code == 200
508 assert response.json()["deleted"] is True
509
510 # no PDS deletion should be scheduled since there's no ATProto record
511 mock_schedule.assert_not_called()
512
513 # verify DB entry is still gone
514 result = await db_session.execute(
515 select(TrackComment).where(TrackComment.id == comment_id)
516 )
517 assert result.scalar_one_or_none() is None