CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Failing after 32s
Build images / build-web (push) Successful in 58s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m47s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m16s
A3 made a tier-gated source say "47 posts you can't see". The roster turns that into a reason: the membership ended, or the tier doesn't reach these posts, or it's a free follow. Rendered under A3's count in the health tooltip, quieter than the count it explains. FREE is a fourth case the step didn't enumerate, and it earns its own sentence. has_paid_access collapses "former patron" and "current free follower" to the same False, so deriving the reason from that boolean would tell a free follower "you're not a patron any more" - a false statement about a state they were never in. gated_reason reads the status axis first, calling has_paid_access with is_free_member forced off, then splits on the free flag. Silence is the default, and there are four ways into it: campaign absent from the roster, roster stale, platform never swept, status word not yet characterised. All four send null and the count stands alone. The frontend has no fallback sentence either - a default would turn "we don't know why" into a reason, which is the one thing this step must not do. The line that must not be crossed is pinned structurally rather than by inspection: test_no_fetch_path_can_read_the_roster walks the transitive first-party imports from the fetch roots and asserts the roster is unreachable. FC runs no local verification (rule 85), so a guard cannot be falsified by hand before it lands - it carries two positive controls instead, proving the walker finds roster imports that ARE there, one direct and one through a hop, so the real assertion can never pass merely because the walk resolved nothing. C4's identity loop moved to membership_roster.pair_sources_with_memberships when C5 became its second caller; two copies would let the Subscriptions row and the reconciliation card disagree about which creator a source IS. Three test files were each building PlatformMembership rows with their own drifting helper - consolidated into tests/roster_builders.py, same family as issue 3109. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
302 lines
11 KiB
Python
302 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,
|
|
)
|
|
from tests.roster_builders import membership as _membership
|
|
|
|
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
|
|
|
|
|
|
@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"
|