/** * 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 & { initialSelectedFeatures?: PlanFeatureWrite[]; onChangeCapture?: (features: PlanFeatureWrite[]) => void; } > = ({ initialSelectedFeatures = [], onChangeCapture, ...props }) => { const [selectedFeatures, setSelectedFeatures] = useState(initialSelectedFeatures); const handleChange = (features: PlanFeatureWrite[]) => { setSelectedFeatures(features); onChangeCapture?.(features); }; return ( ); }; describe('FeaturePicker', () => { const defaultProps = { features: mockApiFeatures, selectedFeatures: [], onChange: vi.fn(), }; beforeEach(() => { vi.clearAllMocks(); }); describe('Rendering', () => { it('renders boolean features in Capabilities section', () => { render(); 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(); 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(); 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(); 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(); 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( ); 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(); 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( ); 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(); // 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(); // 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(); 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(); 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(); 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(); 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( ); 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( ); // 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(); // 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(); const input = screen.getByDisplayValue('10'); expect(input).toHaveAttribute('aria-label'); }); }); });