"""Milestone 388 E4: proposing that a creator and a membership are the same. E4's own first instruction was to verify before building, and the verification said the association ALREADY works — `Source.artist_id` is a plain FK, the API takes an `artist_id`, the add-source dialog attaches to an existing artist, and `reassign` moves a source with post/image re-attribution. So these test the SUGGESTION, which is what was actually missing. The failure to avoid is a wrong link. Accepting adds a SOURCE rather than merging artists precisely because the first is trivially undone and the second silently mixes two creators' work — so most of what follows pins refusals, and the weights are asserted structurally so they survive a refactor of the scorer. """ from datetime import UTC, datetime import pytest from sqlalchemy import select from backend.app.models import ( Artist, ArtistMembershipSuggestion, PlatformMembership, Post, Source, ) from backend.app.services.artist_membership_service import ( DEFAULT_THRESHOLD, WEIGHTS, ArtistMembershipService, declared_signal, name_signal, weighted_score, ) pytestmark = pytest.mark.integration # --- the structural guard -------------------------------------------------- def test_the_weights_encode_the_judgement_rather_than_a_code_path(): """Three claims, asserted against WEIGHTS directly so they survive any refactor of the scorer. Do NOT fix a failure here by moving the numbers — the arithmetic IS the decision. * an EXACT name match alone proposes (same slug is strong evidence, and demanding corroboration would propose almost nothing); * a CONTAINMENT name match alone does not ("art" sits inside "artgirl"); * the declaration ALONE never proposes, because a creator may link another creator's Patreon and a link is not a claim of identity. """ assert sum(WEIGHTS.values()) == pytest.approx(1.0) assert weighted_score({"name": 1.0}) >= DEFAULT_THRESHOLD assert weighted_score({"name": 0.6}) < DEFAULT_THRESHOLD assert weighted_score({"declared": 1.0}) < DEFAULT_THRESHOLD assert weighted_score({"name": 0.6, "declared": 1.0}) >= DEFAULT_THRESHOLD # --- the signals ----------------------------------------------------------- def _m(**kw): kw.setdefault("platform", "patreon") kw.setdefault("external_campaign_id", "1") return PlatformMembership(**kw) def test_name_matches_on_either_the_display_name_or_the_vanity(): """Creators routinely differ between the two, and either may be what the operator typed when they created the artist.""" m = _m(display_name="Team Melon Collie", details={"campaign": {"vanity": "MelonCollieStudios"}}) assert name_signal(m, Artist(name="Team Melon Collie", slug="x")) == 1.0 assert name_signal(m, Artist(name="meloncolliestudios", slug="x")) == 1.0 def test_a_containment_match_is_a_hint_not_a_match(): m = _m(display_name="Maewix Studios", details={}) got = name_signal(m, Artist(name="Maewix", slug="x")) assert 0 < got < 1.0 def test_a_short_slug_does_not_match_by_containment(): """A 3-character slug is inside a great many longer ones — containment there is a coincidence generator, not a signal.""" m = _m(display_name="Artgirl Studios", details={}) assert name_signal(m, Artist(name="art", slug="art")) == 0.0 def test_unrelated_names_do_not_match(): m = _m(display_name="Maewix", details={}) assert name_signal(m, Artist(name="Floppystack", slug="x")) == 0.0 def test_the_declaration_reads_through_an_anchor_href(): """These links live in an `href`, and html_to_plain discards attributes — the trap that already caught E5's invite detection.""" body = '

Support me: here

