feat(artist): "new since last visit" badge + banner
Per-artist "+N" accent pill on the artists directory and a "N new since last visit" banner inside ArtistView. Counts new IMAGES (not posts) so multi-image posts increment correctly. - alembic 0034: artist_visit (artist_id PK, last_viewed_at NOT NULL). Seeds every existing artist with last_viewed_at=NOW() so the badge starts at 0 across the board — no noisy "5000 unseen images" on first deploy. - ArtistService.find_or_create autoseeds a visit row alongside new artists, so freshly imported content doesn't read as unseen. - ArtistService.overview reads pre-visit last_viewed_at, counts images created since, then atomically UPSERTs last_viewed_at=NOW() via postgres ON CONFLICT DO UPDATE (no SELECT-then-INSERT race per reference_scalar_one_or_none_duplicates). Returns the pre-update count as `unseen_count_at_visit` so the banner has data. - ArtistDirectoryService.list_artists adds an `unseen_count` aggregate to each card via LEFT JOIN artist_visit + conditional COUNT. NULL last_viewed_at (artist created before this code shipped) defensively counts as "never visited" → all images unseen. - Frontend: ArtistCard renders an accent pill in the preview-strip corner when unseen_count > 0 (capped at 99+); ArtistView shows a closable v-alert banner on initial load when unseen_count_at_visit > 0, re-arms on slug change. Single-row-per-artist (no user_id) — rule #47 multi-user ACL is aspirational; widens to (user_id, artist_id) PK when User lands, per rule #22. Scribe plan #597.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"""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 datetime, timedelta, timezone
|
||||
|
||||
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=timezone.utc)
|
||||
_RECENTLY = datetime(2099, 1, 1, tzinfo=timezone.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
|
||||
Reference in New Issue
Block a user