/** * 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 }) => ( {children} ); }; // 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, }), useForceUpdatePlanVersion: () => ({ mutateAsync: vi.fn().mockResolvedValue({ version: { id: 1 }, affected_count: 5 }), isPending: false, }), isForceUpdateConfirmRequired: (response: unknown) => response !== null && typeof response === 'object' && 'requires_confirm' in response && (response as { requires_confirm: boolean }).requires_confirm === true, })); // Mock useCurrentUser from useAuth vi.mock('../../../hooks/useAuth', () => ({ useCurrentUser: () => ({ data: { id: 1, role: 'superuser', email: 'admin@test.com' }, isLoading: 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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(, { 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( , { 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( , { 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( , { 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(); }); }); }); describe('Force Update (Superuser)', () => { it('shows "Update Without Versioning" button for superuser editing plan with subscribers', async () => { const user = userEvent.setup(); render( , { wrapper: createWrapper() } ); // Navigate to last step await user.click(screen.getByRole('button', { name: /pricing/i })); await user.click(screen.getByRole('button', { name: /features/i })); await user.click(screen.getByRole('button', { name: /display/i })); // Should show "Update Without Versioning" button expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument(); }); it('shows confirmation dialog when clicking "Update Without Versioning"', async () => { const user = userEvent.setup(); render( , { wrapper: createWrapper() } ); // Navigate to last step await user.click(screen.getByRole('button', { name: /pricing/i })); await user.click(screen.getByRole('button', { name: /features/i })); await user.click(screen.getByRole('button', { name: /display/i })); // Click the force update button await user.click(screen.getByRole('button', { name: /update without versioning/i })); // Should show confirmation dialog with warning expect(screen.getByText(/warning: this will affect existing customers/i)).toBeInTheDocument(); expect(screen.getByText(/5/)).toBeInTheDocument(); // subscriber count expect(screen.getByRole('button', { name: /yes, update all subscribers/i })).toBeInTheDocument(); }); it('can cancel force update confirmation', async () => { const user = userEvent.setup(); render( , { wrapper: createWrapper() } ); // Navigate to last step await user.click(screen.getByRole('button', { name: /pricing/i })); await user.click(screen.getByRole('button', { name: /features/i })); await user.click(screen.getByRole('button', { name: /display/i })); // Click the force update button await user.click(screen.getByRole('button', { name: /update without versioning/i })); // Click Cancel const cancelButtons = screen.getAllByRole('button', { name: /cancel/i }); await user.click(cancelButtons[0]); // First cancel is in the confirmation dialog // Confirmation dialog should be hidden, back to normal footer expect(screen.queryByText(/warning: this will affect existing customers/i)).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: /update without versioning/i })).toBeInTheDocument(); }); it('does not show "Update Without Versioning" for plans without subscribers', async () => { const user = userEvent.setup(); render( , { wrapper: createWrapper() } ); // Navigate to last step await user.click(screen.getByRole('button', { name: /pricing/i })); await user.click(screen.getByRole('button', { name: /features/i })); await user.click(screen.getByRole('button', { name: /display/i })); // Should NOT show "Update Without Versioning" button expect(screen.queryByRole('button', { name: /update without versioning/i })).not.toBeInTheDocument(); }); }); });