' assert declared_signal(body, "maewix") == 1.0 def test_the_declaration_is_specific_to_THIS_membership(): """A creator linking SOMEONE ELSE's Patreon must not link the two.""" body = 'a friend' assert declared_signal(body, "maewix") == 0.0 def test_the_declaration_tolerates_the_c_and_cw_url_variants(): """Patreon serves /c/ and /cw/ per campaign (#3886).""" assert declared_signal("see https://www.patreon.com/c/maewix", "maewix") == 1.0 assert declared_signal("see https://www.patreon.com/cw/maewix", "maewix") == 1.0 # --- end to end ------------------------------------------------------------ async def _artist_with_discord(db, name, slug): a = Artist(name=name, slug=slug) db.add(a) await db.flush() db.add(Source( artist_id=a.id, platform="discord", url=f"https://discord.com/channels/1/{slug}", enabled=True, )) await db.flush() return a async def _membership(db, **kw): kw.setdefault("platform", "patreon") kw.setdefault("external_campaign_id", "c1") kw.setdefault("url", "https://www.patreon.com/maewix") kw.setdefault("details", {"campaign": {"vanity": "maewix"}}) m = PlatformMembership(**kw) db.add(m) await db.flush() return m @pytest.mark.asyncio async def test_a_matching_name_proposes_the_link(db): artist = await _artist_with_discord(db, "Maewix", "maewix") m = await _membership(db, display_name="Maewix") await db.commit() made = await ArtistMembershipService(db).match_membership(m.id) await db.commit() assert made == 1 s = (await db.execute(select(ArtistMembershipSuggestion))).scalar_one() assert s.artist_id == artist.id assert s.status == "pending", "nothing is linked without the operator" @pytest.mark.asyncio async def test_an_artist_already_on_that_platform_is_never_proposed(db): """The link exists; a suggestion would be noise.""" artist = await _artist_with_discord(db, "Maewix", "maewix") db.add(Source( artist_id=artist.id, platform="patreon", url="https://www.patreon.com/maewix", enabled=True, )) m = await _membership(db, display_name="Maewix") await db.commit() assert await ArtistMembershipService(db).match_membership(m.id) == 0 @pytest.mark.asyncio async def test_an_artist_with_no_sources_at_all_is_never_proposed(db): """Not a creator FC follows through another channel, which is the whole case this step is about.""" a = Artist(name="Maewix", slug="maewix") db.add(a) m = await _membership(db, display_name="Maewix") await db.commit() assert await ArtistMembershipService(db).match_membership(m.id) == 0 @pytest.mark.asyncio async def test_a_weak_name_needs_the_declaration(db): artist = await _artist_with_discord(db, "Maewix", "maewix") m = await _membership(db, display_name="Maewix Studios Official") await db.commit() assert await ArtistMembershipService(db).match_membership(m.id) == 0 db.add(Post( artist_id=artist.id, source_id=None, external_post_id="p1", description='my patreon', post_date=datetime.now(UTC), )) await db.commit() assert await ArtistMembershipService(db).match_membership(m.id) == 1 @pytest.mark.asyncio async def test_accepting_adds_a_source_and_never_merges_artists(db): """THE safety property. Adding a source is trivially undone; a wrong merge mixes two creators' work with nothing left to separate them by.""" artist = await _artist_with_discord(db, "Maewix", "maewix") m = await _membership(db, display_name="Maewix") await db.commit() artists_before = len((await db.execute(select(Artist))).scalars().all()) svc = ArtistMembershipService(db) await svc.match_membership(m.id) await db.commit() s = (await db.execute(select(ArtistMembershipSuggestion))).scalar_one() result = await svc.accept(s.id) await db.commit() assert result["status"] == "linked" sources = (await db.execute( select(Source).where(Source.artist_id == artist.id) )).scalars().all() assert {x.platform for x in sources} == {"discord", "patreon"} assert len((await db.execute(select(Artist))).scalars().all()) == artists_before, ( "no artist may be created or destroyed by accepting" ) @pytest.mark.asyncio async def test_accepting_twice_does_not_add_a_second_source(db): """A source appearing between the proposal and the click is the operator having done it by hand — not an error.""" artist = await _artist_with_discord(db, "Maewix", "maewix") m = await _membership(db, display_name="Maewix") await db.commit() svc = ArtistMembershipService(db) await svc.match_membership(m.id) await db.commit() s = (await db.execute(select(ArtistMembershipSuggestion))).scalar_one() await svc.accept(s.id) await db.commit() second = await svc.accept(s.id) await db.commit() assert "already_linked" in second sources = (await db.execute( select(Source).where( Source.artist_id == artist.id, Source.platform == "patreon", ) )).scalars().all() assert len(sources) == 1 @pytest.mark.asyncio async def test_a_dismissed_pair_is_never_proposed_again(db): await _artist_with_discord(db, "Maewix", "maewix") m = await _membership(db, display_name="Maewix") await db.commit() svc = ArtistMembershipService(db) assert await svc.match_membership(m.id) == 1 await db.commit() s = (await db.execute(select(ArtistMembershipSuggestion))).scalar_one() await svc.dismiss(s.id) await db.commit() assert await svc.match_membership(m.id) == 0 await db.commit() rows = (await db.execute(select(ArtistMembershipSuggestion))).scalars().all() assert len(rows) == 1 and rows[0].status == "dismissed" @pytest.mark.asyncio async def test_the_vanity_falls_back_to_the_url_when_details_lack_it(db): """C1 modelled the roster before any platform was characterised, so the vanity lives in `details` rather than a column — and must still be findable for a row written before that field was understood.""" m = await _membership( db, display_name="Something Else", details={}, url="https://www.patreon.com/maewix", ) assert m.vanity_or_none() == "maewix"