CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Failing after 32s
Build images / build-web (push) Successful in 58s
Build images / smoke-web (push) Skipped
Build images / build-ml (push) Successful in 1m47s
Build images / promote (push) Skipped
CI / integration (push) Successful in 2m16s
A3 made a tier-gated source say "47 posts you can't see". The roster turns that into a reason: the membership ended, or the tier doesn't reach these posts, or it's a free follow. Rendered under A3's count in the health tooltip, quieter than the count it explains. FREE is a fourth case the step didn't enumerate, and it earns its own sentence. has_paid_access collapses "former patron" and "current free follower" to the same False, so deriving the reason from that boolean would tell a free follower "you're not a patron any more" - a false statement about a state they were never in. gated_reason reads the status axis first, calling has_paid_access with is_free_member forced off, then splits on the free flag. Silence is the default, and there are four ways into it: campaign absent from the roster, roster stale, platform never swept, status word not yet characterised. All four send null and the count stands alone. The frontend has no fallback sentence either - a default would turn "we don't know why" into a reason, which is the one thing this step must not do. The line that must not be crossed is pinned structurally rather than by inspection: test_no_fetch_path_can_read_the_roster walks the transitive first-party imports from the fetch roots and asserts the roster is unreachable. FC runs no local verification (rule 85), so a guard cannot be falsified by hand before it lands - it carries two positive controls instead, proving the walker finds roster imports that ARE there, one direct and one through a hop, so the real assertion can never pass merely because the walk resolved nothing. C4's identity loop moved to membership_roster.pair_sources_with_memberships when C5 became its second caller; two copies would let the Subscriptions row and the reconciliation card disagree about which creator a source IS. Three test files were each building PlatformMembership rows with their own drifting helper - consolidated into tests/roster_builders.py, same family as issue 3109. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
142 lines
5.1 KiB
JavaScript
142 lines
5.1 KiB
JavaScript
// @vitest-environment happy-dom
|
|
import { describe, it, expect } from 'vitest'
|
|
|
|
import SourceHealthDot from '../../src/components/subscriptions/SourceHealthDot.vue'
|
|
import { VTooltipStub, mountComponent } from '../support/mountComponent.js'
|
|
|
|
// Milestone #387 A3. The dot is the only always-visible signal per source, so
|
|
// the grade it picks IS the claim FC makes about that subscription. These pin
|
|
// that no-access is graded as its own thing — not as healthy (which hides it)
|
|
// and not as a failure (which would send the operator hunting for a break that
|
|
// isn't there).
|
|
|
|
const checked = { last_checked_at: '2026-09-09T12:00:00+00:00' }
|
|
|
|
function dotClass (w) {
|
|
return w.find('.fc-health-dot').classes().join(' ')
|
|
}
|
|
|
|
describe('SourceHealthDot', () => {
|
|
it('grades a tier-gated source as no-access, not healthy', () => {
|
|
const w = mountComponent(SourceHealthDot, {
|
|
stubs: { VTooltip: VTooltipStub },
|
|
props: {
|
|
source: { ...checked, consecutive_failures: 0, error_type: 'tier_limited' },
|
|
},
|
|
})
|
|
expect(dotClass(w)).toContain('fc-health-dot--no-access')
|
|
expect(dotClass(w)).not.toContain('fc-health-dot--healthy')
|
|
})
|
|
|
|
it('shows the gated count when the list endpoint supplied one', () => {
|
|
const w = mountComponent(SourceHealthDot, {
|
|
stubs: { VTooltip: VTooltipStub },
|
|
props: {
|
|
source: {
|
|
...checked, consecutive_failures: 0,
|
|
error_type: 'tier_limited', tier_gated_count: 47,
|
|
},
|
|
},
|
|
})
|
|
expect(w.text()).toContain('47 posts')
|
|
})
|
|
|
|
it('states the condition without a number when no count was joined in', () => {
|
|
const w = mountComponent(SourceHealthDot, {
|
|
stubs: { VTooltip: VTooltipStub },
|
|
props: {
|
|
source: {
|
|
...checked, consecutive_failures: 0,
|
|
error_type: 'tier_limited', tier_gated_count: null,
|
|
},
|
|
},
|
|
})
|
|
// Absent is not zero: never render "0 posts you don't have access to".
|
|
expect(w.text()).not.toContain('0 post')
|
|
expect(w.text()).toContain("tier you don't hold")
|
|
})
|
|
|
|
// Milestone #387 C5. The count says WHAT; these say WHY — a much stronger
|
|
// claim, so the cases that must stay silent are pinned alongside the ones
|
|
// that speak.
|
|
|
|
function gated (extra) {
|
|
return mountComponent(SourceHealthDot, {
|
|
stubs: { VTooltip: VTooltipStub },
|
|
props: {
|
|
source: {
|
|
...checked, consecutive_failures: 0,
|
|
error_type: 'tier_limited', tier_gated_count: 47, ...extra,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
it('a lapsed membership says the subscription ended', () => {
|
|
expect(gated({ gated_reason: 'lapsed' }).text()).toContain('not a patron any more')
|
|
})
|
|
|
|
it('an active membership says the tier is the limit, not the subscription', () => {
|
|
const text = gated({ gated_reason: 'tier' }).text()
|
|
expect(text).toContain("tier doesn't cover these posts")
|
|
expect(text).not.toContain('not a patron any more')
|
|
})
|
|
|
|
it('a free follow is not described as a lapsed subscription', () => {
|
|
const text = gated({ gated_reason: 'free' }).text()
|
|
expect(text).toContain('follow this creator for free')
|
|
expect(text).not.toContain('not a patron any more')
|
|
})
|
|
|
|
it('no reason means the count stands alone, with no invented explanation', () => {
|
|
// null covers all four not-evidence cases the backend collapses into it:
|
|
// campaign absent, roster stale, never swept, status uncharacterised.
|
|
const text = gated({ gated_reason: null }).text()
|
|
expect(text).toContain('47 posts')
|
|
expect(text).not.toContain('patron')
|
|
expect(text).not.toContain('tier doesn')
|
|
})
|
|
|
|
it('a reason the frontend has not been taught renders nothing', () => {
|
|
// The backend's status vocabulary grows per platform (D1). An unknown word
|
|
// must degrade to the bare count, never to `undefined` in the tooltip.
|
|
const text = gated({ gated_reason: 'some_future_word' }).text()
|
|
expect(text).toContain('47 posts')
|
|
expect(text).not.toContain('undefined')
|
|
})
|
|
|
|
it('a reason never appears on a source that is not gated', () => {
|
|
const w = mountComponent(SourceHealthDot, {
|
|
stubs: { VTooltip: VTooltipStub },
|
|
props: {
|
|
source: {
|
|
...checked, consecutive_failures: 0,
|
|
error_type: null, gated_reason: 'lapsed',
|
|
},
|
|
},
|
|
})
|
|
// The roster annotates a gated state; it never asserts one on its own.
|
|
expect(w.text()).not.toContain('not a patron any more')
|
|
})
|
|
|
|
it('a genuinely failing source still grades as a failure, gated or not', () => {
|
|
const w = mountComponent(SourceHealthDot, {
|
|
stubs: { VTooltip: VTooltipStub },
|
|
props: {
|
|
source: { ...checked, consecutive_failures: 9, error_type: 'tier_limited' },
|
|
warningThreshold: 5,
|
|
},
|
|
})
|
|
expect(dotClass(w)).toContain('fc-health-dot--critical')
|
|
expect(dotClass(w)).not.toContain('fc-health-dot--no-access')
|
|
})
|
|
|
|
it('an unchecked source is still unchecked', () => {
|
|
const w = mountComponent(SourceHealthDot, {
|
|
stubs: { VTooltip: VTooltipStub },
|
|
props: { source: { last_checked_at: null, consecutive_failures: 0 } },
|
|
})
|
|
expect(dotClass(w)).toContain('fc-health-dot--unchecked')
|
|
})
|
|
})
|