Files
FabledCurator/tests/test_extension_artist_patterns.py
T
bvandeusenandClaude Opus 5.5 4c75dd0f88
CI and images / lint (push) Successful in 2s
CI and images / extension-version (push) Successful in 2s
CI and images / extension-test (push) Successful in 20s
CI and images / frontend-build (push) Successful in 24s
CI and images / backend-lint-and-test (push) Successful in 32s
CI and images / integration (push) Successful in 2m22s
CI and images / build-agent (push) Successful in 5s
CI and images / sign-extension (push) Successful in 3m13s
CI and images / build-web (push) Successful in 1m42s
CI and images / smoke-web (push) Successful in 54s
CI and images / promote (push) Successful in 1s
feat: the extension adds Discord channels to an artist you pick, and its tests gate the XPI (milestone 429)
Server (#4420)
- extension_service gains a Discord pattern (server or channel, jump links,
  ptb/canary; not DMs or threads), mirrored in platforms.js and pinned by
  the shared artist-url-samples.json.
- probe on a Discord URL matches the source by ids under any artist, reports
  a whole-server source as covering the channel, suggests the artist who owns
  another source on the same server, and names server/channel via the stored
  token (best-effort, bounded, no rate-limit waits).
- quick-add takes artist_id / artist_name; Discord URLs are stored canonical.

Extension (#4421, #4422)
- Content script on discord.com; SPA navigation by URL polling (the old
  pushState patch ran in the isolated world and never fired); stale probes
  are dropped.
- Discord chip opens an Add panel: this channel or the whole server, and the
  suggested artist / a search / a new name.
- Popup: sources show artist, platform and state; a Discord token export is
  verified by FC and the result shown. Token capture covers ptb/canary.
- Pure logic in lib/chip.js and lib/popup-format.js, with specs.

CI (#4423)
- extension.yml's lane (web-ext lint, vitest, XPI contents) moves into
  build.yml as extension-test and joins the needs of sign-extension,
  build-web and build-agent. As a separate workflow it gated nothing: a red
  extension suite still signed and shipped the XPI (rule 177).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-24 23:31:38 -04:00

121 lines
4.7 KiB
Python

"""The Python half of the JS<->Py artist-pattern mirror guard (#3093).
`extension/lib/platforms.js` PLATFORM_ARTIST_PATTERNS and
`extension_service._PLATFORM_PATTERNS` are two hand-kept copies of one table.
Both files say "keep in sync by hand; reviewers catch drift" — the same
guarantee `manifest.json` had before #3069, where deviantart sat in the
manifest for seven weeks after the product dropped it.
Drift here is worse than the manifest case, because the two copies gate
opposite halves of ONE interaction:
- the **JS** copy decides whether the "Add to FC" button appears;
- the **Python** copy decides whether the resulting POST is accepted.
So JS-looser-than-Py shows the operator a button that 400s, and
Py-looser-than-JS silently never offers a button for a URL the backend would
happily take. #1485 (Patreon's `/c/` and `/cw/` shapes) was exactly the
second, and its fix had to be applied to both files by hand.
The two-runtimes objection to a shared source file is fair, so this tests the
INVARIANT rather than the source: one table of URL samples, read by both
suites and asserted against each one's own copy of the patterns. Neither
runtime imports the other. A change to one copy alone turns the other
runtime's suite red.
The sibling half is `extension/test/platforms.spec.js`, which reads the same
file. Adding a sample there covers it here for free, and vice versa — which is
the property that makes the guard cheap enough to keep using.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from backend.app.services.extension_service import (
ExtensionService,
UnknownPlatformError,
)
_SAMPLES_PATH = (
Path(__file__).resolve().parents[1]
/ "extension" / "test" / "artist-url-samples.json"
)
def _load_samples() -> dict:
"""The shared table, minus its `$comment` preamble."""
raw = json.loads(_SAMPLES_PATH.read_text(encoding="utf-8"))
return {k: v for k, v in raw.items() if not k.startswith("$")}
_SAMPLES = _load_samples()
_MATCH_CASES = [
pytest.param(platform, entry["url"], entry["slug"], entry["why"],
id=f"{platform}-match-{i}")
for platform, spec in _SAMPLES.items()
for i, entry in enumerate(spec["match"])
]
_NO_MATCH_CASES = [
pytest.param(platform, entry["url"], entry["why"],
id=f"{platform}-nomatch-{i}")
for platform, spec in _SAMPLES.items()
for i, entry in enumerate(spec["no_match"])
]
def _derive(url: str) -> tuple[str, str]:
"""`_derive` needs no session — it is pure regex over the URL."""
return ExtensionService(session=None)._derive(url)
@pytest.mark.parametrize("platform,url,slug,why", _MATCH_CASES)
def test_creator_url_derives_the_expected_platform_and_slug(platform, url, slug, why):
"""A URL the extension would show the button on must be one the backend
accepts, and it must derive the SAME creator. The slug is asserted, not
just the platform: deriving the wrong creator from a URL both copies agree
on is its own defect, and nothing else pins it."""
assert _derive(url) == (platform, slug), why
@pytest.mark.parametrize("platform,url,why", _NO_MATCH_CASES)
def test_non_creator_url_derives_nothing(platform, url, why):
"""The other direction, and the one that fails silently. A URL the
extension refuses to show the button on must also be one the backend
refuses — otherwise the backend is quietly looser than the button, and
nobody finds out because nothing visibly breaks.
`_derive` is asserted to raise rather than merely to miss `platform`: it
tries every pattern in turn, so a nav page that some OTHER platform's
pattern happened to swallow would still be 'accepted by the backend',
which is the same defect wearing a different platform name.
"""
with pytest.raises(UnknownPlatformError):
_derive(url)
def test_the_sample_table_covers_every_platform_that_has_a_pattern():
"""The guard's own coverage check. Without it, deleting a platform's
samples would make this file pass by testing less — the failure mode that
makes absence-based tests untrustworthy (snippet #3352).
Discord is in the table since milestone 429: its "slug" is the
server/channel pair, and the Add panel picks the artist.
"""
from backend.app.services.extension_service import _PLATFORM_PATTERNS
assert set(_SAMPLES) == {platform for platform, _ in _PLATFORM_PATTERNS}
def test_every_platform_has_samples_in_both_directions():
"""A platform with only positive samples pins half the invariant. The
no_match half is the one that catches a pattern quietly widening."""
for platform, spec in _SAMPLES.items():
assert spec["match"], f"{platform} has no match samples"
assert spec["no_match"], f"{platform} has no no_match samples"