feat: offer the creator you already track as the one you subscribe to (388 E4)
CI / lint (push) Failing after 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-ml (push) Successful in 2m23s
Build images / build-web (push) Successful in 1m25s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Failing after 2m31s
CI / lint (push) Failing after 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-ml (push) Successful in 2m23s
Build images / build-web (push) Successful in 1m25s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / integration (push) Failing after 2m31s
**The verification the step asked for came back "not the schema".**
`Source.artist_id` is a plain FK so many sources per artist already works;
`POST /api/sources` already takes an `artist_id`; the add-source dialog already
has an artist autocomplete that attaches to an EXISTING artist; and
`SourceService.reassign` already moves a source between artists WITH post and
image re-attribution. A sweep for one-source-per-artist assumptions found only
`func.count()` calls — the opposite of assuming one.
So no parallel association table was built for a relationship the schema
already expresses (rule 28). What was missing is FC OFFERING the link, and that
is all this adds.
**Accepting adds a SOURCE. It never merges two artists.** That asymmetry sets
the whole posture: adding a source is trivially undone, while a wrong merge
silently mixes two creators' work and corrupts tagging, series and provenance
downstream with nothing left to tell them apart by. A test asserts the artist
count is unchanged by accepting.
The weights encode the judgement rather than a code path doing it — name 0.65,
declared 0.35, cut at 0.60 — so that:
* an EXACT name match alone proposes (same slug on both sides is strong, and
demanding corroboration would propose almost nothing);
* a CONTAINMENT match alone does not ("art" sits inside "artgirl"), and short
slugs are excluded from containment entirely because a 3-character slug is
inside a great many longer ones;
* the declaration ALONE never proposes, because a creator may link another
creator's Patreon and a link is not a claim of identity.
A guard test pins all three against WEIGHTS directly and says not to fix a
failure by moving the numbers.
Two corrections carried forward from earlier steps rather than rediscovered:
* The declaration is NOT read from `ExternalLink`. `SUPPORTED_HOSTS` is file
hosts only and `host_for()` returns None for patreon.com, so no row is ever
written for one — the same trap that caught E5 for Discord invites. It reads
the raw body, because these links live in an `href` and `html_to_plain`
discards attributes.
* `vanity` is not a column: C1 modelled the roster before any platform was
characterised, which is exactly what `details` exists for. `vanity_or_none()`
reads it from there and falls back to the URL's last segment, so a row
written before the field was understood still resolves.
Two fixes during the writing. `accept()` first created a bare `Source()`,
skipping the platform/URL validation, duplicate check and #693 backfill-arming
that a hand-added source gets — a second, quieter way to create a source is how
two paths drift until one is subtly broken; it now goes through
`SourceService.create`. And the candidate query used a bare `exists().where()`,
which has no FROM to correlate against; now `select(...).exists()`.
Chained onto the roster sweep rather than given its own beat entry: a
suggestion can only be as good as the roster behind it, so any other cadence
would just propose from staler data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
"""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_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='<a href="https://www.patreon.com/maewix">my patreon</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"
|
||||
Reference in New Issue
Block a user