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