mirror of
https://github.com/vernu/textbee.git
synced 2026-07-30 17:07:46 -04:00
The plan step hardcoded Free and Pro inline, so Scale never appeared no matter what the pricing page offered. Tiers now come from lib/plans, which mirrors the marketing pricing section, with a test pinning the values so the two cannot drift silently. Kept static rather than fetched from /billing/plans. That endpoint returns Plan documents (limits and cents), not customer-facing copy, and an environment with no plans rows left the step showing "plans could not be loaded" with nothing to choose. Reopening a completed step showed an empty row. Two things caused it: the body was gated on the step not being done, and an effect cleared the selection whenever the selected step was done, so clicking a finished step deselected it on the next render. That effect exists to advance you when the step you are sitting on completes underneath you, so it now fires only on that transition. Both paths are covered: reopening the API key step offers "Generate another API key", and a step completing while selected still moves the selection on. Skip is hidden on an already-finished step, where it would mean nothing. Also corrects the Plan type, which declared amount, currency and recurringInterval. The plans endpoint has never sent those; they belong to Subscription. Anything reading plan.amount saw undefined and rendered a paid plan as free. The fixture encoded the same wrong shape, so tests would have passed while production broke. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
68 lines
2.1 KiB
TypeScript
68 lines
2.1 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import { PLAN_TIERS, findPlanTier, formatPlanPrice } from './plans'
|
|
|
|
// These values mirror the marketing pricing page. They are pinned here so that
|
|
// if the two drift apart, this fails instead of quietly showing a customer a
|
|
// price we do not charge.
|
|
describe('PLAN_TIERS', () => {
|
|
it('matches the marketing pricing page', () => {
|
|
expect(
|
|
PLAN_TIERS.map((t) => [t.id, t.monthlyPrice, t.yearlyPrice])
|
|
).toEqual([
|
|
['free', 0, undefined],
|
|
['pro', 9.99, 99.99],
|
|
['scale', 29.99, 299.99],
|
|
])
|
|
})
|
|
|
|
it('offers the three self-serve tiers, Scale included', () => {
|
|
expect(PLAN_TIERS.map((t) => t.name)).toEqual(['Free', 'Pro', 'Scale'])
|
|
})
|
|
|
|
it('highlights exactly one tier', () => {
|
|
expect(PLAN_TIERS.filter((t) => t.isPopular)).toHaveLength(1)
|
|
expect(PLAN_TIERS.find((t) => t.isPopular)?.id).toBe('pro')
|
|
})
|
|
|
|
it('gives every tier features to show', () => {
|
|
for (const tier of PLAN_TIERS) {
|
|
expect(tier.features.length).toBeGreaterThan(0)
|
|
expect(tier.description).not.toBe('')
|
|
}
|
|
})
|
|
|
|
// The id doubles as the /checkout/{id} segment, so it has to stay
|
|
// URL-clean.
|
|
it('uses lowercase ids safe for the checkout route', () => {
|
|
for (const tier of PLAN_TIERS) {
|
|
expect(tier.id).toMatch(/^[a-z][a-z0-9-]*$/)
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('formatPlanPrice', () => {
|
|
// "$0" not "Free", so the price does not just repeat the tier name above it.
|
|
it('renders the free tier as a price', () => {
|
|
expect(formatPlanPrice(0)).toBe('$0')
|
|
})
|
|
|
|
it('always shows cents on a paid plan', () => {
|
|
expect(formatPlanPrice(9.99)).toBe('$9.99')
|
|
expect(formatPlanPrice(30)).toBe('$30.00')
|
|
})
|
|
})
|
|
|
|
describe('findPlanTier', () => {
|
|
it('is forgiving about casing and whitespace', () => {
|
|
expect(findPlanTier('Pro')?.id).toBe('pro')
|
|
expect(findPlanTier(' SCALE ')?.id).toBe('scale')
|
|
})
|
|
|
|
it('returns nothing for an unknown or missing name', () => {
|
|
expect(findPlanTier('enterprise')).toBeUndefined()
|
|
expect(findPlanTier(undefined)).toBeUndefined()
|
|
expect(findPlanTier(null)).toBeUndefined()
|
|
expect(findPlanTier('')).toBeUndefined()
|
|
})
|
|
})
|