CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
CI and images / extension-test (push) Successful in 19s
CI and images / frontend-build (push) Successful in 25s
CI and images / backend-lint-and-test (push) Successful in 33s
CI and images / integration (push) Successful in 2m22s
CI and images / build-agent (push) Successful in 6s
CI and images / sign-extension (push) Successful in 4m48s
CI and images / build-web (push) Failing after 6s
CI and images / smoke-web (push) Skipped
CI and images / promote (push) Skipped
Operator: "yes add the artist picker to patreon and subscribestar too. but generally we treat the patreon name as the canon" - Every add now goes through the panel. On Patreon/SubscribeStar it opens on the creator's display name (read only when the panel opens: probe?names=1), searches FC's artists with it, and auto-picks a match. An untouched URL handle sends no name, so the server still resolves it. - Patreon is canon: joining a Patreon source to an artist known by another name offers "Rename “x” to the Patreon name “X”", ticked by default. quick-add's use_platform_name renames server-side from the name it reads itself; name only, the slug never moves (#130); never to a URL handle when the name can't be read; ignored on SubscribeStar and Discord. - _platform_display_name returns None rather than the handle, bounded by the same 6s lookup budget as Discord's names. - panelDefaults / addRequest / renameOffer replace the Discord-only helpers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
787 lines
30 KiB
Python
787 lines
30 KiB
Python
"""FC-3g: /api/extension and /extension/<filename> integration tests."""
|
|
|
|
import hashlib
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from sqlalchemy import func, select
|
|
|
|
from backend.app import frontend as frontend_module
|
|
from backend.app.api import extension as extension_module
|
|
from backend.app.models import AppSetting, Artist, Source
|
|
|
|
pytestmark = pytest.mark.integration
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def ext_key(db):
|
|
db.add(AppSetting(key="extension_api_key", value="test-ext-key"))
|
|
await db.commit()
|
|
return "test-ext-key"
|
|
|
|
|
|
# --- /api/extension/quick-add-source ---------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_add_source_creates_artist_and_source(client, ext_key, db_sync):
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": "https://www.patreon.com/maewix"},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 201
|
|
body = await resp.get_json()
|
|
assert body["created_artist"] is True
|
|
assert body["created_source"] is True
|
|
assert body["artist"]["slug"] == "maewix"
|
|
assert body["source"]["platform"] == "patreon"
|
|
assert body["source"]["enabled"] is True
|
|
|
|
# Async Core-DML assertion: column select, not ORM attribute access.
|
|
artist_count = db_sync.execute(
|
|
select(func.count(Artist.id)).where(Artist.slug == "maewix")
|
|
).scalar_one()
|
|
assert artist_count == 1
|
|
source_count = db_sync.execute(
|
|
select(func.count(Source.id)).where(
|
|
Source.platform == "patreon",
|
|
Source.url == "https://www.patreon.com/maewix",
|
|
)
|
|
).scalar_one()
|
|
assert source_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_add_source_idempotent(client, ext_key):
|
|
body = {"url": "https://www.subscribestar.com/some-creator"}
|
|
headers = {"X-Extension-Key": ext_key}
|
|
r1 = await client.post("/api/extension/quick-add-source", json=body, headers=headers)
|
|
r2 = await client.post("/api/extension/quick-add-source", json=body, headers=headers)
|
|
assert r1.status_code == 201
|
|
assert r2.status_code == 200
|
|
body2 = await r2.get_json()
|
|
assert body2["created_source"] is False
|
|
assert body2["created_artist"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_add_reuses_source_artist_after_rename(client, ext_key):
|
|
# #130 identity-by-source: after renaming the artist (slug frozen ≠ new
|
|
# name-slug), re-adding the SAME source must reuse it — a slug-based lookup
|
|
# would miss and duplicate the artist.
|
|
url = "https://www.subscribestar.com/renamed-creator"
|
|
h = {"X-Extension-Key": ext_key}
|
|
a1 = (await (await client.post(
|
|
"/api/extension/quick-add-source", json={"url": url}, headers=h
|
|
)).get_json())["artist"]
|
|
await client.patch(f"/api/artists/{a1['id']}", json={"name": "Totally Different"})
|
|
b2 = await (await client.post(
|
|
"/api/extension/quick-add-source", json={"url": url}, headers=h
|
|
)).get_json()
|
|
assert b2["created_artist"] is False
|
|
assert b2["artist"]["id"] == a1["id"]
|
|
assert b2["artist"]["name"] == "Totally Different"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_artist_name_dispatches_per_platform(db, monkeypatch):
|
|
# #130: each native platform resolves its real display name at add-time
|
|
# (patreon=campaigns API, subscribestar=profile page); gallery-dl platforms
|
|
# and any failure fall back to the URL handle.
|
|
from backend.app.services import patreon_resolver
|
|
from backend.app.services.credential_service import CredentialService
|
|
from backend.app.services.extension_service import ExtensionService
|
|
from backend.app.services.subscribestar_client import SubscribeStarClient
|
|
|
|
async def _cookies(self, platform):
|
|
return "/tmp/cookies.txt"
|
|
|
|
monkeypatch.setattr(CredentialService, "get_cookies_path", _cookies)
|
|
monkeypatch.setattr(patreon_resolver, "resolve_display_name", lambda v, c: "Patreon Name")
|
|
monkeypatch.setattr(SubscribeStarClient, "resolve_display_name", lambda self, u: "SS Name")
|
|
|
|
svc = ExtensionService(db, crypto=object()) # crypto seam only (calls stubbed)
|
|
assert await svc._resolve_artist_name(
|
|
"patreon", "maewix", "https://patreon.com/maewix") == "Patreon Name"
|
|
assert await svc._resolve_artist_name(
|
|
"subscribestar", "sabu", "https://subscribestar.adult/sabu") == "SS Name"
|
|
# gallery-dl platform → readable handle passthrough (no resolver).
|
|
assert await svc._resolve_artist_name("hentaifoundry", "Foo", "u") == "Foo"
|
|
# No crypto → no resolution attempt → the raw handle.
|
|
assert await ExtensionService(db)._resolve_artist_name("patreon", "maewix", "u") == "maewix"
|
|
# Resolver returns None → fall back to the handle.
|
|
monkeypatch.setattr(patreon_resolver, "resolve_display_name", lambda v, c: None)
|
|
assert await svc._resolve_artist_name("patreon", "maewix", "u") == "maewix"
|
|
|
|
|
|
@pytest.mark.parametrize("url,platform,slug", [
|
|
("https://www.patreon.com/maewix", "patreon", "maewix"),
|
|
("https://patreon.com/maewix", "patreon", "maewix"),
|
|
("https://www.subscribestar.com/foobar", "subscribestar", "foobar"),
|
|
("https://subscribestar.adult/foobar", "subscribestar", "foobar"),
|
|
("https://www.hentai-foundry.com/user/Foo/profile", "hentaifoundry", "Foo"),
|
|
])
|
|
@pytest.mark.asyncio
|
|
async def test_quick_add_source_url_patterns(client, ext_key, url, platform, slug):
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": url},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 201, await resp.get_json()
|
|
body = await resp.get_json()
|
|
assert body["source"]["platform"] == platform
|
|
# slugify lowercases — the Artist slug should reflect that.
|
|
assert body["artist"]["slug"] == slug.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_add_source_unknown_url_400(client, ext_key):
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": "https://example.com/foo"},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "unknown_platform"
|
|
assert "known" in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_add_source_rejects_retired_deviantart(client, ext_key):
|
|
"""#3069: a DeviantArt creator URL used to derive cleanly. Now that the
|
|
platform is retired, the extension's own gate should never offer the
|
|
button — but a stale content script on an un-updated browser still can,
|
|
so the backend has to refuse it rather than create an unusable source."""
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": "https://www.deviantart.com/baz"},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "unknown_platform"
|
|
assert "deviantart" not in body["known"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_add_source_rejects_retired_pixiv(client, ext_key):
|
|
"""Milestone #406: the same shape as deviantart's retirement above. An
|
|
un-updated extension can still offer the button on a pixiv creator page, so
|
|
the backend refuses rather than creating a source nothing can download."""
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": "https://www.pixiv.net/users/12345"},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "unknown_platform"
|
|
assert "pixiv" not in body["known"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_add_source_invalid_url_400(client, ext_key):
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": "not-a-url"},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "invalid_url"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_add_source_missing_key_401(client):
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": "https://www.patreon.com/maewix"},
|
|
)
|
|
assert resp.status_code == 401
|
|
body = await resp.get_json()
|
|
assert body["error"] == "unauthorized"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_add_source_wrong_key_401(client, ext_key):
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": "https://www.patreon.com/maewix"},
|
|
headers={"X-Extension-Key": "wrong-key"},
|
|
)
|
|
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
|
|
async def test_quick_add_source_missing_body_400(client, ext_key):
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 400
|
|
body = await resp.get_json()
|
|
assert body["error"] == "invalid_body"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_add_source_attaches_to_existing_artist(client, ext_key, db, db_sync):
|
|
db.add(Artist(name="Maewix Original", slug="maewix", is_subscription=False))
|
|
await db.commit()
|
|
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": "https://www.patreon.com/maewix"},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 201
|
|
body = await resp.get_json()
|
|
assert body["created_artist"] is False
|
|
assert body["created_source"] is True
|
|
|
|
artist_count = db_sync.execute(
|
|
select(func.count(Artist.id)).where(Artist.slug == "maewix")
|
|
).scalar_one()
|
|
assert artist_count == 1 # no duplicate created
|
|
|
|
|
|
# --- Discord: channels are added to a CHOSEN artist (milestone 429) ---
|
|
|
|
|
|
_GUILD = "111111111111111111"
|
|
_CHAN = "222222222222222222"
|
|
_OTHER_CHAN = "333333333333333333"
|
|
|
|
|
|
async def _discord_source(db, name, url):
|
|
artist = Artist(name=name, slug=name.lower(), is_subscription=True)
|
|
db.add(artist)
|
|
await db.flush()
|
|
src = Source(artist_id=artist.id, platform="discord", url=url,
|
|
enabled=True, config_overrides={})
|
|
db.add(src)
|
|
await db.commit()
|
|
return artist, src
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_discord_channel_is_added_to_the_artist_the_operator_picked(
|
|
client, ext_key, db, db_sync,
|
|
):
|
|
artist = Artist(name="Tamada", slug="tamada", is_subscription=True)
|
|
db.add(artist)
|
|
await db.commit()
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
# A jump link on the ptb host still names the channel.
|
|
json={"url": f"https://ptb.discord.com/channels/{_GUILD}/{_CHAN}/999",
|
|
"artist_id": artist.id},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 201, await resp.get_json()
|
|
body = await resp.get_json()
|
|
assert body["artist"]["id"] == artist.id
|
|
assert body["created_artist"] is False
|
|
# Stored canonical, so the manual form and the ingester read the same URL.
|
|
assert body["source"]["url"] == f"https://discord.com/channels/{_GUILD}/{_CHAN}"
|
|
assert body["source"]["platform"] == "discord"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_discord_add_can_name_a_new_artist(client, ext_key):
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": f"https://discord.com/channels/{_GUILD}", "artist_name": "Studio Q"},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 201
|
|
body = await resp.get_json()
|
|
assert body["artist"]["name"] == "Studio Q"
|
|
assert body["created_artist"] is True
|
|
assert body["source"]["url"] == f"https://discord.com/channels/{_GUILD}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_discord_add_with_no_artist_and_no_token_names_the_server(client, ext_key):
|
|
"""No stored token means no server name to read; the fallback still names
|
|
a readable artist rather than slugifying the ids."""
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}"},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 201
|
|
assert (await resp.get_json())["artist"]["name"] == f"Discord {_GUILD}"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_re_adding_a_discord_channel_keeps_its_artist(client, ext_key, db):
|
|
"""Identity by source (#130), compared by ids: a row stored with a
|
|
trailing slash is the same channel, and naming another artist does not
|
|
move it."""
|
|
owner, src = await _discord_source(
|
|
db, "Owner", f"https://discord.com/channels/{_GUILD}/{_CHAN}/",
|
|
)
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}",
|
|
"artist_name": "Someone Else"},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["source"]["id"] == src.id
|
|
assert body["artist"]["id"] == owner.id
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_add_with_a_missing_artist_id_is_404(client, ext_key):
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}", "artist_id": 987654},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_quick_add_rejects_a_non_integer_artist_id(client, ext_key):
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": f"https://discord.com/channels/{_GUILD}/{_CHAN}", "artist_id": "7"},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
async def _probe(client, ext_key, url):
|
|
resp = await client.get(
|
|
"/api/extension/probe", query_string={"url": url},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 200
|
|
return await resp.get_json()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_a_fresh_discord_channel_is_new_with_both_urls(client, ext_key):
|
|
body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}")
|
|
assert body["state"] == "new"
|
|
assert body["platform"] == "discord"
|
|
d = body["discord"]
|
|
assert d["server_id"] == _GUILD and d["channel_id"] == _CHAN
|
|
assert d["server_url"] == f"https://discord.com/channels/{_GUILD}"
|
|
assert d["channel_url"] == f"https://discord.com/channels/{_GUILD}/{_CHAN}"
|
|
# No token stored → no names, and no error.
|
|
assert d["server_name"] is None and d["channel_name"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_suggests_the_artist_who_owns_another_channel_on_the_server(
|
|
client, ext_key, db,
|
|
):
|
|
owner, _ = await _discord_source(
|
|
db, "Owner", f"https://discord.com/channels/{_GUILD}/{_OTHER_CHAN}",
|
|
)
|
|
body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}")
|
|
assert body["state"] == "artist_match"
|
|
assert body["artist"]["id"] == owner.id
|
|
assert "source" not in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_finds_the_channel_under_any_artist(client, ext_key, db):
|
|
owner, src = await _discord_source(
|
|
db, "Owner", f"https://discord.com/channels/{_GUILD}/{_CHAN}",
|
|
)
|
|
body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}/555")
|
|
assert body["state"] == "source_match"
|
|
assert body["source"]["id"] == src.id
|
|
assert body["artist"]["id"] == owner.id
|
|
assert body["covered_by_server"] is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_a_channel_is_covered_by_a_whole_server_source(client, ext_key, db):
|
|
_, src = await _discord_source(db, "Owner", f"https://discord.com/channels/{_GUILD}")
|
|
body = await _probe(client, ext_key, f"https://discord.com/channels/{_GUILD}/{_CHAN}")
|
|
assert body["state"] == "source_match"
|
|
assert body["source"]["id"] == src.id
|
|
assert body["covered_by_server"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_a_discord_dm_is_not_a_source(client, ext_key):
|
|
body = await _probe(client, ext_key, f"https://discord.com/channels/@me/{_CHAN}")
|
|
assert body["state"] == "unknown_platform"
|
|
|
|
|
|
# --- Patreon is canon: the Add panel's names and the rename (milestone 429) ---
|
|
|
|
|
|
@pytest.fixture
|
|
def platform_names(monkeypatch):
|
|
"""Stub both platforms' display-name lookups (no network in tests)."""
|
|
from backend.app.services import patreon_resolver
|
|
from backend.app.services.credential_service import CredentialService
|
|
from backend.app.services.subscribestar_client import SubscribeStarClient
|
|
|
|
async def _cookies(self, platform):
|
|
return "/tmp/cookies.txt"
|
|
|
|
names = {"patreon": "Tamada Heijun", "subscribestar": "SS Tamada"}
|
|
monkeypatch.setattr(CredentialService, "get_cookies_path", _cookies)
|
|
monkeypatch.setattr(
|
|
patreon_resolver, "resolve_display_name", lambda v, c: names["patreon"],
|
|
)
|
|
monkeypatch.setattr(
|
|
SubscribeStarClient, "resolve_display_name", lambda self, u: names["subscribestar"],
|
|
)
|
|
return names
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_probe_with_names_reads_the_patreon_display_name(client, ext_key, platform_names):
|
|
resp = await client.get(
|
|
"/api/extension/probe",
|
|
query_string={"url": "https://www.patreon.com/tamadaheijun", "names": "1"},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
body = await resp.get_json()
|
|
assert body["state"] == "new"
|
|
assert body["display_name"] == "Tamada Heijun"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_plain_probe_does_not_look_the_name_up(client, ext_key, platform_names):
|
|
resp = await client.get(
|
|
"/api/extension/probe",
|
|
query_string={"url": "https://www.patreon.com/tamadaheijun"},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert "display_name" not in await resp.get_json()
|
|
|
|
|
|
async def _artist(db, name, slug):
|
|
artist = Artist(name=name, slug=slug, is_subscription=True)
|
|
db.add(artist)
|
|
await db.commit()
|
|
return artist
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_patreon_source_renames_the_artist_it_joins_to_the_patreon_name(
|
|
client, ext_key, db, db_sync, platform_names,
|
|
):
|
|
artist = await _artist(db, "tamada", "tamada")
|
|
resp = await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": "https://www.patreon.com/tamadaheijun",
|
|
"artist_id": artist.id, "use_platform_name": True},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)
|
|
assert resp.status_code == 201
|
|
body = await resp.get_json()
|
|
assert body["artist"]["name"] == "Tamada Heijun"
|
|
assert body["renamed_from"] == "tamada"
|
|
# Name only: the slug, and every path keyed off it, stays.
|
|
row = db_sync.execute(select(Artist.name, Artist.slug).where(Artist.id == artist.id)).one()
|
|
assert tuple(row) == ("Tamada Heijun", "tamada")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_rename_without_the_flag(client, ext_key, db, platform_names):
|
|
artist = await _artist(db, "tamada", "tamada")
|
|
body = await (await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": "https://www.patreon.com/tamadaheijun", "artist_id": artist.id},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)).get_json()
|
|
assert body["artist"]["name"] == "tamada"
|
|
assert "renamed_from" not in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_an_unreadable_patreon_name_never_renames_to_the_handle(
|
|
client, ext_key, db, platform_names,
|
|
):
|
|
platform_names["patreon"] = None
|
|
artist = await _artist(db, "Tamada", "tamada")
|
|
body = await (await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": "https://www.patreon.com/tamadaheijun",
|
|
"artist_id": artist.id, "use_platform_name": True},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)).get_json()
|
|
assert body["artist"]["name"] == "Tamada"
|
|
assert "renamed_from" not in body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_only_patreon_names_are_canon(client, ext_key, db, platform_names):
|
|
"""A SubscribeStar source joins the picked artist under the name it has."""
|
|
artist = await _artist(db, "Tamada Heijun", "tamada-heijun")
|
|
body = await (await client.post(
|
|
"/api/extension/quick-add-source",
|
|
json={"url": "https://subscribestar.adult/tamada",
|
|
"artist_id": artist.id, "use_platform_name": True},
|
|
headers={"X-Extension-Key": ext_key},
|
|
)).get_json()
|
|
assert body["artist"]["name"] == "Tamada Heijun"
|
|
assert "renamed_from" not in body
|
|
|
|
|
|
# --- /api/extension/manifest ---------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extension_manifest_returns_404_when_dir_missing(client, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path / "does-not-exist")
|
|
resp = await client.get("/api/extension/manifest")
|
|
assert resp.status_code == 404
|
|
body = await resp.get_json()
|
|
assert body == {"installed": False}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extension_manifest_returns_404_when_no_xpi_files(client, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path)
|
|
resp = await client.get("/api/extension/manifest")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extension_manifest_returns_metadata_when_xpi_present(client, monkeypatch, tmp_path):
|
|
xpi = tmp_path / "fabledcurator-1.2.3.xpi"
|
|
xpi.write_bytes(b"fake-xpi-content")
|
|
monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path)
|
|
resp = await client.get("/api/extension/manifest")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["installed"] is True
|
|
assert body["version"] == "1.2.3"
|
|
assert body["xpi_url"] == "/extension/fabledcurator-1.2.3.xpi"
|
|
assert body["latest_url"] == "/extension/fabledcurator-latest.xpi"
|
|
assert body["sha256"] == hashlib.sha256(b"fake-xpi-content").hexdigest()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extension_manifest_reports_the_channel_the_image_declares(
|
|
client, monkeypatch, tmp_path
|
|
):
|
|
"""The channel travels BESIDE the version, never inside it.
|
|
|
|
Folding it in as a `1.0.3499884-dev` suffix is the failure this design
|
|
exists to avoid: the extension's comparator parses each dotted segment as
|
|
an integer, so a suffixed segment collapses to 0 and every dev build
|
|
compares equal to every other — "no update available" and "I cannot read
|
|
this version" stop being distinguishable. Asserting the two are separate
|
|
keys is what keeps a future edit from merging them.
|
|
"""
|
|
(tmp_path / "fabledcurator-1.2.3.xpi").write_bytes(b"x")
|
|
monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path)
|
|
monkeypatch.setattr(extension_module, "FC_CHANNEL", "dev")
|
|
resp = await client.get("/api/extension/manifest")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert body["channel"] == "dev"
|
|
assert body["version"] == "1.2.3"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_extension_manifest_omits_the_channel_when_the_image_declares_none(
|
|
client, monkeypatch, tmp_path
|
|
):
|
|
"""A local build, or any image from before the field existed.
|
|
|
|
The key must be ABSENT rather than present-and-empty: absence is the state
|
|
every consumer already handles (an older image conveys it by not having the
|
|
key at all), so a blank channel reuses that path instead of introducing a
|
|
second spelling of "unknown" for each reader to special-case.
|
|
"""
|
|
(tmp_path / "fabledcurator-1.2.3.xpi").write_bytes(b"x")
|
|
monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path)
|
|
monkeypatch.setattr(extension_module, "FC_CHANNEL", "")
|
|
resp = await client.get("/api/extension/manifest")
|
|
assert resp.status_code == 200
|
|
body = await resp.get_json()
|
|
assert "channel" not in body
|
|
# Everything else still answers — an image with no channel is not a
|
|
# degraded one, it just cannot say which channel it came from.
|
|
assert body["installed"] is True
|
|
assert body["latest_url"] == "/extension/fabledcurator-latest.xpi"
|
|
|
|
|
|
# --- /extension/<filename> -----------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_serve_extension_rejects_non_xpi_filename(client, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
|
|
resp = await client.get("/extension/passwd")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_serve_extension_rejects_path_traversal(client, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
|
|
# Path-traversal attempt — the route regex alone catches this since
|
|
# `..` characters aren't in [\w.-]+, but cover the case anyway.
|
|
resp = await client.get("/extension/fabledcurator-..%2Fetc%2Fpasswd.xpi")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_serve_extension_404_when_xpi_missing(client, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
|
|
resp = await client.get("/extension/fabledcurator-1.0.0.xpi")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_serve_extension_serves_specific_xpi_with_correct_mime(
|
|
client, monkeypatch, tmp_path,
|
|
):
|
|
xpi = tmp_path / "fabledcurator-1.0.0.xpi"
|
|
xpi.write_bytes(b"xpi-bytes")
|
|
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
|
|
resp = await client.get("/extension/fabledcurator-1.0.0.xpi")
|
|
assert resp.status_code == 200
|
|
assert resp.headers["Content-Type"].startswith("application/x-xpinstall")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_serve_extension_latest_returns_most_recent_xpi(
|
|
client, monkeypatch, tmp_path,
|
|
):
|
|
import os
|
|
import time
|
|
|
|
older = tmp_path / "fabledcurator-1.0.0.xpi"
|
|
newer = tmp_path / "fabledcurator-1.0.1.xpi"
|
|
older.write_bytes(b"old")
|
|
newer.write_bytes(b"new")
|
|
os.utime(older, (time.time() - 10, time.time() - 10))
|
|
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
|
|
resp = await client.get("/extension/fabledcurator-latest.xpi")
|
|
assert resp.status_code == 200
|
|
data = await resp.get_data()
|
|
assert data == b"new"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_serve_extension_latest_404_when_dir_empty(client, monkeypatch, tmp_path):
|
|
monkeypatch.setattr(frontend_module, "XPI_DIR", tmp_path)
|
|
resp = await client.get("/extension/fabledcurator-latest.xpi")
|
|
assert resp.status_code == 404
|