audio streaming app
plyr.fm
1"""tests for album API helpers."""
2
3from collections.abc import Generator
4from unittest.mock import AsyncMock, 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
13from backend.api.albums import list_artist_albums
14from backend.main import app
15from backend.models import Album, Artist, Track
16
17
18class MockSession(Session):
19 """mock session for auth bypass in tests."""
20
21 def __init__(self, did: str = "did:test:user123"):
22 self.did = did
23 self.handle = "testuser.bsky.social"
24 self.session_id = "test_session_id"
25 self.access_token = "test_token"
26 self.refresh_token = "test_refresh"
27 self.oauth_session = {
28 "did": did,
29 "handle": "testuser.bsky.social",
30 "pds_url": "https://test.pds",
31 "authserver_iss": "https://auth.test",
32 "scope": "atproto transition:generic",
33 "access_token": "test_token",
34 "refresh_token": "test_refresh",
35 "dpop_private_key_pem": "fake_key",
36 "dpop_authserver_nonce": "",
37 "dpop_pds_nonce": "",
38 }
39
40
41@pytest.fixture
42def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]:
43 """create test app with mocked auth."""
44 from backend._internal import require_artist_profile, require_auth
45
46 async def mock_require_auth() -> Session:
47 return MockSession()
48
49 async def mock_require_artist_profile() -> Session:
50 return MockSession()
51
52 app.dependency_overrides[require_auth] = mock_require_auth
53 app.dependency_overrides[require_artist_profile] = mock_require_artist_profile
54
55 yield app
56
57 app.dependency_overrides.clear()
58
59
60async def test_list_artist_albums_groups_tracks(db_session: AsyncSession):
61 """albums listing groups tracks per slug and aggregates counts."""
62 artist = Artist(
63 did="did:plc:testartist",
64 handle="artist.test",
65 display_name="Artist Test",
66 bio=None,
67 avatar_url="https://example.com/avatar.jpg",
68 )
69 db_session.add(artist)
70 await db_session.commit()
71
72 # create albums first
73 album_a = Album(
74 artist_did=artist.did,
75 slug="album-a",
76 title="Album A",
77 image_url="https://example.com/a.jpg",
78 )
79 album_b = Album(
80 artist_did=artist.did,
81 slug="album-b",
82 title="Album B",
83 image_url="https://example.com/b.jpg",
84 )
85 db_session.add_all([album_a, album_b])
86 await db_session.flush()
87
88 # create tracks linked to albums
89 album_tracks = [
90 Track(
91 title="Song A1",
92 file_id="file-a1",
93 file_type="mp3",
94 artist_did=artist.did,
95 album_id=album_a.id,
96 extra={"album": "Album A"},
97 play_count=5,
98 ),
99 Track(
100 title="Song A2",
101 file_id="file-a2",
102 file_type="mp3",
103 artist_did=artist.did,
104 album_id=album_a.id,
105 extra={"album": "Album A"},
106 play_count=3,
107 ),
108 Track(
109 title="Song B1",
110 file_id="file-b1",
111 file_type="mp3",
112 artist_did=artist.did,
113 album_id=album_b.id,
114 extra={"album": "Album B"},
115 play_count=2,
116 ),
117 ]
118
119 db_session.add_all(album_tracks)
120 await db_session.commit()
121
122 response = await list_artist_albums(artist.handle, db_session)
123 albums = response["albums"]
124
125 assert len(albums) == 2
126 first = next(album for album in albums if album.slug == "album-a")
127 assert first.track_count == 2
128 assert first.total_plays == 8
129 assert first.image_url == "https://example.com/a.jpg"
130
131 second = next(album for album in albums if album.slug == "album-b")
132 assert second.track_count == 1
133 assert second.total_plays == 2
134
135
136async def test_get_album_serializes_tracks_correctly(
137 test_app: FastAPI, db_session: AsyncSession
138):
139 """test that get_album properly serializes tracks with album data."""
140 # create artist
141 artist = Artist(
142 did="did:test:user123",
143 handle="test.artist",
144 display_name="Test Artist",
145 )
146 db_session.add(artist)
147 await db_session.flush()
148
149 # create album
150 album = Album(
151 artist_did=artist.did,
152 slug="test-album",
153 title="Test Album",
154 image_url="https://example.com/album.jpg",
155 )
156 db_session.add(album)
157 await db_session.flush()
158
159 # create tracks linked to album
160 track1 = Track(
161 title="Track 1",
162 file_id="test-file-1",
163 file_type="audio/mpeg",
164 artist_did=artist.did,
165 album_id=album.id,
166 play_count=5,
167 )
168 track2 = Track(
169 title="Track 2",
170 file_id="test-file-2",
171 file_type="audio/mpeg",
172 artist_did=artist.did,
173 album_id=album.id,
174 play_count=3,
175 )
176 db_session.add_all([track1, track2])
177 await db_session.commit()
178
179 # fetch album via API
180 async with AsyncClient(
181 transport=ASGITransport(app=test_app), base_url="http://test"
182 ) as client:
183 response = await client.get(f"/albums/{artist.handle}/{album.slug}")
184
185 assert response.status_code == 200
186 data = response.json()
187
188 # verify album metadata
189 assert data["metadata"]["id"] == album.id
190 assert data["metadata"]["title"] == "Test Album"
191 assert data["metadata"]["slug"] == "test-album"
192 assert data["metadata"]["artist"] == "Test Artist"
193 assert data["metadata"]["artist_handle"] == "test.artist"
194 assert data["metadata"]["track_count"] == 2
195 assert data["metadata"]["total_plays"] == 8
196
197 # verify tracks are properly serialized as dicts
198 assert len(data["tracks"]) == 2
199 assert isinstance(data["tracks"][0], dict)
200 assert data["tracks"][0]["title"] == "Track 1"
201 assert data["tracks"][0]["artist"] == "Test Artist"
202 assert data["tracks"][0]["file_id"] == "test-file-1"
203 assert data["tracks"][0]["play_count"] == 5
204
205 # verify album data is included in tracks
206 assert data["tracks"][0]["album"] is not None
207 assert data["tracks"][0]["album"]["id"] == album.id
208 assert data["tracks"][0]["album"]["slug"] == "test-album"
209 assert data["tracks"][0]["album"]["title"] == "Test Album"
210
211
212async def test_delete_album_orphans_tracks_by_default(
213 test_app: FastAPI, db_session: AsyncSession
214):
215 """test that deleting album orphans tracks (sets album_id to null).
216
217 regression test for user report: unable to delete empty album after
218 deleting individual tracks.
219 """
220 # create artist matching mock session
221 artist = Artist(
222 did="did:test:user123",
223 handle="test.artist",
224 display_name="Test Artist",
225 )
226 db_session.add(artist)
227 await db_session.flush()
228
229 # create album
230 album = Album(
231 artist_did=artist.did,
232 slug="test-album",
233 title="Test Album",
234 )
235 db_session.add(album)
236 await db_session.flush()
237
238 # create tracks linked to album
239 track1 = Track(
240 title="Track 1",
241 file_id="test-file-1",
242 file_type="audio/mpeg",
243 artist_did=artist.did,
244 album_id=album.id,
245 )
246 track2 = Track(
247 title="Track 2",
248 file_id="test-file-2",
249 file_type="audio/mpeg",
250 artist_did=artist.did,
251 album_id=album.id,
252 )
253 db_session.add_all([track1, track2])
254 await db_session.commit()
255
256 album_id = album.id
257 track1_id = track1.id
258 track2_id = track2.id
259
260 # mock ATProto delete (imported inside the function from _internal.atproto.records)
261 with patch(
262 "backend._internal.atproto.records.fm_plyr.track.delete_record_by_uri",
263 new_callable=AsyncMock,
264 ):
265 async with AsyncClient(
266 transport=ASGITransport(app=test_app), base_url="http://test"
267 ) as client:
268 response = await client.delete(f"/albums/{album_id}")
269
270 assert response.status_code == 200
271 data = response.json()
272 assert data["deleted"] is True
273 assert data["cascade"] is False
274
275 # close test session and create fresh one to read committed data
276 await db_session.close()
277
278 # verify album is deleted by checking response
279 # (the API committed in a separate session, test session can't see it)
280 # Use a fresh query to verify the state
281 from backend.utilities.database import get_engine
282
283 engine = get_engine()
284 async with AsyncSession(engine, expire_on_commit=False) as fresh_session:
285 # verify album is deleted
286 result = await fresh_session.execute(select(Album).where(Album.id == album_id))
287 assert result.scalar_one_or_none() is None
288
289 # verify tracks still exist but are orphaned (album_id = null)
290 result = await fresh_session.execute(select(Track).where(Track.id == track1_id))
291 track1_after = result.scalar_one_or_none()
292 assert track1_after is not None
293 assert track1_after.album_id is None
294
295 result = await fresh_session.execute(select(Track).where(Track.id == track2_id))
296 track2_after = result.scalar_one_or_none()
297 assert track2_after is not None
298 assert track2_after.album_id is None
299
300
301async def test_delete_album_cascade_deletes_tracks(
302 test_app: FastAPI, db_session: AsyncSession
303):
304 """test that deleting album with cascade=true also deletes all tracks."""
305 # create artist matching mock session
306 artist = Artist(
307 did="did:test:user123",
308 handle="test.artist",
309 display_name="Test Artist",
310 )
311 db_session.add(artist)
312 await db_session.flush()
313
314 # create album
315 album = Album(
316 artist_did=artist.did,
317 slug="test-album-cascade",
318 title="Test Album Cascade",
319 )
320 db_session.add(album)
321 await db_session.flush()
322
323 # create tracks linked to album
324 track1 = Track(
325 title="Track 1",
326 file_id="cascade-file-1",
327 file_type="audio/mpeg",
328 artist_did=artist.did,
329 album_id=album.id,
330 )
331 track2 = Track(
332 title="Track 2",
333 file_id="cascade-file-2",
334 file_type="audio/mpeg",
335 artist_did=artist.did,
336 album_id=album.id,
337 )
338 db_session.add_all([track1, track2])
339 await db_session.commit()
340
341 album_id = album.id
342 track1_id = track1.id
343 track2_id = track2.id
344
345 # mock ATProto and storage deletes
346 with (
347 patch(
348 "backend._internal.atproto.records.fm_plyr.track.delete_record_by_uri",
349 new_callable=AsyncMock,
350 ),
351 patch(
352 "backend.api.tracks.mutations.delete_record_by_uri",
353 new_callable=AsyncMock,
354 ),
355 patch(
356 "backend.api.tracks.mutations.storage.delete",
357 new_callable=AsyncMock,
358 ),
359 ):
360 async with AsyncClient(
361 transport=ASGITransport(app=test_app), base_url="http://test"
362 ) as client:
363 response = await client.delete(f"/albums/{album_id}?cascade=true")
364
365 assert response.status_code == 200
366 data = response.json()
367 assert data["deleted"] is True
368 assert data["cascade"] is True
369
370 # verify album is deleted
371 result = await db_session.execute(select(Album).where(Album.id == album_id))
372 assert result.scalar_one_or_none() is None
373
374 # verify tracks are also deleted
375 result = await db_session.execute(select(Track).where(Track.id == track1_id))
376 assert result.scalar_one_or_none() is None
377
378 result = await db_session.execute(select(Track).where(Track.id == track2_id))
379 assert result.scalar_one_or_none() is None
380
381
382async def test_delete_album_forbidden_for_non_owner(
383 test_app: FastAPI, db_session: AsyncSession
384):
385 """test that users cannot delete albums they don't own."""
386 # create a different artist (not the mock session's did)
387 other_artist = Artist(
388 did="did:other:artist999",
389 handle="other.artist",
390 display_name="Other Artist",
391 )
392 db_session.add(other_artist)
393 await db_session.flush()
394
395 # create album owned by other artist
396 album = Album(
397 artist_did=other_artist.did,
398 slug="other-album",
399 title="Other Album",
400 )
401 db_session.add(album)
402 await db_session.commit()
403
404 album_id = album.id
405
406 async with AsyncClient(
407 transport=ASGITransport(app=test_app), base_url="http://test"
408 ) as client:
409 response = await client.delete(f"/albums/{album_id}")
410
411 assert response.status_code == 403
412 assert "your own albums" in response.json()["detail"]
413
414
415async def test_delete_empty_album(test_app: FastAPI, db_session: AsyncSession):
416 """test deleting an album with no tracks (empty album shell).
417
418 regression test for user report: after deleting all tracks individually,
419 the album folder remains and cannot be deleted.
420 """
421 # create artist matching mock session
422 artist = Artist(
423 did="did:test:user123",
424 handle="test.artist",
425 display_name="Test Artist",
426 )
427 db_session.add(artist)
428 await db_session.flush()
429
430 # create empty album (no tracks)
431 album = Album(
432 artist_did=artist.did,
433 slug="empty-album",
434 title="Empty Album",
435 )
436 db_session.add(album)
437 await db_session.commit()
438
439 album_id = album.id
440
441 # mock ATProto delete
442 with patch(
443 "backend._internal.atproto.records.fm_plyr.track.delete_record_by_uri",
444 new_callable=AsyncMock,
445 ):
446 async with AsyncClient(
447 transport=ASGITransport(app=test_app), base_url="http://test"
448 ) as client:
449 response = await client.delete(f"/albums/{album_id}")
450
451 assert response.status_code == 200
452 data = response.json()
453 assert data["deleted"] is True
454
455 # verify album is deleted
456 result = await db_session.execute(select(Album).where(Album.id == album_id))
457 assert result.scalar_one_or_none() is None
458
459
460async def test_get_album_respects_atproto_track_order(
461 test_app: FastAPI, db_session: AsyncSession
462):
463 """test that get_album returns tracks in ATProto list order.
464
465 regression test for user report: album track reorder doesn't persist.
466 the frontend saves order to ATProto, but backend was ignoring it.
467 """
468 from datetime import UTC, datetime, timedelta
469
470 # create artist
471 artist = Artist(
472 did="did:test:user123",
473 handle="test.artist",
474 display_name="Test Artist",
475 pds_url="https://test.pds",
476 )
477 db_session.add(artist)
478 await db_session.flush()
479
480 # create album with ATProto record URI
481 album = Album(
482 artist_did=artist.did,
483 slug="ordered-album",
484 title="Ordered Album",
485 atproto_record_uri="at://did:test:user123/fm.plyr.list/album123",
486 )
487 db_session.add(album)
488 await db_session.flush()
489
490 # create tracks with staggered created_at (track3 first, track1 last)
491 base_time = datetime.now(UTC)
492 track1 = Track(
493 title="Track 1",
494 file_id="order-file-1",
495 file_type="audio/mpeg",
496 artist_did=artist.did,
497 album_id=album.id,
498 atproto_record_uri="at://did:test:user123/fm.plyr.track/track1",
499 atproto_record_cid="cid1",
500 created_at=base_time + timedelta(hours=2), # created last
501 )
502 track2 = Track(
503 title="Track 2",
504 file_id="order-file-2",
505 file_type="audio/mpeg",
506 artist_did=artist.did,
507 album_id=album.id,
508 atproto_record_uri="at://did:test:user123/fm.plyr.track/track2",
509 atproto_record_cid="cid2",
510 created_at=base_time + timedelta(hours=1), # created second
511 )
512 track3 = Track(
513 title="Track 3",
514 file_id="order-file-3",
515 file_type="audio/mpeg",
516 artist_did=artist.did,
517 album_id=album.id,
518 atproto_record_uri="at://did:test:user123/fm.plyr.track/track3",
519 atproto_record_cid="cid3",
520 created_at=base_time, # created first
521 )
522 db_session.add_all([track1, track2, track3])
523 await db_session.commit()
524
525 # mock ATProto record fetch to return custom order: track2, track3, track1
526 # (different from created_at order which would be track3, track2, track1)
527 mock_record = {
528 "value": {
529 "items": [
530 {"subject": {"uri": track2.atproto_record_uri, "cid": "cid2"}},
531 {"subject": {"uri": track3.atproto_record_uri, "cid": "cid3"}},
532 {"subject": {"uri": track1.atproto_record_uri, "cid": "cid1"}},
533 ]
534 }
535 }
536
537 with patch(
538 "backend._internal.atproto.records.get_record_public",
539 new_callable=AsyncMock,
540 return_value=mock_record,
541 ):
542 async with AsyncClient(
543 transport=ASGITransport(app=test_app), base_url="http://test"
544 ) as client:
545 response = await client.get(f"/albums/{artist.handle}/{album.slug}")
546
547 assert response.status_code == 200
548 data = response.json()
549
550 # verify tracks are in ATProto order (track2, track3, track1)
551 # NOT created_at order (which would be track3, track2, track1)
552 assert len(data["tracks"]) == 3
553 assert data["tracks"][0]["title"] == "Track 2"
554 assert data["tracks"][1]["title"] == "Track 3"
555 assert data["tracks"][2]["title"] == "Track 1"
556
557
558async def test_get_album_fallback_to_created_at_without_atproto(
559 test_app: FastAPI, db_session: AsyncSession
560):
561 """test that get_album falls back to created_at order without ATProto record."""
562 from datetime import UTC, datetime, timedelta
563
564 # create artist
565 artist = Artist(
566 did="did:test:user123",
567 handle="test.artist",
568 display_name="Test Artist",
569 )
570 db_session.add(artist)
571 await db_session.flush()
572
573 # create album WITHOUT ATProto record URI
574 album = Album(
575 artist_did=artist.did,
576 slug="no-atproto-album",
577 title="No ATProto Album",
578 atproto_record_uri=None, # no ATProto record
579 )
580 db_session.add(album)
581 await db_session.flush()
582
583 # create tracks with specific order
584 base_time = datetime.now(UTC)
585 track1 = Track(
586 title="First Track",
587 file_id="first-file",
588 file_type="audio/mpeg",
589 artist_did=artist.did,
590 album_id=album.id,
591 created_at=base_time,
592 )
593 track2 = Track(
594 title="Second Track",
595 file_id="second-file",
596 file_type="audio/mpeg",
597 artist_did=artist.did,
598 album_id=album.id,
599 created_at=base_time + timedelta(hours=1),
600 )
601 track3 = Track(
602 title="Third Track",
603 file_id="third-file",
604 file_type="audio/mpeg",
605 artist_did=artist.did,
606 album_id=album.id,
607 created_at=base_time + timedelta(hours=2),
608 )
609 db_session.add_all([track1, track2, track3])
610 await db_session.commit()
611
612 async with AsyncClient(
613 transport=ASGITransport(app=test_app), base_url="http://test"
614 ) as client:
615 response = await client.get(f"/albums/{artist.handle}/{album.slug}")
616
617 assert response.status_code == 200
618 data = response.json()
619
620 # verify tracks are in created_at order (default fallback)
621 assert len(data["tracks"]) == 3
622 assert data["tracks"][0]["title"] == "First Track"
623 assert data["tracks"][1]["title"] == "Second Track"
624 assert data["tracks"][2]["title"] == "Third Track"
625
626
627async def test_update_album_title(test_app: FastAPI, db_session: AsyncSession):
628 """test updating album title via PATCH endpoint.
629
630 verifies that:
631 1. album title is updated in database
632 2. track extra["album"] is updated for all tracks
633 3. ATProto records are updated for tracks that have them
634 4. album's ATProto list record name is updated
635 """
636 # create artist matching mock session
637 artist = Artist(
638 did="did:test:user123",
639 handle="test.artist",
640 display_name="Test Artist",
641 )
642 db_session.add(artist)
643 await db_session.flush()
644
645 # create album with ATProto list record
646 album = Album(
647 artist_did=artist.did,
648 slug="test-album",
649 title="Original Title",
650 atproto_record_uri="at://did:test:user123/fm.plyr.dev.list/album123",
651 atproto_record_cid="original_list_cid",
652 )
653 db_session.add(album)
654 await db_session.flush()
655
656 # create track with ATProto record
657 track = Track(
658 title="Test Track",
659 file_id="test-file-update",
660 file_type="mp3",
661 artist_did=artist.did,
662 album_id=album.id,
663 extra={"album": "Original Title"},
664 r2_url="https://r2.example.com/audio/test-file-update.mp3",
665 atproto_record_uri="at://did:test:user123/fm.plyr.track/track123",
666 atproto_record_cid="original_cid",
667 )
668 db_session.add(track)
669 await db_session.commit()
670
671 album_id = album.id
672 track_id = track.id
673
674 # mock ATProto update_record for tracks and list
675 with (
676 patch(
677 "backend._internal.atproto.records.fm_plyr.track.update_record",
678 new_callable=AsyncMock,
679 return_value=("at://did:test:user123/fm.plyr.track/track123", "new_cid"),
680 ) as mock_track_update,
681 patch(
682 "backend._internal.atproto.records.fm_plyr.list.update_list_record",
683 new_callable=AsyncMock,
684 return_value=(
685 "at://did:test:user123/fm.plyr.dev.list/album123",
686 "new_list_cid",
687 ),
688 ) as mock_list_update,
689 ):
690 async with AsyncClient(
691 transport=ASGITransport(app=test_app), base_url="http://test"
692 ) as client:
693 response = await client.patch(f"/albums/{album_id}?title=Updated%20Title")
694
695 assert response.status_code == 200
696 data = response.json()
697 assert data["title"] == "Updated Title"
698 assert data["id"] == album_id
699
700 # verify track ATProto update was called
701 mock_track_update.assert_called_once()
702 call_kwargs = mock_track_update.call_args.kwargs
703 assert call_kwargs["record"]["album"] == "Updated Title"
704
705 # verify list record update was called with new name
706 mock_list_update.assert_called_once()
707 list_call_kwargs = mock_list_update.call_args.kwargs
708 assert list_call_kwargs["name"] == "Updated Title"
709 assert list_call_kwargs["list_type"] == "album"
710
711 # verify track extra["album"] was updated in database
712 from backend.utilities.database import get_engine
713
714 engine = get_engine()
715 async with AsyncSession(engine, expire_on_commit=False) as fresh_session:
716 result = await fresh_session.execute(select(Track).where(Track.id == track_id))
717 updated_track = result.scalar_one()
718 assert updated_track.extra["album"] == "Updated Title"
719 assert updated_track.atproto_record_cid == "new_cid"
720
721 # verify album list record CID was updated
722 album_result = await fresh_session.execute(
723 select(Album).where(Album.id == album_id)
724 )
725 updated_album = album_result.scalar_one()
726 assert updated_album.atproto_record_cid == "new_list_cid"
727
728
729async def test_update_album_forbidden_for_non_owner(
730 test_app: FastAPI, db_session: AsyncSession
731):
732 """test that users cannot update albums they don't own."""
733 # create a different artist
734 other_artist = Artist(
735 did="did:other:artist999",
736 handle="other.artist",
737 display_name="Other Artist",
738 )
739 db_session.add(other_artist)
740 await db_session.flush()
741
742 # create album owned by other artist
743 album = Album(
744 artist_did=other_artist.did,
745 slug="other-album",
746 title="Other Album",
747 )
748 db_session.add(album)
749 await db_session.commit()
750
751 album_id = album.id
752
753 async with AsyncClient(
754 transport=ASGITransport(app=test_app), base_url="http://test"
755 ) as client:
756 response = await client.patch(f"/albums/{album_id}?title=Hacked%20Title")
757
758 assert response.status_code == 403
759 assert "your own albums" in response.json()["detail"]
760
761
762async def test_update_album_syncs_slug_on_title_change(
763 test_app: FastAPI, db_session: AsyncSession
764):
765 """regression test: album slug must update when title changes.
766
767 fixes bug where renaming an album via PATCH didn't update the slug,
768 causing get_or_create_album to create duplicates when adding tracks
769 to the renamed album (since it looks up by slugified title).
770 """
771 from backend.utilities.slugs import slugify
772
773 # create artist matching mock session
774 artist = Artist(
775 did="did:test:user123",
776 handle="test.artist",
777 display_name="Test Artist",
778 )
779 db_session.add(artist)
780 await db_session.flush()
781
782 # create album with original title/slug
783 original_title = "Private Event 2016"
784 album = Album(
785 artist_did=artist.did,
786 slug=slugify(original_title),
787 title=original_title,
788 )
789 db_session.add(album)
790 await db_session.commit()
791
792 album_id = album.id
793 assert album.slug == "private-event-2016"
794
795 # rename album with PATCH
796 new_title = "The Waybacks at Private Event 2016"
797 async with AsyncClient(
798 transport=ASGITransport(app=test_app), base_url="http://test"
799 ) as client:
800 response = await client.patch(
801 f"/albums/{album_id}?title={new_title.replace(' ', '%20')}"
802 )
803
804 assert response.status_code == 200
805 data = response.json()
806 assert data["title"] == new_title
807 # slug should be updated to match new title
808 assert data["slug"] == slugify(new_title)
809 assert data["slug"] == "the-waybacks-at-private-event-2016"
810
811 # verify in database
812 from backend.utilities.database import get_engine
813
814 engine = get_engine()
815 async with AsyncSession(engine, expire_on_commit=False) as fresh_session:
816 result = await fresh_session.execute(select(Album).where(Album.id == album_id))
817 updated_album = result.scalar_one()
818 assert updated_album.title == new_title
819 assert updated_album.slug == "the-waybacks-at-private-event-2016"
820
821
822async def test_remove_track_from_album(test_app: FastAPI, db_session: AsyncSession):
823 """test removing a track from an album (orphaning it)."""
824 # create artist matching mock session
825 artist = Artist(
826 did="did:test:user123",
827 handle="test.artist",
828 display_name="Test Artist",
829 )
830 db_session.add(artist)
831 await db_session.flush()
832
833 # create album
834 album = Album(
835 artist_did=artist.did,
836 slug="test-album",
837 title="Test Album",
838 )
839 db_session.add(album)
840 await db_session.flush()
841
842 # create track in album
843 track = Track(
844 title="Track to Remove",
845 file_id="remove-file-1",
846 file_type="audio/mpeg",
847 artist_did=artist.did,
848 album_id=album.id,
849 )
850 db_session.add(track)
851 await db_session.commit()
852
853 album_id = album.id
854 track_id = track.id
855
856 async with AsyncClient(
857 transport=ASGITransport(app=test_app), base_url="http://test"
858 ) as client:
859 response = await client.delete(f"/albums/{album_id}/tracks/{track_id}")
860
861 assert response.status_code == 200
862 data = response.json()
863 assert data["removed"] is True
864 assert data["track_id"] == track_id
865
866 # verify track is orphaned (album_id = null)
867 from backend.utilities.database import get_engine
868
869 engine = get_engine()
870 async with AsyncSession(engine, expire_on_commit=False) as fresh_session:
871 result = await fresh_session.execute(select(Track).where(Track.id == track_id))
872 track_after = result.scalar_one_or_none()
873 assert track_after is not None
874 assert track_after.album_id is None
875
876
877async def test_remove_track_not_in_album(test_app: FastAPI, db_session: AsyncSession):
878 """test that removing a track not in the album returns 400."""
879 # create artist
880 artist = Artist(
881 did="did:test:user123",
882 handle="test.artist",
883 display_name="Test Artist",
884 )
885 db_session.add(artist)
886 await db_session.flush()
887
888 # create album
889 album = Album(
890 artist_did=artist.did,
891 slug="test-album",
892 title="Test Album",
893 )
894 db_session.add(album)
895 await db_session.flush()
896
897 # create track NOT in this album (orphaned)
898 track = Track(
899 title="Orphan Track",
900 file_id="orphan-file-1",
901 file_type="audio/mpeg",
902 artist_did=artist.did,
903 album_id=None, # not in any album
904 )
905 db_session.add(track)
906 await db_session.commit()
907
908 album_id = album.id
909 track_id = track.id
910
911 async with AsyncClient(
912 transport=ASGITransport(app=test_app), base_url="http://test"
913 ) as client:
914 response = await client.delete(f"/albums/{album_id}/tracks/{track_id}")
915
916 assert response.status_code == 400
917 assert "not in this album" in response.json()["detail"]