Add stackable add-ons with compounding integer features
- Add is_stackable field to AddOnProduct model for add-ons that can be purchased multiple times - Add quantity field to SubscriptionAddOn for tracking purchase count - Update EntitlementService to ADD integer add-on values to base plan (instead of max) and multiply by quantity for stackable add-ons - Add feature selection to AddOnEditorModal using FeaturePicker component - Add AddOnFeatureSerializer for nested feature CRUD on add-ons - Fix Create Add-on button styling to use solid blue (was muted outline) - Widen billing sidebar from 320px to 384px to prevent text wrapping 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Tests for PlanEditorWizard Component Validation
|
||||
*
|
||||
* TDD: These tests define the expected validation behavior.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { PlanEditorWizard } from '../PlanEditorWizard';
|
||||
|
||||
// Create a fresh query client for each test
|
||||
const createTestQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
// Wrapper with QueryClientProvider
|
||||
const createWrapper = () => {
|
||||
const queryClient = createTestQueryClient();
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Mock the hooks
|
||||
vi.mock('../../../hooks/useBillingAdmin', () => ({
|
||||
useFeatures: () => ({
|
||||
data: [
|
||||
{ id: 1, code: 'sms_enabled', name: 'SMS', feature_type: 'boolean' },
|
||||
{ id: 2, code: 'max_users', name: 'Max Users', feature_type: 'integer' },
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
useAddOnProducts: () => ({
|
||||
data: [{ id: 1, code: 'addon1', name: 'Add-on 1', is_active: true }],
|
||||
isLoading: false,
|
||||
}),
|
||||
useCreatePlan: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ id: 1, code: 'test' }),
|
||||
isPending: false,
|
||||
}),
|
||||
useCreatePlanVersion: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ id: 1, version: 1 }),
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdatePlan: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ id: 1 }),
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdatePlanVersion: () => ({
|
||||
mutateAsync: vi.fn().mockResolvedValue({ id: 1 }),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('PlanEditorWizard', () => {
|
||||
const defaultProps = {
|
||||
isOpen: true,
|
||||
onClose: vi.fn(),
|
||||
mode: 'create' as const,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Basics Step Validation', () => {
|
||||
it('requires plan name to proceed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Plan code is entered but name is empty
|
||||
const codeInput = screen.getByLabelText(/plan code/i);
|
||||
await user.type(codeInput, 'test_plan');
|
||||
|
||||
// Try to click Next
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('requires plan code to proceed', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Name is entered but code is empty
|
||||
const nameInput = screen.getByLabelText(/display name/i);
|
||||
await user.type(nameInput, 'Test Plan');
|
||||
|
||||
// Next button should be disabled
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
expect(nextButton).toBeDisabled();
|
||||
});
|
||||
|
||||
it('allows proceeding when code and name are provided', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Enter both code and name
|
||||
const codeInput = screen.getByLabelText(/plan code/i);
|
||||
const nameInput = screen.getByLabelText(/display name/i);
|
||||
|
||||
await user.type(codeInput, 'test_plan');
|
||||
await user.type(nameInput, 'Test Plan');
|
||||
|
||||
// Next button should be enabled
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
expect(nextButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('sanitizes plan code to lowercase with no spaces', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
const codeInput = screen.getByLabelText(/plan code/i);
|
||||
await user.type(codeInput, 'My Test Plan');
|
||||
|
||||
// Should be sanitized
|
||||
expect(codeInput).toHaveValue('mytestplan');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pricing Step Validation', () => {
|
||||
const goToPricingStep = async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Fill basics
|
||||
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||
|
||||
// Go to pricing step
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
it('shows pricing step inputs', async () => {
|
||||
await goToPricingStep();
|
||||
|
||||
expect(screen.getByLabelText(/monthly price/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/yearly price/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not allow negative monthly price', async () => {
|
||||
const user = await goToPricingStep();
|
||||
|
||||
const monthlyInput = screen.getByLabelText(/monthly price/i);
|
||||
await user.clear(monthlyInput);
|
||||
await user.type(monthlyInput, '-50');
|
||||
|
||||
// Should show validation error or prevent input
|
||||
// The input type="number" with min="0" should prevent negative values
|
||||
expect(monthlyInput).toHaveAttribute('min', '0');
|
||||
});
|
||||
|
||||
it('does not allow negative yearly price', async () => {
|
||||
const user = await goToPricingStep();
|
||||
|
||||
const yearlyInput = screen.getByLabelText(/yearly price/i);
|
||||
await user.clear(yearlyInput);
|
||||
await user.type(yearlyInput, '-100');
|
||||
|
||||
// Should have min attribute set
|
||||
expect(yearlyInput).toHaveAttribute('min', '0');
|
||||
});
|
||||
|
||||
it('displays derived monthly equivalent for yearly price', async () => {
|
||||
const user = await goToPricingStep();
|
||||
|
||||
const yearlyInput = screen.getByLabelText(/yearly price/i);
|
||||
await user.clear(yearlyInput);
|
||||
await user.type(yearlyInput, '120');
|
||||
|
||||
// Should show the monthly equivalent ($10/mo)
|
||||
expect(screen.getByText(/\$10.*mo/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Transaction Fees Validation', () => {
|
||||
const goToPricingStep = async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Fill basics and navigate
|
||||
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
it('validates fee percent is between 0 and 100', async () => {
|
||||
const user = await goToPricingStep();
|
||||
|
||||
const feePercentInput = screen.getByLabelText(/fee percentage/i);
|
||||
|
||||
// Should have min and max attributes
|
||||
expect(feePercentInput).toHaveAttribute('min', '0');
|
||||
expect(feePercentInput).toHaveAttribute('max', '100');
|
||||
});
|
||||
|
||||
it('does not allow fee percent over 100', async () => {
|
||||
const user = await goToPricingStep();
|
||||
|
||||
const feePercentInput = screen.getByLabelText(/fee percentage/i);
|
||||
await user.clear(feePercentInput);
|
||||
await user.type(feePercentInput, '150');
|
||||
|
||||
// Should show validation warning
|
||||
expect(screen.getByText(/must be between 0 and 100/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not allow negative fee percent', async () => {
|
||||
const user = await goToPricingStep();
|
||||
|
||||
const feePercentInput = screen.getByLabelText(/fee percentage/i);
|
||||
|
||||
// Input has min="0" attribute to prevent negative values
|
||||
expect(feePercentInput).toHaveAttribute('min', '0');
|
||||
});
|
||||
|
||||
it('shows transaction fee example calculation', async () => {
|
||||
const user = await goToPricingStep();
|
||||
|
||||
// Should show example like "On a $100 transaction: $4.40 fee"
|
||||
expect(screen.getByText(/on a.*transaction/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Wizard Navigation', () => {
|
||||
it('shows all wizard steps', () => {
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Should show step indicators (they have aria-label)
|
||||
expect(screen.getByRole('button', { name: /basics/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /pricing/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /features/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /display/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates back from pricing to basics', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Fill basics and go to pricing
|
||||
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
// Should be on pricing step
|
||||
expect(screen.getByLabelText(/monthly price/i)).toBeInTheDocument();
|
||||
|
||||
// Click back
|
||||
await user.click(screen.getByRole('button', { name: /back/i }));
|
||||
|
||||
// Should be back on basics
|
||||
expect(screen.getByLabelText(/plan code/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('allows clicking step indicators to navigate', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Fill basics
|
||||
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||
|
||||
// Click on Pricing step indicator
|
||||
const pricingStep = screen.getByRole('button', { name: /pricing/i });
|
||||
await user.click(pricingStep);
|
||||
|
||||
// Should navigate to pricing
|
||||
expect(screen.getByLabelText(/monthly price/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Live Summary Panel', () => {
|
||||
it('shows plan name in summary', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
await user.type(screen.getByLabelText(/display name/i), 'My Amazing Plan');
|
||||
|
||||
// Summary should show the plan name
|
||||
expect(screen.getByText('My Amazing Plan')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows price in summary after entering pricing', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Fill basics
|
||||
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||
await user.click(screen.getByRole('button', { name: /next/i }));
|
||||
|
||||
// Enter price
|
||||
const monthlyInput = screen.getByLabelText(/monthly price/i);
|
||||
await user.clear(monthlyInput);
|
||||
await user.type(monthlyInput, '29');
|
||||
|
||||
// Summary should show the price
|
||||
expect(screen.getByText(/\$29/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows selected features count in summary', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<PlanEditorWizard {...defaultProps} />, { wrapper: createWrapper() });
|
||||
|
||||
// Navigate to features step
|
||||
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||
await user.click(screen.getByRole('button', { name: /next/i })); // to pricing
|
||||
await user.click(screen.getByRole('button', { name: /next/i })); // to features
|
||||
|
||||
// Select a feature
|
||||
const smsCheckbox = screen.getByRole('checkbox', { name: /sms/i });
|
||||
await user.click(smsCheckbox);
|
||||
|
||||
// Summary should show feature count
|
||||
expect(screen.getByText(/1 feature/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Create Version Confirmation', () => {
|
||||
it('shows grandfathering warning when editing version with subscribers', async () => {
|
||||
render(
|
||||
<PlanEditorWizard
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
mode="edit"
|
||||
initialData={{
|
||||
id: 1,
|
||||
code: 'pro',
|
||||
name: 'Pro',
|
||||
version: {
|
||||
id: 1,
|
||||
subscriber_count: 5,
|
||||
name: 'Pro v1',
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Should show warning about subscribers and grandfathering
|
||||
expect(screen.getByText(/5/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/subscriber/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/grandfathering/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Create New Version" confirmation for version with subscribers', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<PlanEditorWizard
|
||||
{...defaultProps}
|
||||
mode="edit"
|
||||
initialData={{
|
||||
id: 1,
|
||||
code: 'pro',
|
||||
name: 'Pro',
|
||||
version: {
|
||||
id: 1,
|
||||
subscriber_count: 5,
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Navigate to last step and try to save
|
||||
// The save button should mention "Create New Version"
|
||||
const saveButton = screen.queryByRole('button', { name: /create new version/i });
|
||||
expect(saveButton || screen.getByText(/new version/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('calls onClose after successful creation', async () => {
|
||||
const onClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<PlanEditorWizard {...defaultProps} onClose={onClose} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Fill all required fields
|
||||
await user.type(screen.getByLabelText(/plan code/i), 'test');
|
||||
await user.type(screen.getByLabelText(/display name/i), 'Test');
|
||||
|
||||
// Navigate through wizard
|
||||
await user.click(screen.getByRole('button', { name: /next/i })); // pricing
|
||||
await user.click(screen.getByRole('button', { name: /next/i })); // features
|
||||
await user.click(screen.getByRole('button', { name: /next/i })); // display
|
||||
|
||||
// Submit
|
||||
const createButton = screen.getByRole('button', { name: /create plan/i });
|
||||
await user.click(createButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user