From a13a5878eb2bc97cd4893fc8114d2691e443283a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 20 May 2026 12:53:34 -0400 Subject: [PATCH] =?UTF-8?q?feat(fc3a):=20migration=200010=20=E2=80=94=20un?= =?UTF-8?q?ique(source.artist=5Fid,=20platform,=20url)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- ..._fc3a_source_unique_artist_platform_url.py | 32 +++++++++++++++++ tests/test_migration_0010.py | 35 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 alembic/versions/0010_fc3a_source_unique_artist_platform_url.py create mode 100644 tests/test_migration_0010.py diff --git a/alembic/versions/0010_fc3a_source_unique_artist_platform_url.py b/alembic/versions/0010_fc3a_source_unique_artist_platform_url.py new file mode 100644 index 0000000..10f502b --- /dev/null +++ b/alembic/versions/0010_fc3a_source_unique_artist_platform_url.py @@ -0,0 +1,32 @@ +"""fc3a: unique(source.artist_id, source.platform, source.url) + +Revision ID: 0010 +Revises: 0009 +Create Date: 2026-05-20 + +Enforces FC-3a's dedup invariant at the DB level. No backfill — no +existing rows are expected to collide; if they do the migration will +fail loudly (intended). +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "0010" +down_revision: Union[str, None] = "0009" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_unique_constraint( + "uq_source_artist_platform_url", + "source", + ["artist_id", "platform", "url"], + ) + + +def downgrade() -> None: + op.drop_constraint( + "uq_source_artist_platform_url", "source", type_="unique" + ) diff --git a/tests/test_migration_0010.py b/tests/test_migration_0010.py new file mode 100644 index 0000000..a261e6c --- /dev/null +++ b/tests/test_migration_0010.py @@ -0,0 +1,35 @@ +import pytest +from sqlalchemy.exc import IntegrityError + +from backend.app.models import Artist, Source + +pytestmark = pytest.mark.integration + + +@pytest.mark.asyncio +async def test_duplicate_artist_platform_url_rejected(db): + artist = Artist(name="Alice", slug="alice") + db.add(artist) + await db.flush() + db.add(Source( + artist_id=artist.id, platform="patreon", + url="https://patreon.com/alice", enabled=True, + )) + await db.flush() + db.add(Source( + artist_id=artist.id, platform="patreon", + url="https://patreon.com/alice", enabled=True, + )) + with pytest.raises(IntegrityError): + await db.flush() + + +@pytest.mark.asyncio +async def test_same_url_under_different_artist_ok(db): + a = Artist(name="A", slug="a") + b = Artist(name="B", slug="b") + db.add_all([a, b]) + await db.flush() + db.add(Source(artist_id=a.id, platform="patreon", url="https://x/y", enabled=True)) + db.add(Source(artist_id=b.id, platform="patreon", url="https://x/y", enabled=True)) + await db.flush() # must NOT raise