Files
FabledCurator/tests/test_artist_directory_service.py
T
bvandeusenandClaude Opus 5 ddf896078c
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 23s
extension / lint (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m43s
refactor(platforms): retire deviantart end-to-end (#3069)
Executes the 2026-07-05 product decision (FC downloaders = art-dedicated
services only), which removed Twitter/X and Bluesky but left deviantart
fully wired for seven weeks — the half-retired state rule 22 exists to
prevent.

Removed: the PlatformInfo module and its registry entry, the gallery-dl
extractor block, extension_service's artist-page pattern, the extension's
PLATFORMS + PLATFORM_ARTIST_PATTERNS entries, its manifest host permission
and content-script match, the frontend icon/colour/label, and the operator-
facing "supported platforms" list that still advertised it.

Two judgment calls, both recorded in migration 0088:

  * existing `source` rows are DISABLED, not deleted. The row is the only
    record of the artist's DeviantArt URL. Disabling is also required for
    correctness rather than tidiness: with the platform unregistered the
    download path falls through to gallery-dl, which carries its OWN
    deviantart extractor, so an enabled row would have kept downloading
    from a dropped platform.
  * the `credential` row IS deleted — a live session cookie for a site FC
    will never call again.

Adds the invariant whose absence is why manifest.json drifted in the first
place: nothing tied its domain lists back to the platform table. The
extension suite now asserts both directions, plus that no host permission
belongs to an unclaimed domain (`*://*/*` exempted — FC is self-hosted at
an operator-chosen URL the extension cannot enumerate).

Extension version 1.0.10 -> 1.0.11: ci.yml's guard hard-fails a packaged
extension change without a bump. No release is cut — build.yml's
sign-extension job only runs on main.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 07:24:08 -04:00

266 lines
8.5 KiB
Python

"""FC-3f: ArtistDirectoryService unit tests.
Mirrors TagDirectoryService's test shape — covers cursor encode/decode,
ordering by (name ASC, id ASC), substring q filter, platform EXISTS
filter (with the row-duplication trap), pagination, image_count via
LEFT JOIN, and the window-function preview helper.
"""
import hashlib
import pytest
from backend.app.models import Artist, ImageRecord, Source
from backend.app.services.artist_directory_service import (
ArtistDirectoryService,
_decode,
_encode,
)
pytestmark = pytest.mark.integration
# --- Cursor round-trip ----------------------------------------------------
def test_cursor_round_trip():
c = _encode("alice", 5)
assert _decode(c) == ("alice", 5)
def test_decode_cursor_rejects_garbage():
with pytest.raises(ValueError):
_decode("not-base64!!!")
def test_cursor_round_trip_handles_pipes_in_name():
# Names can theoretically contain '|'; rsplit on the LAST '|' must
# still recover (name, id) correctly.
c = _encode("a|b|c", 7)
assert _decode(c) == ("a|b|c", 7)
# --- Fixture builders -----------------------------------------------------
async def _seed_artist(db, name: str, *, is_subscription: bool = False):
a = Artist(
name=name, slug=name.lower().replace(" ", "-"),
is_subscription=is_subscription,
)
db.add(a)
await db.flush()
return a
async def _seed_source(db, artist_id: int, platform: str, url: str):
s = Source(artist_id=artist_id, platform=platform, url=url, enabled=True)
db.add(s)
await db.flush()
return s
async def _seed_image(db, artist_id: int, suffix: str):
# Derive a stable, collision-free digest from the suffix. The old
# `abs(hash(suffix)) % 10000` used PYTHONHASHSEED-randomized hash() over only
# 10k buckets, so two suffixes in one test could birthday-collide and violate
# uq_image_record_sha256 — flaky per process seed (CI run 1182).
db.add(ImageRecord(
path=f"/images/{suffix}.jpg",
sha256=hashlib.sha256(suffix.encode()).hexdigest(),
size_bytes=10, mime="image/jpeg", width=10, height=10,
origin="downloaded", artist_id=artist_id,
))
await db.flush()
# --- Ordering + pagination -----------------------------------------------
@pytest.mark.asyncio
async def test_list_orders_by_name_asc(db):
await _seed_artist(db, "zara-order")
await _seed_artist(db, "alice-order")
await _seed_artist(db, "miles-order")
await db.commit()
page = await ArtistDirectoryService(db).list_artists(
q="-order", platform=None, cursor=None, limit=60,
)
names = [c["name"] for c in page.cards]
assert names == ["alice-order", "miles-order", "zara-order"]
@pytest.mark.asyncio
async def test_list_pagination_across_two_pages(db):
artists = []
for letter in "abcde":
a = await _seed_artist(db, f"{letter}-page")
artists.append(a)
await db.commit()
page1 = await ArtistDirectoryService(db).list_artists(
q="-page", platform=None, cursor=None, limit=2,
)
assert [c["name"] for c in page1.cards] == ["a-page", "b-page"]
assert page1.next_cursor is not None
page2 = await ArtistDirectoryService(db).list_artists(
q="-page", platform=None, cursor=page1.next_cursor, limit=2,
)
assert [c["name"] for c in page2.cards] == ["c-page", "d-page"]
assert page2.next_cursor is not None
page3 = await ArtistDirectoryService(db).list_artists(
q="-page", platform=None, cursor=page2.next_cursor, limit=2,
)
assert [c["name"] for c in page3.cards] == ["e-page"]
assert page3.next_cursor is None
# --- q filter ------------------------------------------------------------
@pytest.mark.asyncio
async def test_list_q_filter_case_insensitive_substring(db):
await _seed_artist(db, "Alice-Q")
await _seed_artist(db, "Bob-Q")
await _seed_artist(db, "Charlie-Q")
await db.commit()
page = await ArtistDirectoryService(db).list_artists(
q="ALICE", platform=None, cursor=None, limit=60,
)
names = [c["name"] for c in page.cards]
assert names == ["Alice-Q"]
# --- platform filter (correlated EXISTS, not JOIN) -----------------------
@pytest.mark.asyncio
async def test_list_platform_filter_excludes_no_source(db):
a = await _seed_artist(db, "alice-noplat")
await _seed_source(db, a.id, "patreon", "https://p/alice-np")
await _seed_artist(db, "bob-noplat") # no sources
await db.commit()
page = await ArtistDirectoryService(db).list_artists(
q="-noplat", platform="patreon", cursor=None, limit=60,
)
names = [c["name"] for c in page.cards]
assert names == ["alice-noplat"]
@pytest.mark.asyncio
async def test_list_platform_filter_excludes_wrong_platform(db):
a = await _seed_artist(db, "alice-wplat")
await _seed_source(db, a.id, "discord", "https://d/alice-wp")
await db.commit()
page = await ArtistDirectoryService(db).list_artists(
q="-wplat", platform="patreon", cursor=None, limit=60,
)
assert page.cards == []
@pytest.mark.asyncio
async def test_list_platform_filter_does_not_duplicate_artist_with_multiple_sources(db):
"""The EXISTS subquery prevents JOIN-style row duplication when an
artist has multiple sources on the requested platform."""
a = await _seed_artist(db, "alice-multi")
await _seed_source(db, a.id, "patreon", "https://p/alice-m1")
await _seed_source(db, a.id, "patreon", "https://p/alice-m2")
await db.commit()
page = await ArtistDirectoryService(db).list_artists(
q="-multi", platform="patreon", cursor=None, limit=60,
)
assert len(page.cards) == 1
assert page.cards[0]["name"] == "alice-multi"
# --- image_count via LEFT JOIN -------------------------------------------
@pytest.mark.asyncio
async def test_list_image_count_reflects_artist_id_links(db):
a = await _seed_artist(db, "alice-count")
for i in range(4):
await _seed_image(db, a.id, f"count-{i}")
await db.commit()
page = await ArtistDirectoryService(db).list_artists(
q="-count", platform=None, cursor=None, limit=60,
)
target = next(c for c in page.cards if c["name"] == "alice-count")
assert target["image_count"] == 4
@pytest.mark.asyncio
async def test_list_zero_image_artist_preserved_by_left_join(db):
"""LEFT JOIN ImageRecord keeps the artist row even with no images."""
await _seed_artist(db, "alice-zero")
await db.commit()
page = await ArtistDirectoryService(db).list_artists(
q="-zero", platform=None, cursor=None, limit=60,
)
target = next(c for c in page.cards if c["name"] == "alice-zero")
assert target["image_count"] == 0
assert target["preview_thumbnails"] == []
# --- Preview thumbnails (window function) --------------------------------
@pytest.mark.asyncio
async def test_list_previews_returns_top_three_by_image_id_desc(db):
a = await _seed_artist(db, "alice-prev")
for i in range(5):
await _seed_image(db, a.id, f"prev-{i}")
await db.commit()
page = await ArtistDirectoryService(db).list_artists(
q="-prev", platform=None, cursor=None, limit=60,
)
target = next(c for c in page.cards if c["name"] == "alice-prev")
assert len(target["preview_thumbnails"]) == 3
for url in target["preview_thumbnails"]:
assert url.startswith("/images/thumbs/")
# --- is_subscription field plumbed through -------------------------------
@pytest.mark.asyncio
async def test_list_is_subscription_field_present(db):
await _seed_artist(db, "alice-sub", is_subscription=True)
await _seed_artist(db, "bob-sub", is_subscription=False)
await db.commit()
page = await ArtistDirectoryService(db).list_artists(
q="-sub", platform=None, cursor=None, limit=60,
)
alice = next(c for c in page.cards if c["name"] == "alice-sub")
bob = next(c for c in page.cards if c["name"] == "bob-sub")
assert alice["is_subscription"] is True
assert bob["is_subscription"] is False
# --- Combined q + platform -----------------------------------------------
@pytest.mark.asyncio
async def test_list_combined_q_and_platform(db):
alice = await _seed_artist(db, "alice-combo-f")
await _seed_source(db, alice.id, "patreon", "https://p/alice-combo")
bob = await _seed_artist(db, "bob-combo-f")
await _seed_source(db, bob.id, "patreon", "https://p/bob-combo")
await db.commit()
page = await ArtistDirectoryService(db).list_artists(
q="alice", platform="patreon", cursor=None, limit=60,
)
names = [c["name"] for c in page.cards]
assert names == ["alice-combo-f"]