at main 17 kB view raw
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