"""Milestone 388 E5: which Patreon post announced which Discord drop. The failure this step must not have is a WRONG link. Telling the operator that two different pieces are one is worse than telling them nothing — no link leaves them where they already were, a wrong one actively misinforms. So most of what follows pins refusals, and the central one is structural rather than behavioural: the threshold sits above every single signal weight, which is what makes "time proximity alone is never sufficient" arithmetic instead of a hope. """ from datetime import UTC, datetime, timedelta import pytest from sqlalchemy import select from backend.app.models import ( Artist, ImageRecord, ImportSettings, Post, PostAssociation, Source, ) from backend.app.services.discord_grouping import DROP_GROUPER from backend.app.services.post_association_service import ( WEIGHTS, PostAssociationService, declared_signal, proximity_signal, rescan, weighted_score, ) from backend.app.services.post_feed_service import PostFeedService pytestmark = pytest.mark.integration DEFAULT_THRESHOLD = 0.60 WINDOW = 24.0 # --- the structural guard ------------------------------------------------- def test_no_single_signal_can_reach_the_threshold(): """THE load-bearing property of this matcher. On a busy day an artist posts several times, so a matcher that could pair on proximity alone would turn every busy day into false pairs and the review queue would be abandoned. Requiring two signals is what prevents that — and it is a fact about the WEIGHTS, not about any code path, so it survives every refactor of the scorer. If this fails, either a weight grew or the default threshold dropped. Do not "fix" it by lowering the assertion; the arithmetic IS the safeguard. """ assert max(WEIGHTS.values()) < DEFAULT_THRESHOLD assert sum(WEIGHTS.values()) == pytest.approx(1.0) def test_perfect_proximity_alone_does_not_propose(): """The same property, expressed through the scorer.""" score = weighted_score({"proximity": 1.0, "declared": 0.0}) assert score < DEFAULT_THRESHOLD def test_an_explicit_declaration_alone_does_not_propose(): score = weighted_score({"proximity": 0.0, "declared": 1.0}) assert score < DEFAULT_THRESHOLD def test_both_signals_together_do_propose(): assert weighted_score({"proximity": 1.0, "declared": 1.0}) >= DEFAULT_THRESHOLD # --- the signals ---------------------------------------------------------- def test_proximity_decays_to_zero_at_the_window_edge(): window = timedelta(hours=24) assert proximity_signal(timedelta(0), window) == 1.0 assert proximity_signal(timedelta(hours=24), window) == 0.0 assert proximity_signal(timedelta(hours=48), window) == 0.0 assert 0.4 < proximity_signal(timedelta(hours=12), window) < 0.6 def test_proximity_is_symmetric_because_either_can_land_first(): """The teaser usually goes up around the drop, not reliably before it.""" window = timedelta(hours=24) assert proximity_signal(timedelta(hours=-2), window) == proximity_signal( timedelta(hours=2), window ) def test_an_invite_link_scores_higher_than_a_bare_mention(): assert declared_signal("Full set on my discord.gg/abc123 now!") == 1.0 assert 0 < declared_signal("Posted the rest on discord earlier") < 1.0 assert declared_signal("New piece, hope you like it") == 0.0 assert declared_signal(None) == 0.0 def test_the_declaration_signal_reads_through_html(): """Post bodies are HTML; a link inside an anchor tag must still count.""" assert declared_signal( '
Full set: here
' ) > 0 def test_a_word_containing_discord_is_not_a_mention(): """\"discordant\" is not a declaration. Without the word boundary the signal fires on ordinary prose and drags pairs over the threshold.""" assert declared_signal("a discordant palette, deliberately") == 0.0 # --- end to end ----------------------------------------------------------- async def _artist_with_channels(db, name: str): artist = Artist(name=name, slug=name.lower().replace(" ", "-")) db.add(artist) await db.flush() patreon = Source( artist_id=artist.id, platform="patreon", url=f"https://patreon.com/{name}", enabled=True, ) discord = Source( artist_id=artist.id, platform="discord", url=f"https://discord.com/channels/1/{name}", enabled=True, ) db.add_all([patreon, discord]) await db.flush() return artist, patreon, discord async def _teaser(db, artist, source, *, at, body, ext="teaser"): post = Post( source_id=source.id, artist_id=artist.id, external_post_id=ext, post_date=at, post_title="New piece", description=body, ) db.add(post) await db.flush() db.add(ImageRecord( path=f"/images/{source.id}-{ext}.jpg", sha256=f"{ext:0>64}"[:64], size_bytes=10, mime="image/jpeg", width=10, height=10, origin="downloaded", primary_post_id=post.id, artist_id=artist.id, )) await db.flush() return post async def _drop(db, artist, source, *, at, ext="fc-drop:1"): post = Post( source_id=source.id, artist_id=artist.id, external_post_id=ext, post_date=at, synthesized_by=DROP_GROUPER, synthesis_details={"message_count": 4, "images_since_surface": 0}, ) db.add(post) await db.flush() return post @pytest.mark.asyncio async def test_a_teaser_and_its_drop_an_hour_apart_are_proposed(db): artist, patreon, discord = await _artist_with_channels(db, "pairartist") now = datetime.now(UTC) teaser = await _teaser( db, artist, patreon, at=now - timedelta(hours=3), body="Full set is up on discord.gg/abc now", ) drop = await _drop(db, artist, discord, at=now - timedelta(hours=2)) await db.commit() made = await PostAssociationService(db).match_post( teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, ) await db.commit() assert made == 1 assoc = (await db.execute(select(PostAssociation))).scalar_one() assert assoc.announcement_post_id == teaser.id assert assoc.payload_post_id == drop.id assert assoc.status == "pending", "nothing is linked without the operator" # The per-signal breakdown survives, so the proposal stays explicable # after the weights or threshold move. assert assoc.signals["declared"] == 1.0 assert assoc.signals["proximity"] > 0.9 @pytest.mark.asyncio async def test_two_unrelated_posts_the_same_day_are_not_proposed(db): """Time proximity alone must not be sufficient, or every busy day becomes a false pair. The teaser here says nothing about Discord.""" artist, patreon, discord = await _artist_with_channels(db, "busyartist") now = datetime.now(UTC) teaser = await _teaser( db, artist, patreon, at=now - timedelta(hours=3), body="Just a sketch I liked", ) await _drop(db, artist, discord, at=now - timedelta(hours=2)) await db.commit() made = await PostAssociationService(db).match_post( teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, ) await db.commit() assert made == 0 assert (await db.execute(select(PostAssociation))).scalars().all() == [] @pytest.mark.asyncio async def test_a_drop_outside_the_window_is_not_proposed(db): artist, patreon, discord = await _artist_with_channels(db, "farapartartist") now = datetime.now(UTC) teaser = await _teaser( db, artist, patreon, at=now - timedelta(days=10), body="Everything is on discord.gg/abc", ) await _drop(db, artist, discord, at=now) await db.commit() assert await PostAssociationService(db).match_post( teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, ) == 0 @pytest.mark.asyncio async def test_another_artists_drop_is_never_proposed(db): """Same-artist is a HARD filter, not a scored signal: two different creators posting minutes apart is a coincidence, not evidence.""" artist_a, patreon_a, _ = await _artist_with_channels(db, "artista") _artist_b, _patreon_b, discord_b = await _artist_with_channels(db, "artistb") now = datetime.now(UTC) teaser = await _teaser( db, artist_a, patreon_a, at=now - timedelta(hours=1), body="new drop on discord.gg/abc", ) await _drop(db, _artist_b, discord_b, at=now) await db.commit() assert await PostAssociationService(db).match_post( teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, ) == 0 @pytest.mark.asyncio async def test_an_artist_with_no_discord_source_produces_nothing_and_no_error(db): artist = Artist(name="soloartist", slug="soloartist") db.add(artist) await db.flush() patreon = Source( artist_id=artist.id, platform="patreon", url="https://patreon.com/solo", enabled=True, ) db.add(patreon) await db.flush() teaser = await _teaser( db, artist, patreon, at=datetime.now(UTC), body="on discord.gg/abc", ext="solo", ) await db.commit() assert await PostAssociationService(db).match_post( teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, ) == 0 @pytest.mark.asyncio async def test_a_synthetic_post_cannot_announce_anything(db): """FC wrote it, so it announces nothing — and a grouping proposing itself as the teaser for another grouping would be pure noise.""" artist, _patreon, discord = await _artist_with_channels(db, "noselfannounce") now = datetime.now(UTC) drop_a = await _drop(db, artist, discord, at=now - timedelta(hours=1), ext="fc-drop:a") await _drop(db, artist, discord, at=now, ext="fc-drop:b") await db.commit() assert await PostAssociationService(db).match_post( drop_a.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, ) == 0 @pytest.mark.asyncio async def test_a_dismissed_pair_is_never_proposed_again(db): """The row is what remembers the rejection. Re-proposing a rejected pair on every scan is the single behaviour that makes a review queue get ignored.""" artist, patreon, discord = await _artist_with_channels(db, "dismissartist") now = datetime.now(UTC) teaser = await _teaser( db, artist, patreon, at=now - timedelta(hours=2), body="discord.gg/abc has the rest", ) await _drop(db, artist, discord, at=now - timedelta(hours=1)) await db.commit() svc = PostAssociationService(db) assert await svc.match_post( teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, ) == 1 await db.commit() assoc = (await db.execute(select(PostAssociation))).scalar_one() await svc.dismiss(assoc.id) await db.commit() assert await svc.match_post( teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, ) == 0 await db.commit() assert len((await db.execute(select(PostAssociation))).scalars().all()) == 1 @pytest.mark.asyncio async def test_only_an_accepted_link_reaches_the_post_payload(db): """A pending proposal is a question for the review queue, not a claim to render beside the artwork.""" artist, patreon, discord = await _artist_with_channels(db, "payloadartist") now = datetime.now(UTC) teaser = await _teaser( db, artist, patreon, at=now - timedelta(hours=2), body="rest is on discord.gg/abc", ) drop = await _drop(db, artist, discord, at=now - timedelta(hours=1)) await db.commit() svc = PostAssociationService(db) await svc.match_post(teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW) await db.commit() feed = PostFeedService(db) assert (await feed.get_post(teaser.id))["associations"] == [] assoc = (await db.execute(select(PostAssociation))).scalar_one() await svc.accept(assoc.id) await db.commit() # Both ends see it, and each sees the OTHER post with its own role. teaser_item = await feed.get_post(teaser.id) assert teaser_item["associations"] == [ {"role": "announces", "post_id": drop.id, "id": assoc.id} ] drop_item = await feed.get_post(drop.id) assert drop_item["associations"] == [ {"role": "announced_by", "post_id": teaser.id, "id": assoc.id} ] @pytest.mark.asyncio async def test_deleting_the_grouping_takes_its_proposals_with_it(db): """E3's reversal path is one DELETE; it must not leave a dangling proposal pointing at a post that no longer exists.""" artist, patreon, discord = await _artist_with_channels(db, "cascadeartist") now = datetime.now(UTC) teaser = await _teaser( db, artist, patreon, at=now - timedelta(hours=2), body="discord.gg/abc", ) drop = await _drop(db, artist, discord, at=now - timedelta(hours=1)) await db.commit() await PostAssociationService(db).match_post( teaser.id, threshold=DEFAULT_THRESHOLD, window_hours=WINDOW, ) await db.commit() assert len((await db.execute(select(PostAssociation))).scalars().all()) == 1 await db.delete(drop) await db.commit() assert (await db.execute(select(PostAssociation))).scalars().all() == [] @pytest.mark.asyncio async def test_the_rescan_is_a_no_op_when_the_switch_is_off(db): settings = await ImportSettings.load(db) settings.discord_link_enabled = False await db.commit() assert await rescan(db) == {"enabled": False, "scanned": 0, "proposed": 0}