Phase 2 Task 33. Components can now ask "is this feature on?", "how many
sessions left?", and "what stage is the trial in?" without re-implementing
the read against useBillingStore.
- useFeature(flagKey): boolean — reads enabledFeatures from store
- useFeatureLimit(field): { used, limit, percentage, isAtLimit, isLoading }
with non-blocking 60s module-level cache and graceful 404 degradation
- useTrialBanner(): derives stage from subscription status + trial countdown,
returns null on initial load to prevent flicker
- usageApi.getCount(field) — calls /api/v1/usage/{field}; backend endpoint
is not yet implemented (planned), so the hook degrades to used=0
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
132 lines
4.3 KiB
TypeScript
132 lines
4.3 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||
import { renderHook } from '@testing-library/react'
|
||
import { useTrialBanner } from './useTrialBanner'
|
||
import { useBillingStore } from '@/store/billingStore'
|
||
import type { SubscriptionState } from '@/types/billing'
|
||
|
||
const FROZEN_NOW = new Date('2026-05-06T00:00:00Z')
|
||
|
||
function makeSub(overrides: Partial<SubscriptionState>): SubscriptionState {
|
||
return {
|
||
status: 'trialing',
|
||
plan: 'starter',
|
||
current_period_start: '2026-05-01T00:00:00Z',
|
||
current_period_end: null,
|
||
cancel_at_period_end: false,
|
||
seat_limit: null,
|
||
has_pro_entitlement: false,
|
||
is_paid: false,
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function setSubscription(overrides: Partial<SubscriptionState>) {
|
||
useBillingStore.setState({ subscription: makeSub(overrides) })
|
||
}
|
||
|
||
describe('useTrialBanner', () => {
|
||
beforeEach(() => {
|
||
vi.useFakeTimers()
|
||
vi.setSystemTime(FROZEN_NOW)
|
||
useBillingStore.setState({
|
||
subscription: null,
|
||
planBilling: null,
|
||
planLimits: {},
|
||
enabledFeatures: {},
|
||
isLoading: false,
|
||
error: null,
|
||
})
|
||
})
|
||
|
||
afterEach(() => {
|
||
vi.useRealTimers()
|
||
})
|
||
|
||
describe('stage matches subscription state matrix', () => {
|
||
it('returns null when subscription is null (no flicker on initial load)', () => {
|
||
const { result } = renderHook(() => useTrialBanner())
|
||
expect(result.current.stage).toBe(null)
|
||
expect(result.current.daysRemaining).toBe(null)
|
||
})
|
||
|
||
it('complimentary status -> complimentary stage', () => {
|
||
setSubscription({ status: 'complimentary' })
|
||
const { result } = renderHook(() => useTrialBanner())
|
||
expect(result.current.stage).toBe('complimentary')
|
||
})
|
||
|
||
it('active status -> paid stage', () => {
|
||
setSubscription({ status: 'active' })
|
||
const { result } = renderHook(() => useTrialBanner())
|
||
expect(result.current.stage).toBe('paid')
|
||
})
|
||
|
||
it('past_due status -> past_due stage', () => {
|
||
setSubscription({ status: 'past_due' })
|
||
const { result } = renderHook(() => useTrialBanner())
|
||
expect(result.current.stage).toBe('past_due')
|
||
})
|
||
|
||
it('canceled status -> canceled stage', () => {
|
||
setSubscription({ status: 'canceled' })
|
||
const { result } = renderHook(() => useTrialBanner())
|
||
expect(result.current.stage).toBe('canceled')
|
||
})
|
||
|
||
it('trialing >3 days remaining -> pristine', () => {
|
||
// 7 days from frozen now.
|
||
setSubscription({
|
||
status: 'trialing',
|
||
current_period_end: '2026-05-13T00:00:00Z',
|
||
})
|
||
const { result } = renderHook(() => useTrialBanner())
|
||
expect(result.current.stage).toBe('pristine')
|
||
expect(result.current.daysRemaining).toBe(7)
|
||
})
|
||
|
||
it('trialing 1-3 days remaining -> warning', () => {
|
||
// 2 days from frozen now.
|
||
setSubscription({
|
||
status: 'trialing',
|
||
current_period_end: '2026-05-08T00:00:00Z',
|
||
})
|
||
const { result } = renderHook(() => useTrialBanner())
|
||
expect(result.current.stage).toBe('warning')
|
||
expect(result.current.daysRemaining).toBe(2)
|
||
})
|
||
|
||
it('trialing exactly 24 hours remaining -> warning (boundary, not urgent)', () => {
|
||
// Exactly 1.0 fractional day from frozen now — must sit on the warning
|
||
// side per spec (1–3 days inclusive of 1).
|
||
setSubscription({
|
||
status: 'trialing',
|
||
current_period_end: '2026-05-07T00:00:00Z',
|
||
})
|
||
const { result } = renderHook(() => useTrialBanner())
|
||
expect(result.current.stage).toBe('warning')
|
||
expect(result.current.daysRemaining).toBe(1)
|
||
})
|
||
|
||
it('trialing <1 day remaining -> urgent', () => {
|
||
// 12 hours from frozen now -> Math.ceil(0.5) = 1 day.
|
||
setSubscription({
|
||
status: 'trialing',
|
||
current_period_end: '2026-05-06T12:00:00Z',
|
||
})
|
||
const { result } = renderHook(() => useTrialBanner())
|
||
expect(result.current.stage).toBe('urgent')
|
||
expect(result.current.daysRemaining).toBe(1)
|
||
})
|
||
|
||
it('trialing past period_end -> expired', () => {
|
||
setSubscription({
|
||
status: 'trialing',
|
||
current_period_end: '2026-05-01T00:00:00Z',
|
||
})
|
||
const { result } = renderHook(() => useTrialBanner())
|
||
expect(result.current.stage).toBe('expired')
|
||
expect(result.current.daysRemaining).toBe(0)
|
||
})
|
||
})
|
||
})
|