feat: no-access is visible per source, and findable (milestone 387 step A3)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 2m40s
Build images / build-ml (push) Successful in 2m47s
Build images / build-web (push) Successful in 1m35s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 2m40s
Build images / build-ml (push) Successful in 2m47s
Build images / build-web (push) Successful in 1m35s
Build images / smoke-web (push) Skipped
Build images / promote (push) Skipped
A3 of milestone 387, completing phase A. A1 made the count true, A2 made it a durable state; this makes it something the operator can see without going looking. Turned out smaller than filed, because A2 revealed why the existing `tier_limited` palette entry in FailingSourcesCard had never rendered: the chip was being cleared by the same successful run that produced it. The colour was already chosen. Where it surfaces: - SourceHealthDot gains a `no-access` grade. Deliberately its own grade rather than folded into healthy (which hides it) or warning (which sends the operator hunting for a break that isn't there). A source with real failures still grades as failing whether or not it is also gated. - SourceRow gets an info-coloured lock chip in the status cell, which was empty for these sources — they have zero failures. Placed ahead of the backfill states: "we can't see this creator" is the more useful thing to say than which walk phase it is in, and unlike those it does not resolve on its own. - A "No access" status filter, deliberately separate from "Has errors". Without it a gated source is invisible in a long list, because it correctly stays out of the failing rollup. Left OUT of NeedsAttentionCard on purpose. That card's only affordance is Retry, and you cannot retry your way into a subscription tier — issue 1285 already gives the real escape hatch, since disabling a source clears its state. Nothing structural needed changing: the card is fed by consecutive_failures > 0, which a tier-limited source never has. The count lives on the download event, not the source, so `list()` joins it in with one DISTINCT ON query — selecting the run_stats sub-object rather than whole metadata blobs, which carry up to 500KB of truncated stdout each. Scoped to tier-gated rows only, so a healthy library issues no extra query at all. Absent stays None rather than 0, and both UI surfaces phrase the state without a number when it is missing instead of printing a fabricated zero. Also covers A1's live gated count, which shipped untested, and extends the mount helper with slot stubs: SourceHealthDot puts the dot in a NAMED slot, and unresolved Vuetify components render default slots only — so those assertions would have found an empty wrapper and passed vacuously. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
This commit is contained in:
@@ -125,6 +125,61 @@ async def test_list_filters_by_artist(db):
|
||||
assert len(all_rows) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_joins_tier_gated_count_from_the_latest_event(db):
|
||||
"""A no-access source carries the count from its most recent walk.
|
||||
|
||||
The number lives on the DownloadEvent's run_stats, not on the source, so
|
||||
`list()` joins it in. Two events are seeded deliberately: the newest must
|
||||
win, or the row would show a stale figure from a walk where the operator
|
||||
still held the tier.
|
||||
"""
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from backend.app.models import DownloadEvent
|
||||
|
||||
artist = await _artist(db)
|
||||
svc = SourceService(db)
|
||||
rec = await svc.create(
|
||||
artist_id=artist.id, platform="patreon", url="https://patreon.com/gated",
|
||||
)
|
||||
src = (await db.execute(
|
||||
select(Source).where(Source.id == rec.id)
|
||||
)).scalar_one()
|
||||
src.error_type = "tier_limited"
|
||||
src.last_checked_at = datetime.now(UTC)
|
||||
|
||||
now = datetime.now(UTC)
|
||||
db.add(DownloadEvent(
|
||||
source_id=rec.id, status="ok", started_at=now - timedelta(hours=2),
|
||||
metadata_={"run_stats": {"tier_gated_count": 3}},
|
||||
))
|
||||
db.add(DownloadEvent(
|
||||
source_id=rec.id, status="ok", started_at=now,
|
||||
metadata_={"run_stats": {"tier_gated_count": 47}},
|
||||
))
|
||||
await db.commit()
|
||||
|
||||
rows = await svc.list(artist_id=artist.id)
|
||||
assert rows[0].tier_gated_count == 47
|
||||
assert rows[0].to_dict()["tier_gated_count"] == 47
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_leaves_tier_gated_count_none_for_ordinary_sources(db):
|
||||
"""The join is scoped to tier-gated sources, so a healthy row reports None
|
||||
rather than 0 — the UI distinguishes 'no count available' from 'zero
|
||||
gated', and must not print a fabricated number."""
|
||||
artist = await _artist(db)
|
||||
svc = SourceService(db)
|
||||
await svc.create(
|
||||
artist_id=artist.id, platform="patreon", url="https://patreon.com/fine",
|
||||
)
|
||||
|
||||
rows = await svc.list(artist_id=artist.id)
|
||||
assert rows[0].tier_gated_count is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_changes_fields(db):
|
||||
artist = await _artist(db)
|
||||
|
||||
Reference in New Issue
Block a user