fix: sync album slug when title is updated via PATCH (#557)

when renaming an album via PATCH /albums/{album_id}, the slug was not
being updated to match the new title. this caused get_or_create_album()
to fail lookups when adding tracks to renamed albums (since it looks up
by artist_did + slugify(title)), resulting in duplicate albums.

- regenerate slug from new title when title changes
- add regression test for slug sync behavior

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

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

authored by zzstoatzz.io Claude and committed by GitHub fe1ea78c 0c010d5b

Changed files
+64
backend
src
backend
api
tests
+4
backend/src/backend/api/albums.py
··· 34 34 get_track_tags, 35 35 ) 36 36 from backend.utilities.hashing import CHUNK_SIZE 37 + from backend.utilities.slugs import slugify 37 38 38 39 logger = logging.getLogger(__name__) 39 40 ··· 500 501 501 502 if title is not None: 502 503 album.title = title.strip() 504 + # sync slug when title changes so get_or_create_album lookups work 505 + if title_changed: 506 + album.slug = slugify(title.strip()) 503 507 if description is not None: 504 508 album.description = description.strip() if description.strip() else None 505 509
+60
backend/tests/api/test_albums.py
··· 758 758 assert "your own albums" in response.json()["detail"] 759 759 760 760 761 + async def test_update_album_syncs_slug_on_title_change( 762 + test_app: FastAPI, db_session: AsyncSession 763 + ): 764 + """regression test: album slug must update when title changes. 765 + 766 + fixes bug where renaming an album via PATCH didn't update the slug, 767 + causing get_or_create_album to create duplicates when adding tracks 768 + to the renamed album (since it looks up by slugified title). 769 + """ 770 + from backend.utilities.slugs import slugify 771 + 772 + # create artist matching mock session 773 + artist = Artist( 774 + did="did:test:user123", 775 + handle="test.artist", 776 + display_name="Test Artist", 777 + ) 778 + db_session.add(artist) 779 + await db_session.flush() 780 + 781 + # create album with original title/slug 782 + original_title = "Private Event 2016" 783 + album = Album( 784 + artist_did=artist.did, 785 + slug=slugify(original_title), 786 + title=original_title, 787 + ) 788 + db_session.add(album) 789 + await db_session.commit() 790 + 791 + album_id = album.id 792 + assert album.slug == "private-event-2016" 793 + 794 + # rename album with PATCH 795 + new_title = "The Waybacks at Private Event 2016" 796 + async with AsyncClient( 797 + transport=ASGITransport(app=test_app), base_url="http://test" 798 + ) as client: 799 + response = await client.patch( 800 + f"/albums/{album_id}?title={new_title.replace(' ', '%20')}" 801 + ) 802 + 803 + assert response.status_code == 200 804 + data = response.json() 805 + assert data["title"] == new_title 806 + # slug should be updated to match new title 807 + assert data["slug"] == slugify(new_title) 808 + assert data["slug"] == "the-waybacks-at-private-event-2016" 809 + 810 + # verify in database 811 + from backend.utilities.database import get_engine 812 + 813 + engine = get_engine() 814 + async with AsyncSession(engine, expire_on_commit=False) as fresh_session: 815 + result = await fresh_session.execute(select(Album).where(Album.id == album_id)) 816 + updated_album = result.scalar_one() 817 + assert updated_album.title == new_title 818 + assert updated_album.slug == "the-waybacks-at-private-event-2016" 819 + 820 + 761 821 async def test_remove_track_from_album(test_app: FastAPI, db_session: AsyncSession): 762 822 """test removing a track from an album (orphaning it).""" 763 823 # create artist matching mock session