feat(extension): probe shows current state before click; v1.0.6
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 20s
extension / lint (push) Successful in 13s
CI / intimp (push) Successful in 3m41s
CI / intapi (push) Successful in 7m11s
CI / intcore (push) Successful in 7m41s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 20s
extension / lint (push) Successful in 13s
CI / intimp (push) Successful in 3m41s
CI / intapi (push) Successful in 7m11s
CI / intcore (push) Successful in 7m41s
Operator-asked 2026-05-31 (during sidecar synthetic anchor cleanup): "the add source/subscription button idea to the firefox extension so it can tell me if a source/artist is added or not and offer an option to add it if it isn't." Plan tracked in Scribe task #507. ## Backend - `ExtensionService.probe(url)` — read-only resolution. Reuses `_derive` for platform+slug, then 2 SELECTs. Returns one of: - `source_match` (exact (artist, platform, url) Source exists) - `artist_match` (artist exists, this URL isn't a Source yet; collapses the sidecar-synthetic-only case from v26.06.01.0) - `new` (neither exists) - `unknown_platform` (URL didn't match any artist-page regex) - `GET /api/extension/probe?url=...` route with `X-Extension-Key` auth posture matching `/quick-add-source`. Read-only, side-effect free. - 6 backend tests in tests/test_api_extension.py covering each state + auth + invalid URL. ## Extension - `api.js`: `probeSource(url)` mirroring `quickAddSource` shape. - `background.js`: `PROBE_SOURCE` + `OPEN_ARTIST_PAGE` handlers. The latter strips the `/api` suffix from configured `apiUrl` (placeholder format per options.html) and opens `${base}/artist/{slug}` in a new tab via `browser.tabs.create`. - `content-script.js`: probe-first render — on page-load and SPA navigation, asks the backend for the URL's state and renders the chip in the matching color/copy on FIRST paint instead of flashing generic "Add" and updating after. Click handler branches: `source_match` → OPEN_ARTIST_PAGE; `artist_match`/`new` → existing ADD_AS_SOURCE flow (then re-probes so the chip flips green immediately, no wait for next nav). - `content-script.css`: three state-color modifiers (--new, --artist-match, --source-match) on the FC parchment-on-slate palette. Sage for already-added, amber for artist-exists, accent orange for new. ## Versioning - `extension/manifest.json` + `extension/package.json` → 1.0.6. build.yml's sign-extension job will fire on push to main since no `ext-1.0.6` Forgejo/Gitea release exists yet — exercises the regenerated AMO keys end-to-end. ## Behavior on the sidecar-synthetic case Filesystem-imported "Dymkens"-style artist with only a sidecar synthetic Source: probe returns `artist_match` (not `new`), so the chip reads "+ Add Patreon source to Dymkens" rather than offering to recreate the artist. Clicking adds the real Source; existing `_source_for_sidecar` preference logic (v26.06.01.0) routes future gallery-dl Posts to the real one.
This commit is contained in:
@@ -57,6 +57,24 @@ def _sha256(path: Path) -> str:
|
|||||||
return h.hexdigest()
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@extension_bp.route("/probe", methods=["GET"])
|
||||||
|
async def probe_source():
|
||||||
|
"""Read-only resolution of a creator-page URL: tells the extension
|
||||||
|
whether this URL is already a Source, is for an Artist that exists
|
||||||
|
but with a different URL, is brand new, or doesn't match any known
|
||||||
|
platform pattern. Drives the content-script chip's color/copy
|
||||||
|
BEFORE the operator clicks, so the button can show 'already added'
|
||||||
|
without requiring an add-attempt."""
|
||||||
|
url = (request.args.get("url") or "").strip()
|
||||||
|
if not url:
|
||||||
|
return _bad("invalid_body", detail="url query parameter is required")
|
||||||
|
async with get_session() as session:
|
||||||
|
if not await _ext_key_required(session):
|
||||||
|
return _bad("unauthorized", status=401)
|
||||||
|
result = await ExtensionService(session).probe(url)
|
||||||
|
return jsonify(result)
|
||||||
|
|
||||||
|
|
||||||
@extension_bp.route("/quick-add-source", methods=["POST"])
|
@extension_bp.route("/quick-add-source", methods=["POST"])
|
||||||
async def quick_add_source():
|
async def quick_add_source():
|
||||||
body = await request.get_json(silent=True)
|
body = await request.get_json(silent=True)
|
||||||
|
|||||||
@@ -86,6 +86,67 @@ class ExtensionService:
|
|||||||
"created_artist": created_artist,
|
"created_artist": created_artist,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def probe(self, url: str) -> dict:
|
||||||
|
"""Read-only resolution of a creator-page URL against the FC DB.
|
||||||
|
Returns one of:
|
||||||
|
- {state: 'unknown_platform'} — URL didn't match any
|
||||||
|
platform's strict artist-page pattern
|
||||||
|
- {state: 'new', platform, slug} — would create both
|
||||||
|
artist and source on quick-add
|
||||||
|
- {state: 'artist_match', platform, slug, artist}
|
||||||
|
— artist exists, this
|
||||||
|
exact URL isn't a Source yet (collapses the sidecar-synthetic
|
||||||
|
case too — the synthetic anchor counts as an existing artist
|
||||||
|
row but not as a pollable Source for this URL)
|
||||||
|
- {state: 'source_match', platform, slug, artist, source}
|
||||||
|
— exact (artist, platform,
|
||||||
|
url) Source already exists
|
||||||
|
|
||||||
|
Side-effect-free: two SELECTs at most.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
platform, raw_slug = self._derive(url)
|
||||||
|
except (UnknownPlatformError, InvalidUrlError):
|
||||||
|
return {"state": "unknown_platform"}
|
||||||
|
|
||||||
|
slug = slugify(raw_slug)
|
||||||
|
artist = (await self.session.execute(
|
||||||
|
select(Artist).where(Artist.slug == slug)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if artist is None:
|
||||||
|
return {"state": "new", "platform": platform, "slug": slug}
|
||||||
|
|
||||||
|
artist_payload = {"id": artist.id, "name": artist.name, "slug": artist.slug}
|
||||||
|
|
||||||
|
source = (await self.session.execute(
|
||||||
|
select(Source).where(
|
||||||
|
Source.artist_id == artist.id,
|
||||||
|
Source.platform == platform,
|
||||||
|
Source.url == url,
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if source is None:
|
||||||
|
return {
|
||||||
|
"state": "artist_match",
|
||||||
|
"platform": platform,
|
||||||
|
"slug": slug,
|
||||||
|
"artist": artist_payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"state": "source_match",
|
||||||
|
"platform": platform,
|
||||||
|
"slug": slug,
|
||||||
|
"artist": artist_payload,
|
||||||
|
"source": {
|
||||||
|
"id": source.id,
|
||||||
|
"artist_id": source.artist_id,
|
||||||
|
"platform": source.platform,
|
||||||
|
"url": source.url,
|
||||||
|
"enabled": source.enabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
def _derive(self, url: str) -> tuple[str, str]:
|
def _derive(self, url: str) -> tuple[str, str]:
|
||||||
if not isinstance(url, str) or not url.strip():
|
if not isinstance(url, str) or not url.strip():
|
||||||
raise InvalidUrlError("url is empty")
|
raise InvalidUrlError("url is empty")
|
||||||
|
|||||||
@@ -259,6 +259,29 @@ browser.runtime.onMessage.addListener(async (msg) => {
|
|||||||
return { error: e.message };
|
return { error: e.message };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'PROBE_SOURCE':
|
||||||
|
try {
|
||||||
|
return await api.probeSource(msg.url);
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'OPEN_ARTIST_PAGE': {
|
||||||
|
// apiUrl is configured with the /api suffix (see
|
||||||
|
// options/options.html placeholder); the SPA artist route is
|
||||||
|
// /artist/:slug, served from the same origin. Strip /api so the
|
||||||
|
// browser-level URL hits the Vue router, not the JSON API.
|
||||||
|
const base = (api.baseUrl || '').replace(/\/+$/, '').replace(/\/api$/, '');
|
||||||
|
const slug = encodeURIComponent(msg.slug || '');
|
||||||
|
if (!base || !slug) return { error: 'apiUrl or slug missing' };
|
||||||
|
try {
|
||||||
|
await browser.tabs.create({ url: `${base}/artist/${slug}` });
|
||||||
|
return { success: true };
|
||||||
|
} catch (e) {
|
||||||
|
return { error: e.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return { error: `Unknown message type: ${msg.type}` };
|
return { error: `Unknown message type: ${msg.type}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,26 @@
|
|||||||
background: rgb(20, 23, 26); color: rgb(244, 186, 122);
|
background: rgb(20, 23, 26); color: rgb(244, 186, 122);
|
||||||
font: 500 14px/1.2 system-ui, sans-serif;
|
font: 500 14px/1.2 system-ui, sans-serif;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); cursor: pointer;
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); cursor: pointer;
|
||||||
transition: transform 100ms ease;
|
transition: transform 100ms ease, background 150ms ease, color 150ms ease;
|
||||||
}
|
}
|
||||||
.fc-add-source-btn:hover { transform: translateY(-1px); }
|
.fc-add-source-btn:hover { transform: translateY(-1px); }
|
||||||
.fc-add-source-btn:disabled { opacity: 0.6; cursor: wait; }
|
.fc-add-source-btn:disabled { opacity: 0.6; cursor: wait; }
|
||||||
|
|
||||||
|
/* state colors map to the FC palette: parchment-on-slate base,
|
||||||
|
accent-orange for new, sage for already-subscribed, amber-warning for
|
||||||
|
artist-exists-but-source-missing. All readable on the dark base. */
|
||||||
|
.fc-add-source-btn--new {
|
||||||
|
background: rgb(20, 23, 26); color: rgb(244, 186, 122);
|
||||||
|
}
|
||||||
|
.fc-add-source-btn--artist-match {
|
||||||
|
background: rgb(28, 23, 16); color: rgb(255, 200, 120);
|
||||||
|
border: 1px solid rgb(180, 130, 60);
|
||||||
|
}
|
||||||
|
.fc-add-source-btn--source-match {
|
||||||
|
background: rgb(18, 28, 20); color: rgb(140, 220, 160);
|
||||||
|
border: 1px solid rgb(80, 160, 100);
|
||||||
|
}
|
||||||
|
|
||||||
.fc-toast {
|
.fc-toast {
|
||||||
all: revert;
|
all: revert;
|
||||||
position: fixed; bottom: 84px; right: 24px; z-index: 2147483647;
|
position: fixed; bottom: 84px; right: 24px; z-index: 2147483647;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
if (window.__fc_addsource_injected) return;
|
if (window.__fc_addsource_injected) return;
|
||||||
window.__fc_addsource_injected = true;
|
window.__fc_addsource_injected = true;
|
||||||
|
|
||||||
|
// Cached probe result for the current URL so click-handlers know which
|
||||||
|
// action to dispatch without round-tripping again.
|
||||||
|
let currentProbe = null;
|
||||||
|
|
||||||
evaluate();
|
evaluate();
|
||||||
|
|
||||||
const reEval = () => evaluate();
|
const reEval = () => evaluate();
|
||||||
@@ -9,38 +13,116 @@
|
|||||||
const origPush = history.pushState;
|
const origPush = history.pushState;
|
||||||
history.pushState = function () { origPush.apply(this, arguments); reEval(); };
|
history.pushState = function () { origPush.apply(this, arguments); reEval(); };
|
||||||
|
|
||||||
function evaluate() {
|
async function evaluate() {
|
||||||
const platform = getPlatformFromUrl(window.location.href);
|
const url = window.location.href;
|
||||||
const onArtist = platform && isArtistPage(window.location.href, platform);
|
const platform = getPlatformFromUrl(url);
|
||||||
let btn = document.getElementById('fc-add-source-btn');
|
const onArtist = platform && isArtistPage(url, platform);
|
||||||
if (onArtist && !btn) injectButton();
|
const btn = document.getElementById('fc-add-source-btn');
|
||||||
else if (!onArtist && btn) btn.remove();
|
if (!onArtist) {
|
||||||
|
if (btn) btn.remove();
|
||||||
|
currentProbe = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// On artist pages, ask the backend what state the URL is in BEFORE
|
||||||
|
// injecting the button — so the chip can render the right state on
|
||||||
|
// first paint instead of flashing the generic "Add" copy and
|
||||||
|
// updating afterwards.
|
||||||
|
let probe;
|
||||||
|
try {
|
||||||
|
probe = await browser.runtime.sendMessage({ type: 'PROBE_SOURCE', url });
|
||||||
|
} catch (e) {
|
||||||
|
probe = { error: e?.message || 'probe failed' };
|
||||||
|
}
|
||||||
|
currentProbe = probe;
|
||||||
|
if (probe?.state === 'unknown_platform') {
|
||||||
|
if (btn) btn.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderButton(probe);
|
||||||
}
|
}
|
||||||
|
|
||||||
function injectButton() {
|
function renderButton(probe) {
|
||||||
const btn = document.createElement('button');
|
let btn = document.getElementById('fc-add-source-btn');
|
||||||
btn.id = 'fc-add-source-btn';
|
if (!btn) {
|
||||||
|
btn = document.createElement('button');
|
||||||
|
btn.id = 'fc-add-source-btn';
|
||||||
|
btn.addEventListener('click', onClick);
|
||||||
|
document.body.appendChild(btn);
|
||||||
|
}
|
||||||
|
// Reset state classes so re-renders (SPA navigation) don't stack.
|
||||||
btn.className = 'fc-add-source-btn';
|
btn.className = 'fc-add-source-btn';
|
||||||
btn.textContent = '+ Add to FabledCurator';
|
btn.classList.add(`fc-add-source-btn--${stateModifier(probe)}`);
|
||||||
btn.addEventListener('click', onClick);
|
btn.textContent = labelFor(probe);
|
||||||
document.body.appendChild(btn);
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateModifier(probe) {
|
||||||
|
if (!probe || probe.error) return 'new';
|
||||||
|
return ({
|
||||||
|
source_match: 'source-match',
|
||||||
|
artist_match: 'artist-match',
|
||||||
|
new: 'new',
|
||||||
|
})[probe.state] || 'new';
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFor(probe) {
|
||||||
|
if (!probe || probe.error) return '+ Add to FabledCurator';
|
||||||
|
const platformName = platformDisplayName(probe.platform);
|
||||||
|
const artistName = probe.artist?.name;
|
||||||
|
switch (probe.state) {
|
||||||
|
case 'source_match':
|
||||||
|
return `✓ In FabledCurator · ${platformName}`;
|
||||||
|
case 'artist_match':
|
||||||
|
return `+ Add ${platformName} source to ${artistName || 'artist'}`;
|
||||||
|
case 'new':
|
||||||
|
default:
|
||||||
|
return '+ Add to FabledCurator';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function platformDisplayName(key) {
|
||||||
|
return PLATFORMS[key]?.name || key || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onClick() {
|
async function onClick() {
|
||||||
const btn = document.getElementById('fc-add-source-btn');
|
const btn = document.getElementById('fc-add-source-btn');
|
||||||
|
if (!btn) return;
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
const original = btn.textContent;
|
const original = btn.textContent;
|
||||||
|
const probe = currentProbe;
|
||||||
|
|
||||||
|
if (probe?.state === 'source_match') {
|
||||||
|
btn.textContent = 'Opening…';
|
||||||
|
try {
|
||||||
|
const r = await browser.runtime.sendMessage({
|
||||||
|
type: 'OPEN_ARTIST_PAGE',
|
||||||
|
slug: probe.artist?.slug,
|
||||||
|
});
|
||||||
|
if (r?.error) showToast(`Error: ${r.error}`, 'error');
|
||||||
|
} catch (e) {
|
||||||
|
showToast(`Error: ${e.message}`, 'error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = original;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
btn.textContent = 'Adding…';
|
btn.textContent = 'Adding…';
|
||||||
try {
|
try {
|
||||||
const r = await browser.runtime.sendMessage({
|
const r = await browser.runtime.sendMessage({
|
||||||
type: 'ADD_AS_SOURCE',
|
type: 'ADD_AS_SOURCE',
|
||||||
url: window.location.href,
|
url: window.location.href,
|
||||||
});
|
});
|
||||||
if (r.error) {
|
if (r?.error) {
|
||||||
showToast(`Error: ${r.error}`, 'error');
|
showToast(`Error: ${r.error}`, 'error');
|
||||||
} else {
|
} else {
|
||||||
const verb = r.created_source ? 'Added' : 'Already a source for';
|
const verb = r.created_source ? 'Added' : 'Already a source for';
|
||||||
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success');
|
showToast(`${verb} ${r.artist?.name || 'artist'} (${r.source?.platform || ''})`, 'success');
|
||||||
|
// Re-probe so the chip flips green without waiting for the next
|
||||||
|
// navigation.
|
||||||
|
evaluate();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showToast(`Error: ${e.message}`, 'error');
|
showToast(`Error: ${e.message}`, 'error');
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ class FabledCuratorAPI {
|
|||||||
quickAddSource(url) {
|
quickAddSource(url) {
|
||||||
return this.request('POST', '/extension/quick-add-source', { url });
|
return this.request('POST', '/extension/quick-add-source', { url });
|
||||||
}
|
}
|
||||||
|
probeSource(url) {
|
||||||
|
// Read-only existence check. Drives the content-script chip's
|
||||||
|
// color/copy BEFORE the operator clicks Add.
|
||||||
|
const qs = new URLSearchParams({ url }).toString();
|
||||||
|
return this.request('GET', `/extension/probe?${qs}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Connection test = the cheapest read with auth.
|
// Connection test = the cheapest read with auth.
|
||||||
testConnection() {
|
testConnection() {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "FabledCurator",
|
"name": "FabledCurator",
|
||||||
"version": "1.0.5",
|
"version": "1.0.6",
|
||||||
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
"description": "Export cookies from supported platforms to FabledCurator and add creators as sources in one click.",
|
||||||
|
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fabledcurator-extension",
|
"name": "fabledcurator-extension",
|
||||||
"version": "1.0.5",
|
"version": "1.0.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Firefox extension for FabledCurator",
|
"description": "Firefox extension for FabledCurator",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -135,6 +135,114 @@ async def test_quick_add_source_wrong_key_401(client, ext_key):
|
|||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# --- /api/extension/probe ---------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_returns_new_when_nothing_exists(client, ext_key):
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/extension/probe",
|
||||||
|
query_string={"url": "https://www.patreon.com/freshcreator"},
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["state"] == "new"
|
||||||
|
assert body["platform"] == "patreon"
|
||||||
|
assert body["slug"] == "freshcreator"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_returns_source_match_for_already_added(client, ext_key, db):
|
||||||
|
artist = Artist(name="Alice", slug="alice", is_subscription=True)
|
||||||
|
db.add(artist)
|
||||||
|
await db.flush()
|
||||||
|
src = Source(
|
||||||
|
artist_id=artist.id, platform="patreon",
|
||||||
|
url="https://www.patreon.com/alice", enabled=True, config_overrides={},
|
||||||
|
)
|
||||||
|
db.add(src)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/extension/probe",
|
||||||
|
query_string={"url": "https://www.patreon.com/alice"},
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["state"] == "source_match"
|
||||||
|
assert body["artist"]["slug"] == "alice"
|
||||||
|
assert body["source"]["url"] == "https://www.patreon.com/alice"
|
||||||
|
assert body["source"]["platform"] == "patreon"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_returns_artist_match_when_only_synthetic_anchor_exists(
|
||||||
|
client, ext_key, db,
|
||||||
|
):
|
||||||
|
"""Filesystem-imported artist with only a sidecar synthetic Source
|
||||||
|
for the (artist, platform) — the URL the operator's browsing isn't
|
||||||
|
yet a real Source. The probe should collapse this into artist_match
|
||||||
|
so the chip says '+ Add Patreon source to Dymkens' rather than
|
||||||
|
'+ Add to FabledCurator' (which would re-create the artist)."""
|
||||||
|
artist = Artist(name="Dymkens", slug="dymkens", is_subscription=False)
|
||||||
|
db.add(artist)
|
||||||
|
await db.flush()
|
||||||
|
synthetic = Source(
|
||||||
|
artist_id=artist.id, platform="patreon",
|
||||||
|
url="sidecar:patreon:dymkens", enabled=False, config_overrides={},
|
||||||
|
)
|
||||||
|
db.add(synthetic)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/extension/probe",
|
||||||
|
query_string={"url": "https://www.patreon.com/dymkens"},
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["state"] == "artist_match"
|
||||||
|
assert body["artist"]["slug"] == "dymkens"
|
||||||
|
assert "source" not in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_returns_unknown_platform_for_non_artist_url(client, ext_key):
|
||||||
|
"""A patreon URL that isn't an artist page (e.g. /home, /posts/N)
|
||||||
|
shouldn't trigger the button. Sentinel 'unknown_platform' state
|
||||||
|
tells the content script to skip injection."""
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/extension/probe",
|
||||||
|
query_string={"url": "https://www.patreon.com/posts/12345"},
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["state"] == "unknown_platform"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_missing_key_401(client):
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/extension/probe",
|
||||||
|
query_string={"url": "https://www.patreon.com/maewix"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_probe_missing_url_400(client, ext_key):
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/extension/probe",
|
||||||
|
headers={"X-Extension-Key": ext_key},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
body = await resp.get_json()
|
||||||
|
assert body["error"] == "invalid_body"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_quick_add_source_missing_body_400(client, ext_key):
|
async def test_quick_add_source_missing_body_400(client, ext_key):
|
||||||
resp = await client.post(
|
resp = await client.post(
|
||||||
|
|||||||
Reference in New Issue
Block a user