"""ArtistVisit unseen-count badge + ArtistService.overview banner data. Covers: - Directory cards include `unseen_count` - LEFT JOIN keeps artists without a visit row (treats NULL as "never visited" → all images unseen) - overview() returns `unseen_count_at_visit` and stamps the visit - find_or_create autoseeds a visit row so freshly imported content doesn't show up as unseen - Repeat overview() returns 0 (since the previous visit just stamped last_viewed_at = NOW()) """ from datetime import UTC, datetime, timedelta import pytest from sqlalchemy import select from backend.app.models import Artist, ArtistVisit, ImageRecord from backend.app.services.artist_directory_service import ArtistDirectoryService from backend.app.services.artist_service import ArtistService pytestmark = pytest.mark.integration _LONG_AGO = datetime(2000, 1, 1, tzinfo=UTC) _RECENTLY = datetime(2099, 1, 1, tzinfo=UTC) async def _seed_artist(db, name: str) -> Artist: a = Artist(name=name, slug=name.lower().replace(" ", "-")) db.add(a) await db.flush() return a async def _seed_visit(db, artist_id: int, when: datetime) -> None: db.add(ArtistVisit(artist_id=artist_id, last_viewed_at=when)) await db.flush() async def _seed_image(db, artist_id: int, suffix: str, *, created_at: datetime) -> None: db.add(ImageRecord( path=f"/images/visit-{suffix}.jpg", sha256=f"visit{suffix}".ljust(64, "0")[:64], size_bytes=10, mime="image/jpeg", width=10, height=10, origin="downloaded", artist_id=artist_id, created_at=created_at, )) await db.flush() # --- Directory unseen_count ----------------------------------------------- @pytest.mark.asyncio async def test_directory_unseen_count_zero_when_no_images(db): await _seed_artist(db, "zoey-empty-visit") await db.commit() page = await ArtistDirectoryService(db).list_artists( q="zoey-empty-visit", platform=None, cursor=None, limit=60, ) target = next(c for c in page.cards if c["name"] == "zoey-empty-visit") assert target["unseen_count"] == 0 @pytest.mark.asyncio async def test_directory_unseen_counts_only_images_after_visit(db): a = await _seed_artist(db, "alice-visit-mix") await _seed_visit(db, a.id, _LONG_AGO + timedelta(days=365)) # Two images BEFORE the visit (seen), three AFTER (unseen). for i in range(2): await _seed_image(db, a.id, f"old-{i}", created_at=_LONG_AGO) for i in range(3): await _seed_image(db, a.id, f"new-{i}", created_at=_RECENTLY) await db.commit() page = await ArtistDirectoryService(db).list_artists( q="alice-visit-mix", platform=None, cursor=None, limit=60, ) target = next(c for c in page.cards if c["name"] == "alice-visit-mix") assert target["image_count"] == 5 assert target["unseen_count"] == 3 @pytest.mark.asyncio async def test_directory_treats_missing_visit_as_never_visited(db): """No ArtistVisit row → defensive count of all images as unseen. Shouldn't happen in practice (migration 0034 seeds existing artists, find_or_create autoseeds new ones), but the directory query must not regress to "0 unseen" if a row is missing. """ a = await _seed_artist(db, "bob-no-visit") for i in range(4): await _seed_image(db, a.id, f"orphan-{i}", created_at=_RECENTLY) await db.commit() page = await ArtistDirectoryService(db).list_artists( q="bob-no-visit", platform=None, cursor=None, limit=60, ) target = next(c for c in page.cards if c["name"] == "bob-no-visit") assert target["unseen_count"] == 4 # --- overview() marks visit + returns count ------------------------------- @pytest.mark.asyncio async def test_overview_returns_unseen_count_at_visit_and_stamps_now(db): a = await _seed_artist(db, "carol-stamp") await _seed_visit(db, a.id, _LONG_AGO) await _seed_image(db, a.id, "stamp-1", created_at=_RECENTLY) await _seed_image(db, a.id, "stamp-2", created_at=_RECENTLY) await db.commit() data = await ArtistService(db).overview("carol-stamp") assert data is not None assert data["unseen_count_at_visit"] == 2 # last_viewed_at advanced to NOW() — directly check the row. visit_at = (await db.execute( select(ArtistVisit.last_viewed_at).where(ArtistVisit.artist_id == a.id) )).scalar_one() assert visit_at > _LONG_AGO @pytest.mark.asyncio async def test_overview_repeat_call_returns_zero(db): a = await _seed_artist(db, "dana-repeat") await _seed_visit(db, a.id, _LONG_AGO) await _seed_image(db, a.id, "repeat-1", created_at=_LONG_AGO + timedelta(days=1)) await db.commit() first = await ArtistService(db).overview("dana-repeat") assert first is not None assert first["unseen_count_at_visit"] == 1 # Second call: no new images, visit just stamped → count is 0. second = await ArtistService(db).overview("dana-repeat") assert second is not None assert second["unseen_count_at_visit"] == 0 # --- find_or_create autoseeds the visit row ------------------------------ @pytest.mark.asyncio async def test_find_or_create_autoseeds_visit_row(db): artist, created = await ArtistService(db).find_or_create("Eve-Autoseed") assert created is True row = (await db.execute( select(ArtistVisit.last_viewed_at).where(ArtistVisit.artist_id == artist.id) )).scalar_one_or_none() assert row is not None @pytest.mark.asyncio async def test_find_or_create_existing_does_not_reset_visit(db): """Calling find_or_create on an existing artist returns it as-is — must NOT touch the visit row's timestamp.""" a = await _seed_artist(db, "Frank-Existing") await _seed_visit(db, a.id, _LONG_AGO) await db.commit() artist, created = await ArtistService(db).find_or_create("Frank-Existing") assert created is False assert artist.id == a.id visit_at = (await db.execute( select(ArtistVisit.last_viewed_at).where(ArtistVisit.artist_id == a.id) )).scalar_one() assert visit_at == _LONG_AGO