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:
327
frontend/src/billing/components/__tests__/FeaturePicker.test.tsx
Normal file
327
frontend/src/billing/components/__tests__/FeaturePicker.test.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Tests for FeaturePicker Component
|
||||
*
|
||||
* TDD: These tests define the expected behavior of the FeaturePicker component.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { FeaturePicker, FeaturePickerProps } from '../FeaturePicker';
|
||||
import { FEATURE_CATALOG, BOOLEAN_FEATURES, INTEGER_FEATURES } from '../../featureCatalog';
|
||||
import type { PlanFeatureWrite } from '../../../hooks/useBillingAdmin';
|
||||
|
||||
// Mock features from API (similar to what useFeatures() returns)
|
||||
const mockApiFeatures = [
|
||||
{ id: 1, code: 'sms_enabled', name: 'SMS Enabled', description: 'Allow SMS', feature_type: 'boolean' as const },
|
||||
{ id: 2, code: 'email_enabled', name: 'Email Enabled', description: 'Allow email', feature_type: 'boolean' as const },
|
||||
{ id: 3, code: 'max_users', name: 'Maximum Users', description: 'Max users limit', feature_type: 'integer' as const },
|
||||
{ id: 4, code: 'max_resources', name: 'Maximum Resources', description: 'Max resources', feature_type: 'integer' as const },
|
||||
{ id: 5, code: 'custom_feature', name: 'Custom Feature', description: 'Not in catalog', feature_type: 'boolean' as const },
|
||||
];
|
||||
|
||||
/**
|
||||
* Wrapper component that manages state for controlled FeaturePicker
|
||||
*/
|
||||
const StatefulFeaturePicker: React.FC<
|
||||
Omit<FeaturePickerProps, 'selectedFeatures' | 'onChange'> & {
|
||||
initialSelectedFeatures?: PlanFeatureWrite[];
|
||||
onChangeCapture?: (features: PlanFeatureWrite[]) => void;
|
||||
}
|
||||
> = ({ initialSelectedFeatures = [], onChangeCapture, ...props }) => {
|
||||
const [selectedFeatures, setSelectedFeatures] = useState<PlanFeatureWrite[]>(initialSelectedFeatures);
|
||||
|
||||
const handleChange = (features: PlanFeatureWrite[]) => {
|
||||
setSelectedFeatures(features);
|
||||
onChangeCapture?.(features);
|
||||
};
|
||||
|
||||
return (
|
||||
<FeaturePicker
|
||||
{...props}
|
||||
selectedFeatures={selectedFeatures}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
describe('FeaturePicker', () => {
|
||||
const defaultProps = {
|
||||
features: mockApiFeatures,
|
||||
selectedFeatures: [],
|
||||
onChange: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders boolean features in Capabilities section', () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Capabilities')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMS Enabled')).toBeInTheDocument();
|
||||
expect(screen.getByText('Email Enabled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders integer features in Limits & Quotas section', () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Limits & Quotas')).toBeInTheDocument();
|
||||
expect(screen.getByText('Maximum Users')).toBeInTheDocument();
|
||||
expect(screen.getByText('Maximum Resources')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows selected features as checked', () => {
|
||||
const selectedFeatures = [
|
||||
{ feature_code: 'sms_enabled', bool_value: true, int_value: null },
|
||||
];
|
||||
|
||||
render(<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} />);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /sms enabled/i });
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
it('shows integer values for selected integer features', () => {
|
||||
const selectedFeatures = [
|
||||
{ feature_code: 'max_users', bool_value: null, int_value: 50 },
|
||||
];
|
||||
|
||||
render(<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} />);
|
||||
|
||||
const input = screen.getByDisplayValue('50');
|
||||
expect(input).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature Selection', () => {
|
||||
it('calls onChange when a boolean feature is selected', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<FeaturePicker {...defaultProps} onChange={onChange} />);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /sms enabled/i });
|
||||
await userEvent.click(checkbox);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ feature_code: 'sms_enabled', bool_value: true, int_value: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it('calls onChange when a boolean feature is deselected', async () => {
|
||||
const selectedFeatures = [
|
||||
{ feature_code: 'sms_enabled', bool_value: true, int_value: null },
|
||||
];
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} onChange={onChange} />
|
||||
);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /sms enabled/i });
|
||||
await userEvent.click(checkbox);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('calls onChange when an integer feature is selected with default value 0', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<FeaturePicker {...defaultProps} onChange={onChange} />);
|
||||
|
||||
const checkbox = screen.getByRole('checkbox', { name: /maximum users/i });
|
||||
await userEvent.click(checkbox);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ feature_code: 'max_users', bool_value: null, int_value: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('calls onChange when integer value is updated', async () => {
|
||||
const initialSelectedFeatures = [
|
||||
{ feature_code: 'max_users', bool_value: null, int_value: 10 },
|
||||
];
|
||||
const onChangeCapture = vi.fn();
|
||||
|
||||
render(
|
||||
<StatefulFeaturePicker
|
||||
features={mockApiFeatures}
|
||||
initialSelectedFeatures={initialSelectedFeatures}
|
||||
onChangeCapture={onChangeCapture}
|
||||
/>
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('10');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, '50');
|
||||
|
||||
// Should have been called multiple times as user types
|
||||
expect(onChangeCapture).toHaveBeenCalled();
|
||||
const lastCall = onChangeCapture.mock.calls[onChangeCapture.mock.calls.length - 1][0];
|
||||
expect(lastCall).toContainEqual({ feature_code: 'max_users', bool_value: null, int_value: 50 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Canonical Catalog Validation', () => {
|
||||
it('shows warning badge for features not in canonical catalog', () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
// custom_feature is not in the canonical catalog
|
||||
const customFeatureRow = screen.getByText('Custom Feature').closest('label');
|
||||
expect(customFeatureRow).toBeInTheDocument();
|
||||
|
||||
// Should show a warning indicator
|
||||
const warningIndicator = within(customFeatureRow!).queryByTitle(/not in canonical catalog/i);
|
||||
expect(warningIndicator).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show warning for canonical features', () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
// sms_enabled is in the canonical catalog
|
||||
const smsFeatureRow = screen.getByText('SMS Enabled').closest('label');
|
||||
expect(smsFeatureRow).toBeInTheDocument();
|
||||
|
||||
const warningIndicator = within(smsFeatureRow!).queryByTitle(/not in canonical catalog/i);
|
||||
expect(warningIndicator).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search Functionality', () => {
|
||||
it('filters features when search term is entered', async () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search features/i);
|
||||
await userEvent.type(searchInput, 'sms');
|
||||
|
||||
expect(screen.getByText('SMS Enabled')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Email Enabled')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Maximum Users')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows no results message when search has no matches', async () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search features/i);
|
||||
await userEvent.type(searchInput, 'nonexistent');
|
||||
|
||||
expect(screen.getByText(/no features found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears search when clear button is clicked', async () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
const searchInput = screen.getByPlaceholderText(/search features/i);
|
||||
await userEvent.type(searchInput, 'sms');
|
||||
|
||||
const clearButton = screen.getByRole('button', { name: /clear search/i });
|
||||
await userEvent.click(clearButton);
|
||||
|
||||
expect(searchInput).toHaveValue('');
|
||||
expect(screen.getByText('Email Enabled')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Payload Shape', () => {
|
||||
it('produces correct payload shape for boolean features', async () => {
|
||||
const onChange = vi.fn();
|
||||
render(<FeaturePicker {...defaultProps} onChange={onChange} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /sms enabled/i }));
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /email enabled/i }));
|
||||
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
||||
|
||||
// Verify payload shape matches PlanFeatureWrite interface
|
||||
expect(lastCall).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
feature_code: expect.any(String),
|
||||
bool_value: expect.any(Boolean),
|
||||
int_value: null,
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('produces correct payload shape for integer features', async () => {
|
||||
const selectedFeatures = [
|
||||
{ feature_code: 'max_users', bool_value: null, int_value: 25 },
|
||||
];
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} onChange={onChange} />
|
||||
);
|
||||
|
||||
const input = screen.getByDisplayValue('25');
|
||||
await userEvent.clear(input);
|
||||
await userEvent.type(input, '100');
|
||||
|
||||
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1][0];
|
||||
|
||||
expect(lastCall).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
feature_code: 'max_users',
|
||||
bool_value: null,
|
||||
int_value: expect.any(Number),
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('produces correct payload shape for mixed selection', async () => {
|
||||
const onChangeCapture = vi.fn();
|
||||
render(
|
||||
<StatefulFeaturePicker
|
||||
features={mockApiFeatures}
|
||||
onChangeCapture={onChangeCapture}
|
||||
/>
|
||||
);
|
||||
|
||||
// Select a boolean feature
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /sms enabled/i }));
|
||||
// Select an integer feature
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /maximum users/i }));
|
||||
|
||||
const lastCall = onChangeCapture.mock.calls[onChangeCapture.mock.calls.length - 1][0];
|
||||
|
||||
expect(lastCall).toHaveLength(2);
|
||||
expect(lastCall).toContainEqual({
|
||||
feature_code: 'sms_enabled',
|
||||
bool_value: true,
|
||||
int_value: null,
|
||||
});
|
||||
expect(lastCall).toContainEqual({
|
||||
feature_code: 'max_users',
|
||||
bool_value: null,
|
||||
int_value: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has accessible labels for all checkboxes', () => {
|
||||
render(<FeaturePicker {...defaultProps} />);
|
||||
|
||||
// Each feature should have an accessible checkbox
|
||||
mockApiFeatures.forEach((feature) => {
|
||||
const checkbox = screen.getByRole('checkbox', { name: new RegExp(feature.name, 'i') });
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('integer input has accessible label', () => {
|
||||
const selectedFeatures = [
|
||||
{ feature_code: 'max_users', bool_value: null, int_value: 10 },
|
||||
];
|
||||
|
||||
render(<FeaturePicker {...defaultProps} selectedFeatures={selectedFeatures} />);
|
||||
|
||||
const input = screen.getByDisplayValue('10');
|
||||
expect(input).toHaveAttribute('aria-label');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user