- Add max_public_pages billing feature (Free=0, Starter=1, Growth=5, Pro=10) - Gate site builder access based on max_public_pages entitlement - Auto-create Site with default booking page for new tenants - Update PageEditor to use useEntitlements hook for permission checks - Replace hardcoded limits in BusinessEditModal with DynamicFeaturesEditor - Add force update functionality for superusers in PlanEditorWizard - Add comprehensive filters to all safe scripting get_* methods - Update plugin documentation with full filter reference 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
561 lines
19 KiB
TypeScript
561 lines
19 KiB
TypeScript
/**
|
|
* 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,
|
|
}),
|
|
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(<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();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('Force Update (Superuser)', () => {
|
|
it('shows "Update Without Versioning" button for superuser editing plan 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,
|
|
name: 'Pro v1',
|
|
},
|
|
}}
|
|
/>,
|
|
{ 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(
|
|
<PlanEditorWizard
|
|
{...defaultProps}
|
|
mode="edit"
|
|
initialData={{
|
|
id: 1,
|
|
code: 'pro',
|
|
name: 'Pro',
|
|
version: {
|
|
id: 1,
|
|
subscriber_count: 5,
|
|
name: 'Pro v1',
|
|
},
|
|
}}
|
|
/>,
|
|
{ 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(
|
|
<PlanEditorWizard
|
|
{...defaultProps}
|
|
mode="edit"
|
|
initialData={{
|
|
id: 1,
|
|
code: 'pro',
|
|
name: 'Pro',
|
|
version: {
|
|
id: 1,
|
|
subscriber_count: 5,
|
|
name: 'Pro v1',
|
|
},
|
|
}}
|
|
/>,
|
|
{ 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(
|
|
<PlanEditorWizard
|
|
{...defaultProps}
|
|
mode="edit"
|
|
initialData={{
|
|
id: 1,
|
|
code: 'pro',
|
|
name: 'Pro',
|
|
version: {
|
|
id: 1,
|
|
subscriber_count: 0, // No subscribers
|
|
name: 'Pro v1',
|
|
},
|
|
}}
|
|
/>,
|
|
{ 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();
|
|
});
|
|
});
|
|
});
|