audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

at feat/auto-resolve-deleted-tracks 917 lines 29 kB view raw
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"]