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
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
121 lines
4.7 KiB
Python
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"
|