test: pin the JS<->Py artist-pattern mirror with a shared sample table (3093)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 5s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
extension / lint (push) Successful in 17s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 2m7s
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 5s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
extension / lint (push) Successful in 17s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 2m7s
`PLATFORM_ARTIST_PATTERNS` (extension/lib/platforms.js) and `_PLATFORM_PATTERNS` (extension_service.py) are two hand-kept copies of one table whose only guard was the comment "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. JS looser than Py shows a button that 400s; Py looser than JS silently never offers a button for a URL the backend would take. #1485 (the Patreon /c/ and /cw/ shapes) was the second of those, 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 instead of the source. `extension/test/artist-url-samples.json` is one table of 25 URL samples — match (with the expected slug) and no_match, each with a `why` — 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. Both halves also assert their own coverage: the sample platforms must equal the platforms that actually have an artist pattern, and every platform must have samples in both directions. Without that, deleting a platform's samples would make the guard pass by testing less. Discord is deliberately in neither table — it is channel-based and has no creator page to put a button on. The no_match half asserts `_derive` RAISES rather than merely missing the platform: it tries every pattern in turn, so a nav page some other platform's pattern happened to swallow would still be accepted by the backend — the same defect wearing a different platform name. Samples live under extension/test/ because that path is excluded from both the XPI file set and the extension version derivation (packaging.sh: NOT_PACKAGED_TRACKED and NOT_VERSION_RELEVANT both carry `test/**`), so adding samples ships no bytes and forces no re-sign. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
"""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 deliberately absent from both: it has no artist pattern on
|
||||
either side, because it is channel-based and has no creator page to put a
|
||||
button on.
|
||||
"""
|
||||
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"
|
||||
Reference in New Issue
Block a user