Files
resolutionflow/frontend/src/hooks/useTrialBanner.test.ts
Michael Chihlas 0b5ed9aa10 feat(billing): add useFeature, useFeatureLimit, useTrialBanner hooks
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>
2026-05-06 20:55:58 -04:00

132 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (13 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)
})
})
})