"""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, "deviantart", "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"]