perf: move ATProto sync to login callback (#505)

* perf: defer ATProto sync queries to background task

- move all sync-related DB queries out of /artists/me request path
- queries for albums, tracks, prefs, and likes now run in background
- reduces response time from ~1.2s to ~300ms (only artist lookup needed)

also fix: add trailing slash to playlist search URL (fixes 307 redirect)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: move ATProto sync from /artists/me to login callback

- create sync_atproto_records() function in records.py
- call sync as fire-and-forget background task after OAuth callback
- remove all sync logic from /artists/me (now just returns artist)
- also sync on scope upgrade flow
- update tests to verify new behavior

this ensures ATProto records sync immediately on login rather than
on profile page access, and removes unnecessary work from /artists/me

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub da7d9540 19ae79fb

Changed files
+311 -214
backend
src
backend
_internal
api
tests
frontend
src
routes
playlist
+2
backend/src/backend/_internal/atproto/__init__.py
··· 11 11 create_list_record, 12 12 create_track_record, 13 13 delete_record_by_uri, 14 + sync_atproto_records, 14 15 update_comment_record, 15 16 update_list_record, 16 17 upsert_album_list_record, ··· 27 28 "fetch_user_avatar", 28 29 "fetch_user_profile", 29 30 "normalize_avatar_url", 31 + "sync_atproto_records", 30 32 "update_comment_record", 31 33 "update_list_record", 32 34 "upsert_album_list_record",
+124
backend/src/backend/_internal/atproto/records.py
··· 7 7 from typing import Any 8 8 9 9 from atproto_oauth.models import OAuthSession 10 + from sqlalchemy import select 10 11 11 12 from backend._internal import Session as AuthSession 12 13 from backend._internal import get_oauth_client, get_session, update_session_tokens ··· 839 840 ) 840 841 logger.info(f"created liked list record for {auth_session.did}: {uri}") 841 842 return uri, cid 843 + 844 + 845 + async def sync_atproto_records( 846 + auth_session: AuthSession, 847 + user_did: str, 848 + ) -> None: 849 + """sync profile, albums, and liked tracks to ATProto. 850 + 851 + this is the actual sync logic - runs all queries and PDS calls. 852 + should be called from a background task to avoid blocking. 853 + """ 854 + from backend.models import Album, Artist, Track, TrackLike, UserPreferences 855 + from backend.utilities.database import db_session 856 + 857 + # sync profile record 858 + async with db_session() as session: 859 + artist_result = await session.execute( 860 + select(Artist).where(Artist.did == user_did) 861 + ) 862 + artist = artist_result.scalar_one_or_none() 863 + artist_bio = artist.bio if artist else None 864 + 865 + if artist_bio is not None or artist: 866 + try: 867 + profile_result = await upsert_profile_record(auth_session, bio=artist_bio) 868 + if profile_result: 869 + logger.info(f"synced ATProto profile record for {user_did}") 870 + except Exception as e: 871 + logger.warning(f"failed to sync ATProto profile record for {user_did}: {e}") 872 + 873 + # query and sync album list records 874 + async with db_session() as session: 875 + albums_result = await session.execute( 876 + select(Album).where(Album.artist_did == user_did) 877 + ) 878 + albums = albums_result.scalars().all() 879 + 880 + for album in albums: 881 + tracks_result = await session.execute( 882 + select(Track) 883 + .where( 884 + Track.album_id == album.id, 885 + Track.atproto_record_uri.isnot(None), 886 + Track.atproto_record_cid.isnot(None), 887 + ) 888 + .order_by(Track.created_at.asc()) 889 + ) 890 + tracks = tracks_result.scalars().all() 891 + 892 + if tracks: 893 + track_refs = [ 894 + {"uri": t.atproto_record_uri, "cid": t.atproto_record_cid} 895 + for t in tracks 896 + ] 897 + try: 898 + album_result = await upsert_album_list_record( 899 + auth_session, 900 + album_id=album.id, 901 + album_title=album.title, 902 + track_refs=track_refs, 903 + existing_uri=album.atproto_record_uri, 904 + ) 905 + if album_result: 906 + album.atproto_record_uri = album_result[0] 907 + album.atproto_record_cid = album_result[1] 908 + await session.commit() 909 + logger.info( 910 + f"synced album list record for {album.id}: {album_result[0]}" 911 + ) 912 + except Exception as e: 913 + logger.warning( 914 + f"failed to sync album list record for {album.id}: {e}" 915 + ) 916 + 917 + # query and sync liked tracks list record 918 + async with db_session() as session: 919 + prefs_result = await session.execute( 920 + select(UserPreferences).where(UserPreferences.did == user_did) 921 + ) 922 + prefs = prefs_result.scalar_one_or_none() 923 + 924 + likes_result = await session.execute( 925 + select(Track) 926 + .join(TrackLike, TrackLike.track_id == Track.id) 927 + .where( 928 + TrackLike.user_did == user_did, 929 + Track.atproto_record_uri.isnot(None), 930 + Track.atproto_record_cid.isnot(None), 931 + ) 932 + .order_by(TrackLike.created_at.desc()) 933 + ) 934 + liked_tracks = likes_result.scalars().all() 935 + 936 + if liked_tracks: 937 + liked_refs = [ 938 + {"uri": t.atproto_record_uri, "cid": t.atproto_record_cid} 939 + for t in liked_tracks 940 + ] 941 + existing_liked_uri = prefs.liked_list_uri if prefs else None 942 + 943 + try: 944 + liked_result = await upsert_liked_list_record( 945 + auth_session, 946 + track_refs=liked_refs, 947 + existing_uri=existing_liked_uri, 948 + ) 949 + if liked_result: 950 + if prefs: 951 + prefs.liked_list_uri = liked_result[0] 952 + prefs.liked_list_cid = liked_result[1] 953 + else: 954 + prefs = UserPreferences( 955 + did=user_did, 956 + liked_list_uri=liked_result[0], 957 + liked_list_cid=liked_result[1], 958 + ) 959 + session.add(prefs) 960 + await session.commit() 961 + logger.info( 962 + f"synced liked list record for {user_did}: {liked_result[0]}" 963 + ) 964 + except Exception as e: 965 + logger.warning(f"failed to sync liked list record for {user_did}: {e}")
+1 -142
backend/src/backend/api/artists.py
··· 1 1 """artist profile API endpoints.""" 2 2 3 - import asyncio 4 3 import logging 5 4 from datetime import UTC, datetime 6 5 from typing import Annotated ··· 14 13 from backend._internal.atproto import ( 15 14 fetch_user_avatar, 16 15 normalize_avatar_url, 17 - upsert_album_list_record, 18 - upsert_liked_list_record, 19 16 upsert_profile_record, 20 17 ) 21 - from backend.models import Album, Artist, Track, TrackLike, UserPreferences, get_db 22 - from backend.utilities.database import db_session 18 + from backend.models import Artist, Track, TrackLike, UserPreferences, get_db 23 19 24 20 logger = logging.getLogger(__name__) 25 21 26 22 router = APIRouter(prefix="/artists", tags=["artists"]) 27 - 28 - # hold references to background tasks to prevent GC before completion 29 - _background_tasks: set[asyncio.Task[None]] = set() 30 - 31 - 32 - def _create_background_task(coro) -> asyncio.Task: 33 - """Create a background task with proper lifecycle management.""" 34 - task = asyncio.create_task(coro) 35 - _background_tasks.add(task) 36 - task.add_done_callback(_background_tasks.discard) 37 - return task 38 23 39 24 40 25 # request/response models ··· 171 156 status_code=404, 172 157 detail="artist profile not found - please create one first", 173 158 ) 174 - 175 - # fire-and-forget sync of ATProto profile record 176 - # creates record if doesn't exist, skips if unchanged 177 - async def _sync_profile(): 178 - try: 179 - result = await upsert_profile_record(auth_session, bio=artist.bio) 180 - if result: 181 - logger.info(f"synced ATProto profile record for {auth_session.did}") 182 - except Exception as e: 183 - logger.warning( 184 - f"failed to sync ATProto profile record for {auth_session.did}: {e}" 185 - ) 186 - 187 - _create_background_task(_sync_profile()) 188 - 189 - # fire-and-forget sync of album list records 190 - # query albums and their tracks, then sync each album as a list record 191 - albums_result = await db.execute( 192 - select(Album).where(Album.artist_did == auth_session.did) 193 - ) 194 - albums = albums_result.scalars().all() 195 - 196 - for album in albums: 197 - # get tracks for this album that have ATProto records 198 - tracks_result = await db.execute( 199 - select(Track) 200 - .where( 201 - Track.album_id == album.id, 202 - Track.atproto_record_uri.isnot(None), 203 - Track.atproto_record_cid.isnot(None), 204 - ) 205 - .order_by(Track.created_at.asc()) 206 - ) 207 - tracks = tracks_result.scalars().all() 208 - 209 - if tracks: 210 - track_refs = [ 211 - {"uri": t.atproto_record_uri, "cid": t.atproto_record_cid} 212 - for t in tracks 213 - ] 214 - # capture values for closure 215 - album_id = album.id 216 - album_title = album.title 217 - existing_uri = album.atproto_record_uri 218 - 219 - async def _sync_album( 220 - aid=album_id, title=album_title, refs=track_refs, uri=existing_uri 221 - ): 222 - try: 223 - result = await upsert_album_list_record( 224 - auth_session, 225 - album_id=aid, 226 - album_title=title, 227 - track_refs=refs, 228 - existing_uri=uri, 229 - ) 230 - if result: 231 - # persist the new URI/CID to the database 232 - async with db_session() as session: 233 - album_to_update = await session.get(Album, aid) 234 - if album_to_update: 235 - album_to_update.atproto_record_uri = result[0] 236 - album_to_update.atproto_record_cid = result[1] 237 - await session.commit() 238 - logger.info(f"synced album list record for {aid}: {result[0]}") 239 - except Exception as e: 240 - logger.warning(f"failed to sync album list record for {aid}: {e}") 241 - 242 - _create_background_task(_sync_album()) 243 - 244 - # fire-and-forget sync of liked tracks list record 245 - # query user's likes and sync as a single list record 246 - prefs_result = await db.execute( 247 - select(UserPreferences).where(UserPreferences.did == auth_session.did) 248 - ) 249 - prefs = prefs_result.scalar_one_or_none() 250 - 251 - likes_result = await db.execute( 252 - select(Track) 253 - .join(TrackLike, TrackLike.track_id == Track.id) 254 - .where( 255 - TrackLike.user_did == auth_session.did, 256 - Track.atproto_record_uri.isnot(None), 257 - Track.atproto_record_cid.isnot(None), 258 - ) 259 - .order_by(TrackLike.created_at.desc()) 260 - ) 261 - liked_tracks = likes_result.scalars().all() 262 - 263 - if liked_tracks: 264 - liked_refs = [ 265 - {"uri": t.atproto_record_uri, "cid": t.atproto_record_cid} 266 - for t in liked_tracks 267 - ] 268 - existing_liked_uri = prefs.liked_list_uri if prefs else None 269 - user_did = auth_session.did 270 - 271 - async def _sync_liked(): 272 - try: 273 - result = await upsert_liked_list_record( 274 - auth_session, 275 - track_refs=liked_refs, 276 - existing_uri=existing_liked_uri, 277 - ) 278 - if result: 279 - # persist the new URI/CID to user preferences 280 - async with db_session() as session: 281 - user_prefs = await session.get(UserPreferences, user_did) 282 - if user_prefs: 283 - user_prefs.liked_list_uri = result[0] 284 - user_prefs.liked_list_cid = result[1] 285 - await session.commit() 286 - else: 287 - # create preferences if they don't exist 288 - new_prefs = UserPreferences( 289 - did=user_did, 290 - liked_list_uri=result[0], 291 - liked_list_cid=result[1], 292 - ) 293 - session.add(new_prefs) 294 - await session.commit() 295 - logger.info(f"synced liked list record for {user_did}: {result[0]}") 296 - except Exception as e: 297 - logger.warning(f"failed to sync liked list record for {user_did}: {e}") 298 - 299 - _create_background_task(_sync_liked()) 300 159 301 160 return ArtistResponse.model_validate(artist) 302 161
+49
backend/src/backend/api/auth.py
··· 1 1 """authentication api endpoints.""" 2 2 3 + import asyncio 4 + import logging 3 5 from typing import Annotated 4 6 5 7 from fastapi import APIRouter, Depends, HTTPException, Query, Request ··· 27 29 start_oauth_flow, 28 30 start_oauth_flow_with_scopes, 29 31 ) 32 + from backend._internal.atproto import sync_atproto_records 30 33 from backend.config import settings 31 34 from backend.utilities.rate_limit import limiter 35 + 36 + logger = logging.getLogger(__name__) 37 + 38 + # hold references to background tasks to prevent GC before completion 39 + _background_tasks: set[asyncio.Task[None]] = set() 40 + 41 + 42 + def _create_background_task(coro) -> asyncio.Task: 43 + """create a background task with proper lifecycle management.""" 44 + task = asyncio.create_task(coro) 45 + _background_tasks.add(task) 46 + task.add_done_callback(_background_tasks.discard) 47 + return task 48 + 32 49 33 50 router = APIRouter(prefix="/auth", tags=["auth"]) 34 51 ··· 142 159 # create exchange token - NOT marked as dev token so cookie gets set 143 160 exchange_token = await create_exchange_token(session_id) 144 161 162 + # fire-and-forget: sync ATProto records with new scopes 163 + auth_session = Session( 164 + session_id=session_id, 165 + did=did, 166 + handle=handle, 167 + oauth_session=oauth_session, 168 + ) 169 + 170 + async def _sync_on_scope_upgrade(): 171 + try: 172 + await sync_atproto_records(auth_session, did) 173 + except Exception as e: 174 + logger.error(f"background sync failed for {did}: {e}", exc_info=True) 175 + 176 + _create_background_task(_sync_on_scope_upgrade()) 177 + 145 178 return RedirectResponse( 146 179 url=f"{settings.frontend.url}/settings?exchange_token={exchange_token}&scope_upgraded=true", 147 180 status_code=303, ··· 155 188 156 189 # check if artist profile exists 157 190 has_profile = await check_artist_profile_exists(did) 191 + 192 + # fire-and-forget: sync ATProto records on login 193 + auth_session = Session( 194 + session_id=session_id, 195 + did=did, 196 + handle=handle, 197 + oauth_session=oauth_session, 198 + ) 199 + 200 + async def _sync_on_login(): 201 + try: 202 + await sync_atproto_records(auth_session, did) 203 + except Exception as e: 204 + logger.error(f"background sync failed for {did}: {e}", exc_info=True) 205 + 206 + _create_background_task(_sync_on_login()) 158 207 159 208 # redirect to profile setup if needed, otherwise to portal 160 209 redirect_path = "/portal" if has_profile else "/profile/setup"
+134 -71
backend/tests/api/test_list_record_sync.py
··· 144 144 return tracks 145 145 146 146 147 - async def test_get_profile_syncs_albums( 148 - test_app: FastAPI, 147 + async def test_sync_atproto_records_syncs_albums( 149 148 db_session: AsyncSession, 150 149 test_artist: Artist, 151 150 test_album_with_tracks: tuple[Album, list[Track]], 152 151 ): 153 - """test that GET /artists/me triggers album list record sync.""" 152 + """test that sync_atproto_records syncs album list records.""" 153 + from backend._internal.atproto import sync_atproto_records 154 + 154 155 album, _ = test_album_with_tracks 156 + mock_session = MockSession(did="did:plc:testartist123") 155 157 156 158 with ( 157 159 patch( 158 - "backend.api.artists.upsert_profile_record", 160 + "backend._internal.atproto.records.upsert_profile_record", 159 161 new_callable=AsyncMock, 160 162 return_value=None, 161 163 ), 162 164 patch( 163 - "backend.api.artists.upsert_album_list_record", 165 + "backend._internal.atproto.records.upsert_album_list_record", 164 166 new_callable=AsyncMock, 165 167 return_value=( 166 168 "at://did:plc:testartist123/fm.plyr.list/album123", ··· 168 170 ), 169 171 ) as mock_album_sync, 170 172 patch( 171 - "backend.api.artists.upsert_liked_list_record", 173 + "backend._internal.atproto.records.upsert_liked_list_record", 172 174 new_callable=AsyncMock, 173 175 return_value=None, 174 176 ), 175 177 ): 176 - async with AsyncClient( 177 - transport=ASGITransport(app=test_app), base_url="http://test" 178 - ) as client: 179 - response = await client.get("/artists/me") 180 - 181 - # give background tasks time to run 182 - await asyncio.sleep(0.1) 183 - 184 - assert response.status_code == 200 178 + await sync_atproto_records(mock_session, "did:plc:testartist123") 185 179 186 180 # verify album sync was called with correct track refs 187 181 mock_album_sync.assert_called_once() ··· 191 185 assert len(call_args.kwargs["track_refs"]) == 3 192 186 193 187 194 - async def test_get_profile_syncs_liked_list( 195 - test_app: FastAPI, 188 + async def test_sync_atproto_records_syncs_liked_list( 196 189 db_session: AsyncSession, 197 190 test_artist: Artist, 198 191 test_liked_tracks: list[Track], 199 192 ): 200 - """test that GET /artists/me triggers liked list record sync.""" 193 + """test that sync_atproto_records syncs liked list record.""" 194 + from backend._internal.atproto import sync_atproto_records 195 + 196 + mock_session = MockSession(did="did:plc:testartist123") 197 + 201 198 with ( 202 199 patch( 203 - "backend.api.artists.upsert_profile_record", 200 + "backend._internal.atproto.records.upsert_profile_record", 204 201 new_callable=AsyncMock, 205 202 return_value=None, 206 203 ), 207 204 patch( 208 - "backend.api.artists.upsert_album_list_record", 205 + "backend._internal.atproto.records.upsert_album_list_record", 209 206 new_callable=AsyncMock, 210 207 return_value=None, 211 208 ), 212 209 patch( 213 - "backend.api.artists.upsert_liked_list_record", 210 + "backend._internal.atproto.records.upsert_liked_list_record", 214 211 new_callable=AsyncMock, 215 212 return_value=( 216 213 "at://did:plc:testartist123/fm.plyr.list/liked456", ··· 218 215 ), 219 216 ) as mock_liked_sync, 220 217 ): 221 - async with AsyncClient( 222 - transport=ASGITransport(app=test_app), base_url="http://test" 223 - ) as client: 224 - response = await client.get("/artists/me") 225 - 226 - # give background tasks time to run 227 - await asyncio.sleep(0.1) 228 - 229 - assert response.status_code == 200 218 + await sync_atproto_records(mock_session, "did:plc:testartist123") 230 219 231 220 # verify liked sync was called with correct track refs 232 221 mock_liked_sync.assert_called_once() ··· 234 223 assert len(call_args.kwargs["track_refs"]) == 2 235 224 236 225 237 - async def test_get_profile_skips_albums_without_atproto_tracks( 238 - test_app: FastAPI, db_session: AsyncSession, test_artist: Artist 226 + async def test_sync_atproto_records_skips_albums_without_atproto_tracks( 227 + db_session: AsyncSession, test_artist: Artist 239 228 ): 240 229 """test that albums with no ATProto-enabled tracks are skipped.""" 230 + from backend._internal.atproto import sync_atproto_records 231 + 241 232 # create album with tracks that have no ATProto records 242 233 album = Album( 243 234 artist_did=test_artist.did, ··· 259 250 db_session.add(track) 260 251 await db_session.commit() 261 252 253 + mock_session = MockSession(did="did:plc:testartist123") 254 + 262 255 with ( 263 256 patch( 264 - "backend.api.artists.upsert_profile_record", 257 + "backend._internal.atproto.records.upsert_profile_record", 265 258 new_callable=AsyncMock, 266 259 return_value=None, 267 260 ), 268 261 patch( 269 - "backend.api.artists.upsert_album_list_record", 262 + "backend._internal.atproto.records.upsert_album_list_record", 270 263 new_callable=AsyncMock, 271 264 ) as mock_album_sync, 272 265 patch( 273 - "backend.api.artists.upsert_liked_list_record", 266 + "backend._internal.atproto.records.upsert_liked_list_record", 274 267 new_callable=AsyncMock, 275 268 ), 276 269 ): 277 - async with AsyncClient( 278 - transport=ASGITransport(app=test_app), base_url="http://test" 279 - ) as client: 280 - response = await client.get("/artists/me") 281 - 282 - await asyncio.sleep(0.1) 283 - 284 - assert response.status_code == 200 270 + await sync_atproto_records(mock_session, "did:plc:testartist123") 285 271 286 272 # album sync should NOT be called for albums without ATProto tracks 287 273 mock_album_sync.assert_not_called() 288 274 289 275 290 - async def test_get_profile_continues_on_album_sync_failure( 291 - test_app: FastAPI, 276 + async def test_sync_atproto_records_continues_on_album_sync_failure( 292 277 db_session: AsyncSession, 293 278 test_artist: Artist, 294 279 test_album_with_tracks: tuple[Album, list[Track]], 280 + test_liked_tracks: list[Track], 295 281 ): 296 - """test that profile fetch succeeds even if album sync fails.""" 282 + """test that sync continues even if album sync fails.""" 283 + from backend._internal.atproto import sync_atproto_records 284 + 285 + mock_session = MockSession(did="did:plc:testartist123") 286 + 297 287 with ( 298 288 patch( 299 - "backend.api.artists.upsert_profile_record", 289 + "backend._internal.atproto.records.upsert_profile_record", 300 290 new_callable=AsyncMock, 301 291 return_value=None, 302 292 ), 303 293 patch( 304 - "backend.api.artists.upsert_album_list_record", 294 + "backend._internal.atproto.records.upsert_album_list_record", 305 295 side_effect=Exception("PDS error"), 306 296 ), 307 297 patch( 308 - "backend.api.artists.upsert_liked_list_record", 298 + "backend._internal.atproto.records.upsert_liked_list_record", 309 299 new_callable=AsyncMock, 310 300 return_value=None, 311 - ), 301 + ) as mock_liked_sync, 312 302 ): 313 - async with AsyncClient( 314 - transport=ASGITransport(app=test_app), base_url="http://test" 315 - ) as client: 316 - response = await client.get("/artists/me") 303 + # should not raise 304 + await sync_atproto_records(mock_session, "did:plc:testartist123") 305 + 306 + # liked sync should still be attempted (user has liked tracks) 307 + mock_liked_sync.assert_called_once() 308 + 309 + 310 + async def test_sync_atproto_records_continues_on_liked_sync_failure( 311 + db_session: AsyncSession, 312 + test_artist: Artist, 313 + test_liked_tracks: list[Track], 314 + ): 315 + """test that sync completes even if liked sync fails.""" 316 + from backend._internal.atproto import sync_atproto_records 317 317 318 - await asyncio.sleep(0.1) 318 + mock_session = MockSession(did="did:plc:testartist123") 319 319 320 - # request should still succeed 321 - assert response.status_code == 200 322 - data = response.json() 323 - assert data["did"] == "did:plc:testartist123" 320 + with ( 321 + patch( 322 + "backend._internal.atproto.records.upsert_profile_record", 323 + new_callable=AsyncMock, 324 + return_value=None, 325 + ), 326 + patch( 327 + "backend._internal.atproto.records.upsert_album_list_record", 328 + new_callable=AsyncMock, 329 + return_value=None, 330 + ), 331 + patch( 332 + "backend._internal.atproto.records.upsert_liked_list_record", 333 + side_effect=Exception("PDS error"), 334 + ), 335 + ): 336 + # should not raise 337 + await sync_atproto_records(mock_session, "did:plc:testartist123") 324 338 325 339 326 - async def test_get_profile_continues_on_liked_sync_failure( 340 + async def test_login_callback_triggers_background_sync( 327 341 test_app: FastAPI, 328 342 db_session: AsyncSession, 329 343 test_artist: Artist, 330 - test_liked_tracks: list[Track], 331 344 ): 332 - """test that profile fetch succeeds even if liked sync fails.""" 345 + """test that OAuth callback triggers ATProto sync in background.""" 346 + mock_oauth_session = { 347 + "did": "did:plc:testartist123", 348 + "handle": "testartist.bsky.social", 349 + "pds_url": "https://test.pds", 350 + "authserver_iss": "https://auth.test", 351 + "scope": "atproto transition:generic", 352 + "access_token": "test_token", 353 + "refresh_token": "test_refresh", 354 + "dpop_private_key_pem": "fake_key", 355 + "dpop_authserver_nonce": "", 356 + "dpop_pds_nonce": "", 357 + } 358 + 333 359 with ( 334 360 patch( 335 - "backend.api.artists.upsert_profile_record", 361 + "backend.api.auth.handle_oauth_callback", 362 + new_callable=AsyncMock, 363 + return_value=( 364 + "did:plc:testartist123", 365 + "testartist.bsky.social", 366 + mock_oauth_session, 367 + ), 368 + ), 369 + patch( 370 + "backend.api.auth.get_pending_dev_token", 336 371 new_callable=AsyncMock, 337 372 return_value=None, 338 373 ), 339 374 patch( 340 - "backend.api.artists.upsert_album_list_record", 375 + "backend.api.auth.get_pending_scope_upgrade", 341 376 new_callable=AsyncMock, 342 377 return_value=None, 343 378 ), 344 379 patch( 345 - "backend.api.artists.upsert_liked_list_record", 346 - side_effect=Exception("PDS error"), 380 + "backend.api.auth.create_session", 381 + new_callable=AsyncMock, 382 + return_value="test_session_id", 347 383 ), 384 + patch( 385 + "backend.api.auth.create_exchange_token", 386 + new_callable=AsyncMock, 387 + return_value="test_exchange_token", 388 + ), 389 + patch( 390 + "backend.api.auth.check_artist_profile_exists", 391 + new_callable=AsyncMock, 392 + return_value=True, 393 + ), 394 + patch( 395 + "backend.api.auth.sync_atproto_records", 396 + new_callable=AsyncMock, 397 + ) as mock_sync, 348 398 ): 349 399 async with AsyncClient( 350 400 transport=ASGITransport(app=test_app), base_url="http://test" 351 401 ) as client: 352 - response = await client.get("/artists/me") 402 + response = await client.get( 403 + "/auth/callback", 404 + params={ 405 + "code": "test_code", 406 + "state": "test_state", 407 + "iss": "https://auth.test", 408 + }, 409 + follow_redirects=False, 410 + ) 353 411 412 + # give background tasks time to run 354 413 await asyncio.sleep(0.1) 355 414 356 - # request should still succeed 357 - assert response.status_code == 200 358 - data = response.json() 359 - assert data["did"] == "did:plc:testartist123" 415 + assert response.status_code == 303 416 + assert "exchange_token=test_exchange_token" in response.headers["location"] 417 + 418 + # verify sync was triggered in background 419 + mock_sync.assert_called_once() 420 + call_args = mock_sync.call_args 421 + # first arg is the session, second is the DID 422 + assert call_args[0][1] == "did:plc:testartist123"
+1 -1
frontend/src/routes/playlist/[id]/+page.svelte
··· 87 87 searchError = ''; 88 88 89 89 try { 90 - const response = await fetch(`${API_URL}/search?q=${encodeURIComponent(searchQuery)}&type=tracks&limit=10`, { 90 + const response = await fetch(`${API_URL}/search/?q=${encodeURIComponent(searchQuery)}&type=tracks&limit=10`, { 91 91 credentials: 'include' 92 92 }); 93 93