CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 5s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 1m18s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m11s
Build images / promote (push) Skipped
CI / integration (push) Successful in 3m4s
Reconciliation in Subscriptions, asymmetric on purpose. Subscriptions FC does not follow get a per-row add; sources the roster cannot account for are REPORT ONLY (operator decision) and link to the list on the same page. No one-click disable, so no disabled-reason column and no migration. The membership<->source join lands as a SHARED resolver in membership_roster, not inline here: E4 now uses it as its negative check, so the two features cannot give different answers to 'is this membership already tracked?'. Keys on the exact cached campaign id FIRST and the URL handle only as fallback, because the id is written only after a source has been walked once. Read via any <platform>_campaign_id override rather than naming Patreon's, per rule 169. The report-only bucket is gated on roster freshness and carries a per-row basis, so 'your membership says former patron', 'we know this id and it is absent', and 'we only have a handle' stay three different sentences. has_paid_access None never reads as lapsed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""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 = '<p>Support me: <a href="https://www.patreon.com/maewix">here</a></p>'
|
|
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 href="https://www.patreon.com/someoneelse">a friend</a>'
|
|
assert declared_signal(body, "maewix") == 0.0
|
|
|
|
|
|
def test_the_declaration_tolerates_the_c_and_cw_url_variants():
|
|
"""Patreon serves /c/<vanity> and /cw/<vanity> 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_a_membership_already_tracked_elsewhere_is_never_proposed(db):
|
|
"""C4's shared identity join, used here as the NEGATIVE check.
|
|
|
|
A membership FC already has a source for is tracked — even when that source
|
|
sits under a DIFFERENT artist — and proposing it to this one would be the
|
|
wrong link this service exists to avoid. `_candidate_artists` cannot see
|
|
this on its own: it only knows whether the artist in front of it has a
|
|
source on the platform, not whether someone else already does.
|
|
"""
|
|
await _artist_with_discord(db, "Maewix", "maewix")
|
|
other = Artist(name="Someone Else", slug="someoneelse")
|
|
db.add(other)
|
|
await db.flush()
|
|
db.add(Source(
|
|
artist_id=other.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")
|
|
# BOTH identity fields must be weak, or the test contradicts itself: the
|
|
# first draft left the default vanity exactly matching the artist slug, so
|
|
# the name signal was legitimately 1.0 and the code was right to propose.
|
|
# CI caught it — a test bug, not a code bug.
|
|
m = await _membership(
|
|
db, display_name="Maewix Studios Official",
|
|
details={"campaign": {"vanity": "maewixstudios"}},
|
|
url="https://www.patreon.com/maewixstudios",
|
|
)
|
|
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='<a href="https://www.patreon.com/maewixstudios">mine</a>',
|
|
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"
|