CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 34s
Build images / build-web (push) Successful in 1m13s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 2m15s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m46s
Two failures on 51e78a3, neither in the shipped logic.
**The integration failure was a TEST bug, not a code bug.**
`test_a_weak_name_needs_the_declaration` set `display_name` to something
deliberately weak but left `_membership`'s DEFAULT vanity, which matched the
artist slug exactly. So the name signal was legitimately 1.0 and the matcher
was right to propose — my assertion of 0 was asserting the wrong scenario.
Both identity fields now have to be weak for the test to mean what it says,
and the arithmetic was checked before pushing: 0.39 without the declaration,
0.74 with it.
Worth keeping: a fixture whose fields disagree with each other will pass or
fail for reasons unrelated to the property under test, and this one was one
default away from silently testing nothing.
**The lint failure was import order** — `artist_membership_service` sorts
before `credential_*` and I inserted it after. Second isort slip this session
from patching an import block with a script rather than reading it back; the
repo has a rule about exactly this (#102).
Everything else passed on that SHA: 1256 tests, the frontend suite, and
migration 0096.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
288 lines
10 KiB
Python
288 lines
10 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_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"
|