feat: Add comprehensive test suite and misc improvements
- Add frontend unit tests with Vitest for components, hooks, pages, and utilities - Add backend tests for webhooks, notifications, middleware, and edge cases - Add ForgotPassword, NotFound, and ResetPassword pages - Add migration for orphaned staff resources conversion - Add coverage directory to gitignore (generated reports) - Various bug fixes and improvements from previous work 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
533
frontend/src/components/marketing/__tests__/CTASection.test.tsx
Normal file
533
frontend/src/components/marketing/__tests__/CTASection.test.tsx
Normal file
@@ -0,0 +1,533 @@
|
||||
/**
|
||||
* Unit tests for CTASection component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering in both variants (default and minimal)
|
||||
* - CTA text rendering
|
||||
* - Button/link presence and navigation
|
||||
* - Click navigation behavior
|
||||
* - Icon display
|
||||
* - Internationalization (i18n)
|
||||
* - Accessibility
|
||||
* - Styling variations
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import CTASection from '../CTASection';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'marketing.cta.ready': 'Ready to get started?',
|
||||
'marketing.cta.readySubtitle': 'Join thousands of businesses already using SmoothSchedule.',
|
||||
'marketing.cta.startFree': 'Get Started Free',
|
||||
'marketing.cta.talkToSales': 'Talk to Sales',
|
||||
'marketing.cta.noCredit': 'No credit card required',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test wrapper with Router
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('CTASection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Default Variant', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render the CTA section', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render CTA text elements', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
// Main heading
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
|
||||
// Subtitle
|
||||
const subtitle = screen.getByText(/join thousands of businesses/i);
|
||||
expect(subtitle).toBeInTheDocument();
|
||||
|
||||
// No credit card required
|
||||
const disclaimer = screen.getByText(/no credit card required/i);
|
||||
expect(disclaimer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with correct text hierarchy', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading.tagName).toBe('H2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button/Link Presence', () => {
|
||||
it('should render the signup button', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the talk to sales button', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
|
||||
expect(salesButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render both CTA buttons', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should have correct href for signup button', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toHaveAttribute('href', '/signup');
|
||||
});
|
||||
|
||||
it('should have correct href for sales button', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
|
||||
expect(salesButton).toHaveAttribute('href', '/contact');
|
||||
});
|
||||
|
||||
it('should navigate when signup button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
|
||||
// Click should not throw error
|
||||
await expect(user.click(signupButton)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it('should navigate when sales button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
|
||||
|
||||
// Click should not throw error
|
||||
await expect(user.click(salesButton)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon Display', () => {
|
||||
it('should display ArrowRight icon on signup button', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
const icon = signupButton.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have correct icon size', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
const icon = signupButton.querySelector('svg');
|
||||
expect(icon).toHaveClass('h-5', 'w-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply gradient background', () => {
|
||||
const { container } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toHaveClass('bg-gradient-to-br', 'from-brand-600', 'to-brand-700');
|
||||
});
|
||||
|
||||
it('should apply correct padding', () => {
|
||||
const { container } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toHaveClass('py-20', 'lg:py-28');
|
||||
});
|
||||
|
||||
it('should style signup button as primary CTA', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toHaveClass('bg-white', 'text-brand-600');
|
||||
expect(signupButton).toHaveClass('hover:bg-brand-50');
|
||||
});
|
||||
|
||||
it('should style sales button as secondary CTA', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
|
||||
expect(salesButton).toHaveClass('bg-white/10', 'text-white');
|
||||
expect(salesButton).toHaveClass('hover:bg-white/20');
|
||||
});
|
||||
|
||||
it('should have responsive button layout', () => {
|
||||
const { container } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const buttonContainer = container.querySelector('.flex.flex-col.sm\\:flex-row');
|
||||
expect(buttonContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply shadow to signup button', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toHaveClass('shadow-lg', 'shadow-black/10');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Background Pattern', () => {
|
||||
it('should render decorative background elements', () => {
|
||||
const { container } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const backgroundPattern = container.querySelector('.absolute.inset-0');
|
||||
expect(backgroundPattern).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Minimal Variant', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render the minimal CTA section', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render CTA text in minimal variant', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
|
||||
const subtitle = screen.getByText(/join thousands of businesses/i);
|
||||
expect(subtitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should only render one button in minimal variant', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Button/Link Presence', () => {
|
||||
it('should render only the signup button', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the sales button', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const salesButton = screen.queryByRole('link', { name: /talk to sales/i });
|
||||
expect(salesButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render the disclaimer text', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const disclaimer = screen.queryByText(/no credit card required/i);
|
||||
expect(disclaimer).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should have correct href for signup button', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toHaveAttribute('href', '/signup');
|
||||
});
|
||||
|
||||
it('should navigate when button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
|
||||
// Click should not throw error
|
||||
await expect(user.click(signupButton)).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon Display', () => {
|
||||
it('should display ArrowRight icon', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
const icon = signupButton.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have correct icon size', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
const icon = signupButton.querySelector('svg');
|
||||
expect(icon).toHaveClass('h-5', 'w-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('should apply white background', () => {
|
||||
const { container } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toHaveClass('bg-white', 'dark:bg-gray-900');
|
||||
});
|
||||
|
||||
it('should apply minimal padding', () => {
|
||||
const { container } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toHaveClass('py-16');
|
||||
});
|
||||
|
||||
it('should use brand colors for button', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toHaveClass('bg-brand-600', 'text-white');
|
||||
expect(signupButton).toHaveClass('hover:bg-brand-700');
|
||||
});
|
||||
|
||||
it('should have smaller heading size', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading).toHaveClass('text-2xl', 'sm:text-3xl');
|
||||
});
|
||||
|
||||
it('should not have gradient background', () => {
|
||||
const { container } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).not.toHaveClass('bg-gradient-to-br');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Variant Comparison', () => {
|
||||
it('should render different layouts for different variants', () => {
|
||||
const { container: defaultContainer } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
const { container: minimalContainer } = render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const defaultSection = defaultContainer.querySelector('section');
|
||||
const minimalSection = minimalContainer.querySelector('section');
|
||||
|
||||
expect(defaultSection?.className).not.toEqual(minimalSection?.className);
|
||||
});
|
||||
|
||||
it('should use default variant when no variant prop provided', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
// Check for elements unique to default variant
|
||||
const salesButton = screen.queryByRole('link', { name: /talk to sales/i });
|
||||
expect(salesButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should switch variants correctly', () => {
|
||||
const { rerender } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
// Should have 2 buttons in default
|
||||
let links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(2);
|
||||
|
||||
rerender(<CTASection variant="minimal" />);
|
||||
|
||||
// Should have 1 button in minimal
|
||||
links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Internationalization', () => {
|
||||
it('should use translation for heading', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByText('Ready to get started?');
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for subtitle', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const subtitle = screen.getByText('Join thousands of businesses already using SmoothSchedule.');
|
||||
expect(subtitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for button text', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toHaveTextContent('Get Started Free');
|
||||
});
|
||||
|
||||
it('should use translation for sales button text', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
|
||||
expect(salesButton).toHaveTextContent('Talk to Sales');
|
||||
});
|
||||
|
||||
it('should use translation for disclaimer', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const disclaimer = screen.getByText('No credit card required');
|
||||
expect(disclaimer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should translate all text in minimal variant', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Ready to get started?')).toBeInTheDocument();
|
||||
expect(screen.getByText('Join thousands of businesses already using SmoothSchedule.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /get started free/i })).toHaveTextContent('Get Started Free');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have semantic section element', () => {
|
||||
const { container } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have heading hierarchy', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 2 });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have keyboard accessible links', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
|
||||
|
||||
expect(signupButton.tagName).toBe('A');
|
||||
expect(salesButton.tagName).toBe('A');
|
||||
});
|
||||
|
||||
it('should have descriptive link text', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
const salesButton = screen.getByRole('link', { name: /talk to sales/i });
|
||||
|
||||
expect(signupButton).toHaveAccessibleName();
|
||||
expect(salesButton).toHaveAccessibleName();
|
||||
});
|
||||
|
||||
it('should maintain accessibility in minimal variant', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 2 });
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(signupButton).toHaveAccessibleName();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Design', () => {
|
||||
it('should have responsive heading sizes', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading).toHaveClass('text-3xl', 'sm:text-4xl', 'lg:text-5xl');
|
||||
});
|
||||
|
||||
it('should have responsive subtitle size', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const subtitle = screen.getByText(/join thousands of businesses/i);
|
||||
expect(subtitle).toHaveClass('text-lg', 'sm:text-xl');
|
||||
});
|
||||
|
||||
it('should have responsive button layout', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const signupButton = screen.getByRole('link', { name: /get started free/i });
|
||||
expect(signupButton).toHaveClass('w-full', 'sm:w-auto');
|
||||
});
|
||||
|
||||
it('should have responsive padding in minimal variant', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /ready to get started/i });
|
||||
expect(heading).toHaveClass('text-2xl', 'sm:text-3xl');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render correctly with default variant', () => {
|
||||
render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByRole('heading', { name: /ready to get started/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/join thousands of businesses/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /get started free/i })).toHaveAttribute('href', '/signup');
|
||||
expect(screen.getByRole('link', { name: /talk to sales/i })).toHaveAttribute('href', '/contact');
|
||||
expect(screen.getByText(/no credit card required/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render correctly with minimal variant', () => {
|
||||
render(<CTASection variant="minimal" />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByRole('heading', { name: /ready to get started/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/join thousands of businesses/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /get started free/i })).toHaveAttribute('href', '/signup');
|
||||
expect(screen.queryByRole('link', { name: /talk to sales/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/no credit card required/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain structure with all elements in place', () => {
|
||||
const { container } = render(<CTASection />, { wrapper: createWrapper() });
|
||||
|
||||
const section = container.querySelector('section');
|
||||
const heading = screen.getByRole('heading');
|
||||
const subtitle = screen.getByText(/join thousands/i);
|
||||
const buttons = screen.getAllByRole('link');
|
||||
|
||||
expect(section).toContainElement(heading);
|
||||
expect(section).toContainElement(subtitle);
|
||||
buttons.forEach(button => {
|
||||
expect(section).toContainElement(button);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
362
frontend/src/components/marketing/__tests__/CodeBlock.test.tsx
Normal file
362
frontend/src/components/marketing/__tests__/CodeBlock.test.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
||||
import CodeBlock from '../CodeBlock';
|
||||
|
||||
describe('CodeBlock', () => {
|
||||
// Mock clipboard API
|
||||
const originalClipboard = navigator.clipboard;
|
||||
const mockWriteText = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: originalClipboard,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders code content correctly', () => {
|
||||
const code = 'print("Hello, World!")';
|
||||
const { container } = render(<CodeBlock code={code} />);
|
||||
|
||||
// Check that the code content is rendered (text is within code element)
|
||||
const codeElement = container.querySelector('code');
|
||||
expect(codeElement?.textContent).toContain('print(');
|
||||
// Due to string splitting in regex, checking for function call
|
||||
expect(container.querySelector('.text-blue-400')?.textContent).toContain('print(');
|
||||
});
|
||||
|
||||
it('renders multi-line code with line numbers', () => {
|
||||
const code = 'line 1\nline 2\nline 3';
|
||||
render(<CodeBlock code={code} />);
|
||||
|
||||
// Check line numbers
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
|
||||
// Check content
|
||||
expect(screen.getByText(/line 1/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/line 2/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/line 3/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders terminal-style dots', () => {
|
||||
render(<CodeBlock code="test code" />);
|
||||
|
||||
const container = screen.getByRole('button', { name: /copy code/i }).closest('div');
|
||||
expect(container).toBeInTheDocument();
|
||||
|
||||
// Check for the presence of the terminal-style dots container
|
||||
const dotsContainer = container?.querySelector('.flex.gap-1\\.5');
|
||||
expect(dotsContainer).toBeInTheDocument();
|
||||
expect(dotsContainer?.children).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Language and Filename', () => {
|
||||
it('applies default language class when no language specified', () => {
|
||||
const code = 'test code';
|
||||
render(<CodeBlock code={code} />);
|
||||
|
||||
const codeElement = screen.getByText(/test code/).closest('code');
|
||||
expect(codeElement).toHaveClass('language-python');
|
||||
});
|
||||
|
||||
it('applies custom language class when specified', () => {
|
||||
const code = 'const x = 1;';
|
||||
render(<CodeBlock code={code} language="javascript" />);
|
||||
|
||||
const codeElement = screen.getByText(/const x = 1/).closest('code');
|
||||
expect(codeElement).toHaveClass('language-javascript');
|
||||
});
|
||||
|
||||
it('displays filename when provided', () => {
|
||||
const code = 'test code';
|
||||
const filename = 'example.py';
|
||||
render(<CodeBlock code={code} filename={filename} />);
|
||||
|
||||
expect(screen.getByText(filename)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not display filename when not provided', () => {
|
||||
const code = 'test code';
|
||||
render(<CodeBlock code={code} />);
|
||||
|
||||
// The filename element should not exist in the DOM
|
||||
const filenameElement = screen.queryByText(/\.py$/);
|
||||
expect(filenameElement).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Copy Functionality', () => {
|
||||
it('renders copy button', () => {
|
||||
render(<CodeBlock code="test code" />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy code/i });
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('copies code to clipboard when copy button is clicked', async () => {
|
||||
const code = 'print("Copy me!")';
|
||||
mockWriteText.mockResolvedValue(undefined);
|
||||
|
||||
render(<CodeBlock code={code} />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy code/i });
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith(code);
|
||||
});
|
||||
|
||||
it('shows check icon after successful copy', async () => {
|
||||
const code = 'test code';
|
||||
mockWriteText.mockResolvedValue(undefined);
|
||||
|
||||
const { container } = render(<CodeBlock code={code} />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy code/i });
|
||||
|
||||
// Initially should show Copy icon
|
||||
let copyIcon = copyButton.querySelector('svg');
|
||||
expect(copyIcon).toBeInTheDocument();
|
||||
|
||||
// Click to copy
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
// Should immediately show Check icon (synchronous state update)
|
||||
const checkIcon = container.querySelector('.text-green-400');
|
||||
expect(checkIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reverts to copy icon after 2 seconds', () => {
|
||||
const code = 'test code';
|
||||
mockWriteText.mockResolvedValue(undefined);
|
||||
|
||||
const { container } = render(<CodeBlock code={code} />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy code/i });
|
||||
|
||||
// Click to copy
|
||||
fireEvent.click(copyButton);
|
||||
|
||||
// Should show Check icon
|
||||
let checkIcon = container.querySelector('.text-green-400');
|
||||
expect(checkIcon).toBeInTheDocument();
|
||||
|
||||
// Fast-forward 2 seconds using act to wrap state updates
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
// Should revert to Copy icon (check icon should be gone)
|
||||
checkIcon = container.querySelector('.text-green-400');
|
||||
expect(checkIcon).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Syntax Highlighting', () => {
|
||||
it('highlights Python comments', () => {
|
||||
const code = '# This is a comment';
|
||||
render(<CodeBlock code={code} language="python" />);
|
||||
|
||||
const commentElement = screen.getByText(/This is a comment/);
|
||||
expect(commentElement).toBeInTheDocument();
|
||||
expect(commentElement).toHaveClass('text-gray-500');
|
||||
});
|
||||
|
||||
it('highlights JavaScript comments', () => {
|
||||
const code = '// This is a comment';
|
||||
render(<CodeBlock code={code} language="javascript" />);
|
||||
|
||||
const commentElement = screen.getByText(/This is a comment/);
|
||||
expect(commentElement).toBeInTheDocument();
|
||||
expect(commentElement).toHaveClass('text-gray-500');
|
||||
});
|
||||
|
||||
it('highlights string literals', () => {
|
||||
const code = 'print("Hello World")';
|
||||
const { container } = render(<CodeBlock code={code} />);
|
||||
|
||||
const stringElements = container.querySelectorAll('.text-green-400');
|
||||
expect(stringElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('highlights Python keywords', () => {
|
||||
const code = 'def my_function():';
|
||||
const { container } = render(<CodeBlock code={code} language="python" />);
|
||||
|
||||
const keywordElements = container.querySelectorAll('.text-purple-400');
|
||||
expect(keywordElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('highlights function calls', () => {
|
||||
const code = 'print("test")';
|
||||
const { container } = render(<CodeBlock code={code} />);
|
||||
|
||||
const functionElements = container.querySelectorAll('.text-blue-400');
|
||||
expect(functionElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('highlights multiple keywords in a line', () => {
|
||||
const code = 'if True return None';
|
||||
const { container } = render(<CodeBlock code={code} />);
|
||||
|
||||
const keywordElements = container.querySelectorAll('.text-purple-400');
|
||||
// Should highlight 'if', 'True', 'return', and 'None'
|
||||
expect(keywordElements.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('does not highlight non-keyword words', () => {
|
||||
const code = 'my_variable = 42';
|
||||
render(<CodeBlock code={code} />);
|
||||
|
||||
const codeText = screen.getByText(/my_variable/);
|
||||
expect(codeText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Complex Code Examples', () => {
|
||||
it('handles Python code with multiple syntax elements', () => {
|
||||
const code = `def greet(name):
|
||||
# Print a greeting
|
||||
return "Hello, " + name`;
|
||||
|
||||
render(<CodeBlock code={code} language="python" />);
|
||||
|
||||
// Check that all lines are rendered
|
||||
expect(screen.getByText(/def/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Print a greeting/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/return/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles JavaScript code', () => {
|
||||
const code = `const greeting = "Hello";
|
||||
// Log the greeting
|
||||
console.log(greeting);`;
|
||||
|
||||
render(<CodeBlock code={code} language="javascript" />);
|
||||
|
||||
expect(screen.getByText(/const greeting =/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Log the greeting/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/console.log/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('preserves indentation and whitespace', () => {
|
||||
const code = `def test():
|
||||
if True:
|
||||
return 1`;
|
||||
|
||||
const { container } = render(<CodeBlock code={code} />);
|
||||
|
||||
// Check for whitespace-pre class which preserves whitespace
|
||||
const codeLines = container.querySelectorAll('.whitespace-pre');
|
||||
expect(codeLines.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles empty code string', () => {
|
||||
render(<CodeBlock code="" />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy code/i });
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles code with only whitespace', () => {
|
||||
const code = ' \n \n ';
|
||||
render(<CodeBlock code={code} />);
|
||||
|
||||
// Should still render line numbers
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles very long single line', () => {
|
||||
const code = 'x = ' + 'a'.repeat(1000);
|
||||
render(<CodeBlock code={code} />);
|
||||
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles special characters in code', () => {
|
||||
const code = 'const regex = /[a-z]+/g;';
|
||||
render(<CodeBlock code={code} language="javascript" />);
|
||||
|
||||
expect(screen.getByText(/regex/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles quotes within strings', () => {
|
||||
const code = 'const msg = "test message";';
|
||||
const { container } = render(<CodeBlock code={code} language="javascript" />);
|
||||
|
||||
// Code should be rendered
|
||||
expect(container.querySelector('code')).toBeInTheDocument();
|
||||
// Should have string highlighting
|
||||
expect(container.querySelectorAll('.text-green-400').length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('has accessible copy button with title', () => {
|
||||
render(<CodeBlock code="test" />);
|
||||
|
||||
const copyButton = screen.getByRole('button', { name: /copy code/i });
|
||||
expect(copyButton).toHaveAttribute('title', 'Copy code');
|
||||
});
|
||||
|
||||
it('uses semantic HTML elements', () => {
|
||||
const { container } = render(<CodeBlock code="test" />);
|
||||
|
||||
const preElement = container.querySelector('pre');
|
||||
const codeElement = container.querySelector('code');
|
||||
|
||||
expect(preElement).toBeInTheDocument();
|
||||
expect(codeElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('line numbers are not selectable', () => {
|
||||
const { container } = render(<CodeBlock code="line 1\nline 2" />);
|
||||
|
||||
const lineNumbers = container.querySelectorAll('.select-none');
|
||||
expect(lineNumbers.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling', () => {
|
||||
it('applies dark theme styling', () => {
|
||||
const { container } = render(<CodeBlock code="test" />);
|
||||
|
||||
const mainContainer = container.querySelector('.bg-gray-900');
|
||||
expect(mainContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies proper border and shadow', () => {
|
||||
const { container } = render(<CodeBlock code="test" />);
|
||||
|
||||
const mainContainer = container.querySelector('.border-gray-800.shadow-2xl');
|
||||
expect(mainContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies monospace font to code', () => {
|
||||
const { container } = render(<CodeBlock code="test" />);
|
||||
|
||||
const preElement = container.querySelector('pre.font-mono');
|
||||
expect(preElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies correct text colors', () => {
|
||||
const { container } = render(<CodeBlock code="test" />);
|
||||
|
||||
const codeText = container.querySelector('.text-gray-300');
|
||||
expect(codeText).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Unit tests for FAQAccordion component
|
||||
*
|
||||
* Tests the FAQ accordion functionality including:
|
||||
* - Rendering questions and answers
|
||||
* - Expanding and collapsing items
|
||||
* - Single-item accordion behavior (only one open at a time)
|
||||
* - Accessibility attributes
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import FAQAccordion from '../FAQAccordion';
|
||||
|
||||
// Test data
|
||||
const mockFAQItems = [
|
||||
{
|
||||
question: 'What is SmoothSchedule?',
|
||||
answer: 'SmoothSchedule is a comprehensive scheduling platform for businesses.',
|
||||
},
|
||||
{
|
||||
question: 'How much does it cost?',
|
||||
answer: 'We offer flexible pricing plans starting at $29/month.',
|
||||
},
|
||||
{
|
||||
question: 'Can I try it for free?',
|
||||
answer: 'Yes! We offer a 14-day free trial with no credit card required.',
|
||||
},
|
||||
];
|
||||
|
||||
describe('FAQAccordion', () => {
|
||||
describe('Rendering', () => {
|
||||
it('should render all questions', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
expect(screen.getByText('What is SmoothSchedule?')).toBeInTheDocument();
|
||||
expect(screen.getByText('How much does it cost?')).toBeInTheDocument();
|
||||
expect(screen.getByText('Can I try it for free?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render first item as expanded by default', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
// First answer should be visible
|
||||
expect(
|
||||
screen.getByText('SmoothSchedule is a comprehensive scheduling platform for businesses.')
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Other answers should not be visible
|
||||
expect(
|
||||
screen.queryByText('We offer flexible pricing plans starting at $29/month.')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Yes! We offer a 14-day free trial with no credit card required.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with empty items array', () => {
|
||||
const { container } = render(<FAQAccordion items={[]} />);
|
||||
|
||||
// Should render the container but no items
|
||||
expect(container.querySelector('.space-y-4')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('button')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should render with single item', () => {
|
||||
const singleItem = [mockFAQItems[0]];
|
||||
render(<FAQAccordion items={singleItem} />);
|
||||
|
||||
expect(screen.getByText('What is SmoothSchedule?')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('SmoothSchedule is a comprehensive scheduling platform for businesses.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have aria-expanded attribute on buttons', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
// First button should be expanded (default)
|
||||
expect(buttons[0]).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Other buttons should be collapsed
|
||||
expect(buttons[1]).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(buttons[2]).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('should update aria-expanded when item is toggled', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const secondButton = buttons[1];
|
||||
|
||||
// Initially collapsed
|
||||
expect(secondButton).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(secondButton);
|
||||
|
||||
// Now expanded
|
||||
expect(secondButton).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('should have proper button semantics', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
buttons.forEach((button) => {
|
||||
// Each button should have text content
|
||||
expect(button.textContent).toBeTruthy();
|
||||
|
||||
// Each button should be clickable
|
||||
expect(button).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Expand/Collapse Behavior', () => {
|
||||
it('should expand answer when question is clicked', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const secondQuestion = screen.getByText('How much does it cost?');
|
||||
|
||||
// Answer should be in the document but potentially hidden
|
||||
const answer = screen.getByText('We offer flexible pricing plans starting at $29/month.');
|
||||
const answerContainer = answer.closest('.overflow-hidden');
|
||||
|
||||
// Initially collapsed (max-h-0)
|
||||
expect(answerContainer).toHaveClass('max-h-0');
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(secondQuestion);
|
||||
|
||||
// Now expanded (max-h-96)
|
||||
expect(answerContainer).toHaveClass('max-h-96');
|
||||
});
|
||||
|
||||
it('should collapse answer when clicking expanded question', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const firstQuestion = screen.getByText('What is SmoothSchedule?');
|
||||
const answer = screen.getByText(
|
||||
'SmoothSchedule is a comprehensive scheduling platform for businesses.'
|
||||
);
|
||||
const answerContainer = answer.closest('.overflow-hidden');
|
||||
|
||||
// Initially expanded (first item is open by default)
|
||||
expect(answerContainer).toHaveClass('max-h-96');
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(firstQuestion);
|
||||
|
||||
// Now collapsed
|
||||
expect(answerContainer).toHaveClass('max-h-0');
|
||||
});
|
||||
|
||||
it('should collapse answer when clicking it again (toggle)', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const secondQuestion = screen.getByText('How much does it cost?');
|
||||
const answer = screen.getByText('We offer flexible pricing plans starting at $29/month.');
|
||||
const answerContainer = answer.closest('.overflow-hidden');
|
||||
|
||||
// Initially collapsed
|
||||
expect(answerContainer).toHaveClass('max-h-0');
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(secondQuestion);
|
||||
expect(answerContainer).toHaveClass('max-h-96');
|
||||
|
||||
// Click again to collapse
|
||||
fireEvent.click(secondQuestion);
|
||||
expect(answerContainer).toHaveClass('max-h-0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Single Item Accordion Behavior', () => {
|
||||
it('should only allow one item to be expanded at a time', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const firstQuestion = screen.getByText('What is SmoothSchedule?');
|
||||
const secondQuestion = screen.getByText('How much does it cost?');
|
||||
const thirdQuestion = screen.getByText('Can I try it for free?');
|
||||
|
||||
const firstAnswer = screen.getByText(
|
||||
'SmoothSchedule is a comprehensive scheduling platform for businesses.'
|
||||
);
|
||||
const secondAnswer = screen.getByText(
|
||||
'We offer flexible pricing plans starting at $29/month.'
|
||||
);
|
||||
const thirdAnswer = screen.getByText(
|
||||
'Yes! We offer a 14-day free trial with no credit card required.'
|
||||
);
|
||||
|
||||
// Initially, first item is expanded
|
||||
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
|
||||
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
|
||||
// Click second question
|
||||
fireEvent.click(secondQuestion);
|
||||
|
||||
// Now only second item is expanded
|
||||
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
|
||||
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
|
||||
// Click third question
|
||||
fireEvent.click(thirdQuestion);
|
||||
|
||||
// Now only third item is expanded
|
||||
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
|
||||
|
||||
// Click first question
|
||||
fireEvent.click(firstQuestion);
|
||||
|
||||
// Back to first item expanded
|
||||
expect(firstAnswer.closest('.overflow-hidden')).toHaveClass('max-h-96');
|
||||
expect(secondAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
expect(thirdAnswer.closest('.overflow-hidden')).toHaveClass('max-h-0');
|
||||
});
|
||||
|
||||
it('should close the currently open item when opening another', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
// First button is expanded by default
|
||||
expect(buttons[0]).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(buttons[1]).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
// Click second button
|
||||
fireEvent.click(buttons[1]);
|
||||
|
||||
// First button should now be collapsed, second expanded
|
||||
expect(buttons[0]).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(buttons[1]).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('should allow collapsing all items by clicking the open one', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const firstQuestion = screen.getByText('What is SmoothSchedule?');
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
// Initially first item is expanded
|
||||
expect(buttons[0]).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(firstQuestion);
|
||||
|
||||
// All items should be collapsed
|
||||
buttons.forEach((button) => {
|
||||
expect(button).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Chevron Icon Rotation', () => {
|
||||
it('should rotate chevron icon when item is expanded', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const firstButton = buttons[0];
|
||||
const secondButton = buttons[1];
|
||||
|
||||
// First item is expanded, so chevron should be rotated
|
||||
const firstChevron = firstButton.querySelector('svg');
|
||||
expect(firstChevron).toHaveClass('rotate-180');
|
||||
|
||||
// Second item is collapsed, so chevron should not be rotated
|
||||
const secondChevron = secondButton.querySelector('svg');
|
||||
expect(secondChevron).not.toHaveClass('rotate-180');
|
||||
|
||||
// Click second button
|
||||
fireEvent.click(secondButton);
|
||||
|
||||
// Now second chevron should be rotated, first should not
|
||||
expect(firstChevron).not.toHaveClass('rotate-180');
|
||||
expect(secondChevron).toHaveClass('rotate-180');
|
||||
});
|
||||
|
||||
it('should toggle chevron rotation when item is clicked multiple times', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const firstButton = screen.getAllByRole('button')[0];
|
||||
const chevron = firstButton.querySelector('svg');
|
||||
|
||||
// Initially rotated (first item is expanded)
|
||||
expect(chevron).toHaveClass('rotate-180');
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(firstButton);
|
||||
expect(chevron).not.toHaveClass('rotate-180');
|
||||
|
||||
// Click to expand
|
||||
fireEvent.click(firstButton);
|
||||
expect(chevron).toHaveClass('rotate-180');
|
||||
|
||||
// Click to collapse again
|
||||
fireEvent.click(firstButton);
|
||||
expect(chevron).not.toHaveClass('rotate-180');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle items with long text content', () => {
|
||||
const longTextItems = [
|
||||
{
|
||||
question: 'This is a very long question that might wrap to multiple lines in the UI?',
|
||||
answer:
|
||||
'This is a very long answer with lots of text. ' +
|
||||
'It contains multiple sentences and provides detailed information. ' +
|
||||
'The accordion should handle this gracefully without breaking the layout. ' +
|
||||
'Users should be able to read all of this content when the item is expanded.',
|
||||
},
|
||||
];
|
||||
|
||||
render(<FAQAccordion items={longTextItems} />);
|
||||
|
||||
expect(
|
||||
screen.getByText('This is a very long question that might wrap to multiple lines in the UI?')
|
||||
).toBeInTheDocument();
|
||||
|
||||
const answer = screen.getByText(/This is a very long answer with lots of text/);
|
||||
expect(answer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle items with special characters', () => {
|
||||
const specialCharItems = [
|
||||
{
|
||||
question: 'What about <special> & "characters"?',
|
||||
answer: 'We support all UTF-8 characters: é, ñ, 中文, 日本語!',
|
||||
},
|
||||
];
|
||||
|
||||
render(<FAQAccordion items={specialCharItems} />);
|
||||
|
||||
expect(screen.getByText('What about <special> & "characters"?')).toBeInTheDocument();
|
||||
expect(screen.getByText('We support all UTF-8 characters: é, ñ, 中文, 日本語!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle rapid clicking without breaking', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const buttons = screen.getAllByRole('button');
|
||||
|
||||
// Rapidly click different buttons
|
||||
fireEvent.click(buttons[0]);
|
||||
fireEvent.click(buttons[1]);
|
||||
fireEvent.click(buttons[2]);
|
||||
fireEvent.click(buttons[0]);
|
||||
fireEvent.click(buttons[1]);
|
||||
|
||||
// Should still be functional - second button should be expanded
|
||||
expect(buttons[1]).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(buttons[0]).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(buttons[2]).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('should handle clicking on the same item multiple times', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const firstButton = screen.getAllByRole('button')[0];
|
||||
|
||||
// Initially expanded
|
||||
expect(firstButton).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// Click multiple times
|
||||
fireEvent.click(firstButton);
|
||||
expect(firstButton).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
fireEvent.click(firstButton);
|
||||
expect(firstButton).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
fireEvent.click(firstButton);
|
||||
expect(firstButton).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Visual States', () => {
|
||||
it('should apply correct CSS classes for expanded state', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const firstAnswer = screen.getByText(
|
||||
'SmoothSchedule is a comprehensive scheduling platform for businesses.'
|
||||
);
|
||||
const answerContainer = firstAnswer.closest('.overflow-hidden');
|
||||
|
||||
// Expanded state should have max-h-96
|
||||
expect(answerContainer).toHaveClass('max-h-96');
|
||||
expect(answerContainer).toHaveClass('transition-all');
|
||||
expect(answerContainer).toHaveClass('duration-200');
|
||||
});
|
||||
|
||||
it('should apply correct CSS classes for collapsed state', () => {
|
||||
render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
const secondAnswer = screen.getByText('We offer flexible pricing plans starting at $29/month.');
|
||||
const answerContainer = secondAnswer.closest('.overflow-hidden');
|
||||
|
||||
// Collapsed state should have max-h-0
|
||||
expect(answerContainer).toHaveClass('max-h-0');
|
||||
expect(answerContainer).toHaveClass('overflow-hidden');
|
||||
});
|
||||
|
||||
it('should have proper container structure', () => {
|
||||
const { container } = render(<FAQAccordion items={mockFAQItems} />);
|
||||
|
||||
// Root container should have space-y-4
|
||||
const rootDiv = container.querySelector('.space-y-4');
|
||||
expect(rootDiv).toBeInTheDocument();
|
||||
|
||||
// Each item should have proper styling
|
||||
const itemContainers = container.querySelectorAll('.bg-white');
|
||||
expect(itemContainers).toHaveLength(mockFAQItems.length);
|
||||
|
||||
itemContainers.forEach((item) => {
|
||||
expect(item).toHaveClass('rounded-xl');
|
||||
expect(item).toHaveClass('border');
|
||||
expect(item).toHaveClass('overflow-hidden');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
688
frontend/src/components/marketing/__tests__/FeatureCard.test.tsx
Normal file
688
frontend/src/components/marketing/__tests__/FeatureCard.test.tsx
Normal file
@@ -0,0 +1,688 @@
|
||||
/**
|
||||
* Unit tests for FeatureCard component
|
||||
*
|
||||
* Tests the FeatureCard marketing component including:
|
||||
* - Basic rendering with title and description
|
||||
* - Icon rendering with different colors
|
||||
* - CSS classes and styling
|
||||
* - Hover states and animations
|
||||
* - Accessibility
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Calendar, Clock, Users, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import FeatureCard from '../FeatureCard';
|
||||
|
||||
describe('FeatureCard', () => {
|
||||
describe('Basic Rendering', () => {
|
||||
it('should render with title and description', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Easy Scheduling"
|
||||
description="Schedule appointments with ease using our intuitive calendar interface."
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Easy Scheduling')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Schedule appointments with ease using our intuitive calendar interface.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with different content', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Users}
|
||||
title="Team Management"
|
||||
description="Manage your team members and their availability efficiently."
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Team Management')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Manage your team members and their availability efficiently.')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with long description text', () => {
|
||||
const longDescription =
|
||||
'This is a very long description that contains multiple sentences. It should wrap properly and display all the content. Our feature card component is designed to handle various lengths of text gracefully.';
|
||||
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Clock}
|
||||
title="Time Tracking"
|
||||
description={longDescription}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(longDescription)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with empty description', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={CheckCircle}
|
||||
title="Success Tracking"
|
||||
description=""
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Success Tracking')).toBeInTheDocument();
|
||||
// Empty description should still render the paragraph element
|
||||
const descriptionElement = screen.getByText('Success Tracking').parentElement?.querySelector('p');
|
||||
expect(descriptionElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon Rendering', () => {
|
||||
it('should render the provided icon', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Calendar Feature"
|
||||
description="Calendar description"
|
||||
/>
|
||||
);
|
||||
|
||||
// Check for SVG element (icons are rendered as SVG)
|
||||
const svgElement = container.querySelector('svg');
|
||||
expect(svgElement).toBeInTheDocument();
|
||||
expect(svgElement).toHaveClass('h-6', 'w-6');
|
||||
});
|
||||
|
||||
it('should render different icons correctly', () => {
|
||||
const { container: container1 } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Feature 1"
|
||||
description="Description 1"
|
||||
/>
|
||||
);
|
||||
|
||||
const { container: container2 } = render(
|
||||
<FeatureCard
|
||||
icon={Users}
|
||||
title="Feature 2"
|
||||
description="Description 2"
|
||||
/>
|
||||
);
|
||||
|
||||
// Both should have SVG elements
|
||||
expect(container1.querySelector('svg')).toBeInTheDocument();
|
||||
expect(container2.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply correct icon size classes', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Clock}
|
||||
title="Time Feature"
|
||||
description="Time description"
|
||||
/>
|
||||
);
|
||||
|
||||
const svgElement = container.querySelector('svg');
|
||||
expect(svgElement).toHaveClass('h-6');
|
||||
expect(svgElement).toHaveClass('w-6');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icon Colors', () => {
|
||||
it('should render with default brand color when no iconColor prop provided', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Default Color"
|
||||
description="Uses brand color by default"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-brand-100');
|
||||
expect(iconWrapper).toHaveClass('dark:bg-brand-900/30');
|
||||
expect(iconWrapper).toHaveClass('text-brand-600');
|
||||
expect(iconWrapper).toHaveClass('dark:text-brand-400');
|
||||
});
|
||||
|
||||
it('should render with brand color when explicitly set', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Brand Color"
|
||||
description="Explicit brand color"
|
||||
iconColor="brand"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-brand-100');
|
||||
expect(iconWrapper).toHaveClass('text-brand-600');
|
||||
});
|
||||
|
||||
it('should render with green color', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={CheckCircle}
|
||||
title="Success Feature"
|
||||
description="Green icon color"
|
||||
iconColor="green"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-green-100');
|
||||
expect(iconWrapper).toHaveClass('dark:bg-green-900/30');
|
||||
expect(iconWrapper).toHaveClass('text-green-600');
|
||||
expect(iconWrapper).toHaveClass('dark:text-green-400');
|
||||
});
|
||||
|
||||
it('should render with purple color', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Users}
|
||||
title="Purple Feature"
|
||||
description="Purple icon color"
|
||||
iconColor="purple"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-purple-100');
|
||||
expect(iconWrapper).toHaveClass('text-purple-600');
|
||||
});
|
||||
|
||||
it('should render with orange color', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={AlertCircle}
|
||||
title="Warning Feature"
|
||||
description="Orange icon color"
|
||||
iconColor="orange"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-orange-100');
|
||||
expect(iconWrapper).toHaveClass('text-orange-600');
|
||||
});
|
||||
|
||||
it('should render with pink color', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Pink Feature"
|
||||
description="Pink icon color"
|
||||
iconColor="pink"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-pink-100');
|
||||
expect(iconWrapper).toHaveClass('text-pink-600');
|
||||
});
|
||||
|
||||
it('should render with cyan color', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Clock}
|
||||
title="Cyan Feature"
|
||||
description="Cyan icon color"
|
||||
iconColor="cyan"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-cyan-100');
|
||||
expect(iconWrapper).toHaveClass('text-cyan-600');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling and CSS Classes', () => {
|
||||
it('should apply base card styling classes', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Card Styling"
|
||||
description="Testing base styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('group');
|
||||
expect(cardElement).toHaveClass('p-6');
|
||||
expect(cardElement).toHaveClass('bg-white');
|
||||
expect(cardElement).toHaveClass('dark:bg-gray-800');
|
||||
expect(cardElement).toHaveClass('rounded-2xl');
|
||||
});
|
||||
|
||||
it('should apply border classes', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Border Test"
|
||||
description="Testing border styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('border');
|
||||
expect(cardElement).toHaveClass('border-gray-200');
|
||||
expect(cardElement).toHaveClass('dark:border-gray-700');
|
||||
});
|
||||
|
||||
it('should apply hover border classes', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Hover Border"
|
||||
description="Testing hover border styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('hover:border-brand-300');
|
||||
expect(cardElement).toHaveClass('dark:hover:border-brand-700');
|
||||
});
|
||||
|
||||
it('should apply shadow classes', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Shadow Test"
|
||||
description="Testing shadow styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('hover:shadow-lg');
|
||||
expect(cardElement).toHaveClass('hover:shadow-brand-600/5');
|
||||
});
|
||||
|
||||
it('should apply transition classes', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Transition Test"
|
||||
description="Testing transition styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('transition-all');
|
||||
expect(cardElement).toHaveClass('duration-300');
|
||||
});
|
||||
|
||||
it('should apply icon wrapper styling', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Icon Wrapper"
|
||||
description="Testing icon wrapper styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('p-3');
|
||||
expect(iconWrapper).toHaveClass('rounded-xl');
|
||||
expect(iconWrapper).toHaveClass('mb-4');
|
||||
});
|
||||
|
||||
it('should apply title styling', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Title Styling"
|
||||
description="Testing title styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const titleElement = screen.getByText('Title Styling');
|
||||
expect(titleElement).toHaveClass('text-lg');
|
||||
expect(titleElement).toHaveClass('font-semibold');
|
||||
expect(titleElement).toHaveClass('text-gray-900');
|
||||
expect(titleElement).toHaveClass('dark:text-white');
|
||||
expect(titleElement).toHaveClass('mb-2');
|
||||
});
|
||||
|
||||
it('should apply title hover classes', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Hover Title"
|
||||
description="Testing title hover styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const titleElement = screen.getByText('Hover Title');
|
||||
expect(titleElement).toHaveClass('group-hover:text-brand-600');
|
||||
expect(titleElement).toHaveClass('dark:group-hover:text-brand-400');
|
||||
expect(titleElement).toHaveClass('transition-colors');
|
||||
});
|
||||
|
||||
it('should apply description styling', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Description Style"
|
||||
description="Testing description styles"
|
||||
/>
|
||||
);
|
||||
|
||||
const descriptionElement = screen.getByText('Testing description styles');
|
||||
expect(descriptionElement).toHaveClass('text-gray-600');
|
||||
expect(descriptionElement).toHaveClass('dark:text-gray-400');
|
||||
expect(descriptionElement).toHaveClass('leading-relaxed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hover and Animation States', () => {
|
||||
it('should have group class for hover effects', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Group Hover"
|
||||
description="Testing group hover functionality"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('group');
|
||||
});
|
||||
|
||||
it('should support mouse hover interactions', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Mouse Hover"
|
||||
description="Testing mouse hover"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
|
||||
// Hovering should not cause errors
|
||||
await user.hover(cardElement);
|
||||
expect(cardElement).toBeInTheDocument();
|
||||
|
||||
// Unhovering should not cause errors
|
||||
await user.unhover(cardElement);
|
||||
expect(cardElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain structure during hover', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Structure Test"
|
||||
description="Testing structure during hover"
|
||||
/>
|
||||
);
|
||||
|
||||
const titleElement = screen.getByText('Structure Test');
|
||||
const descriptionElement = screen.getByText('Testing structure during hover');
|
||||
|
||||
// Hover over the card
|
||||
await user.hover(titleElement.closest('.group')!);
|
||||
|
||||
// Elements should still be present
|
||||
expect(titleElement).toBeInTheDocument();
|
||||
expect(descriptionElement).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should use semantic HTML heading for title', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Semantic Title"
|
||||
description="Testing semantic HTML"
|
||||
/>
|
||||
);
|
||||
|
||||
const titleElement = screen.getByText('Semantic Title');
|
||||
expect(titleElement.tagName).toBe('H3');
|
||||
});
|
||||
|
||||
it('should use paragraph element for description', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Semantic Description"
|
||||
description="Testing paragraph element"
|
||||
/>
|
||||
);
|
||||
|
||||
const descriptionElement = screen.getByText('Testing paragraph element');
|
||||
expect(descriptionElement.tagName).toBe('P');
|
||||
});
|
||||
|
||||
it('should maintain readable text contrast', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Contrast Test"
|
||||
description="Testing text contrast"
|
||||
/>
|
||||
);
|
||||
|
||||
const titleElement = screen.getByText('Contrast Test');
|
||||
const descriptionElement = screen.getByText('Testing text contrast');
|
||||
|
||||
// Title should have dark text (gray-900)
|
||||
expect(titleElement).toHaveClass('text-gray-900');
|
||||
// Description should have readable gray
|
||||
expect(descriptionElement).toHaveClass('text-gray-600');
|
||||
});
|
||||
|
||||
it('should be keyboard accessible when used in interactive context', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Keyboard Test"
|
||||
description="Testing keyboard accessibility"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
// Card itself is not interactive, so it shouldn't have tabIndex
|
||||
expect(cardElement).not.toHaveAttribute('tabIndex');
|
||||
});
|
||||
|
||||
it('should support screen readers with proper text hierarchy', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Screen Reader Test"
|
||||
description="This is a longer description that screen readers will announce."
|
||||
/>
|
||||
);
|
||||
|
||||
// Check that heading comes before paragraph in DOM order
|
||||
const heading = container.querySelector('h3');
|
||||
const paragraph = container.querySelector('p');
|
||||
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(paragraph).toBeInTheDocument();
|
||||
|
||||
// Verify DOM order (heading should appear before paragraph)
|
||||
const headingPosition = Array.from(container.querySelectorAll('*')).indexOf(heading!);
|
||||
const paragraphPosition = Array.from(container.querySelectorAll('*')).indexOf(paragraph!);
|
||||
expect(headingPosition).toBeLessThan(paragraphPosition);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Support', () => {
|
||||
it('should include dark mode classes for card background', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Dark Mode Card"
|
||||
description="Testing dark mode"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('dark:bg-gray-800');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for borders', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Dark Mode Border"
|
||||
description="Testing dark mode borders"
|
||||
/>
|
||||
);
|
||||
|
||||
const cardElement = container.firstChild as HTMLElement;
|
||||
expect(cardElement).toHaveClass('dark:border-gray-700');
|
||||
expect(cardElement).toHaveClass('dark:hover:border-brand-700');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for title text', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Dark Mode Title"
|
||||
description="Testing dark mode title"
|
||||
/>
|
||||
);
|
||||
|
||||
const titleElement = screen.getByText('Dark Mode Title');
|
||||
expect(titleElement).toHaveClass('dark:text-white');
|
||||
expect(titleElement).toHaveClass('dark:group-hover:text-brand-400');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for description text', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Dark Mode Description"
|
||||
description="Testing dark mode description"
|
||||
/>
|
||||
);
|
||||
|
||||
const descriptionElement = screen.getByText('Testing dark mode description');
|
||||
expect(descriptionElement).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for icon colors', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Dark Mode Icon"
|
||||
description="Testing dark mode icon"
|
||||
iconColor="green"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('dark:bg-green-900/30');
|
||||
expect(iconWrapper).toHaveClass('dark:text-green-400');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Component Props Validation', () => {
|
||||
it('should handle all required props', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Required Props"
|
||||
description="All required props provided"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
expect(screen.getByText('Required Props')).toBeInTheDocument();
|
||||
expect(screen.getByText('All required props provided')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle optional iconColor prop', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Optional Props"
|
||||
description="Optional iconColor provided"
|
||||
iconColor="purple"
|
||||
/>
|
||||
);
|
||||
|
||||
const iconWrapper = container.querySelector('.inline-flex');
|
||||
expect(iconWrapper).toHaveClass('bg-purple-100');
|
||||
});
|
||||
|
||||
it('should render correctly with minimal props', () => {
|
||||
const { container } = render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Min Props"
|
||||
description="Minimal props"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle very long title text', () => {
|
||||
const longTitle = 'This is a very long title that might wrap to multiple lines in the card';
|
||||
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title={longTitle}
|
||||
description="Normal description"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(longTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle special characters in title', () => {
|
||||
const specialTitle = 'Special <>&"\' Characters';
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title={specialTitle}
|
||||
description="Testing special chars"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(specialTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle special characters in description', () => {
|
||||
const specialDescription = 'Description with <>&"\' special characters';
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Special Chars"
|
||||
description={specialDescription}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(specialDescription)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle unicode characters', () => {
|
||||
render(
|
||||
<FeatureCard
|
||||
icon={Calendar}
|
||||
title="Unicode Test 你好 🎉"
|
||||
description="Description with émojis and 中文"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Unicode Test 你好 🎉")).toBeInTheDocument();
|
||||
expect(screen.getByText("Description with émojis and 中文")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
544
frontend/src/components/marketing/__tests__/Footer.test.tsx
Normal file
544
frontend/src/components/marketing/__tests__/Footer.test.tsx
Normal file
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* Unit tests for Footer component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering with all sections
|
||||
* - Footer navigation links (Product, Company, Legal)
|
||||
* - Social media links
|
||||
* - Copyright text with dynamic year
|
||||
* - Brand logo and name
|
||||
* - Link accessibility
|
||||
* - Internationalization (i18n)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import Footer from '../Footer';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'marketing.nav.features': 'Features',
|
||||
'marketing.nav.pricing': 'Pricing',
|
||||
'marketing.nav.getStarted': 'Get Started',
|
||||
'marketing.nav.about': 'About',
|
||||
'marketing.nav.contact': 'Contact',
|
||||
'marketing.footer.legal.privacy': 'Privacy Policy',
|
||||
'marketing.footer.legal.terms': 'Terms of Service',
|
||||
'marketing.footer.product.title': 'Product',
|
||||
'marketing.footer.company.title': 'Company',
|
||||
'marketing.footer.legal.title': 'Legal',
|
||||
'marketing.footer.brandName': 'Smooth Schedule',
|
||||
'marketing.description': 'The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.',
|
||||
'marketing.footer.copyright': 'Smooth Schedule Inc. All rights reserved.',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock SmoothScheduleLogo component
|
||||
vi.mock('../../SmoothScheduleLogo', () => ({
|
||||
default: ({ className }: { className?: string }) => (
|
||||
<svg data-testid="smooth-schedule-logo" className={className}>
|
||||
<path d="test" />
|
||||
</svg>
|
||||
),
|
||||
}));
|
||||
|
||||
// Test wrapper with Router
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Footer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the footer element', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const footer = screen.getByRole('contentinfo');
|
||||
expect(footer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all main sections', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Product')).toBeInTheDocument();
|
||||
expect(screen.getByText('Company')).toBeInTheDocument();
|
||||
expect(screen.getByText('Legal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply correct CSS classes for styling', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const footer = screen.getByRole('contentinfo');
|
||||
expect(footer).toHaveClass('bg-gray-50');
|
||||
expect(footer).toHaveClass('dark:bg-gray-900');
|
||||
expect(footer).toHaveClass('border-t');
|
||||
expect(footer).toHaveClass('border-gray-200');
|
||||
expect(footer).toHaveClass('dark:border-gray-800');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Brand Section', () => {
|
||||
it('should render the SmoothSchedule logo', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const logo = screen.getByTestId('smooth-schedule-logo');
|
||||
expect(logo).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render brand name with translation', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Smooth Schedule')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render brand description', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'The all-in-one scheduling platform for businesses of all sizes. Manage resources, staff, and bookings effortlessly.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should link logo to homepage', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const logoLink = screen.getByRole('link', { name: /smooth schedule/i });
|
||||
expect(logoLink).toHaveAttribute('href', '/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Product Links', () => {
|
||||
it('should render Product section title', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Product')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Features link', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const featuresLink = screen.getByRole('link', { name: 'Features' });
|
||||
expect(featuresLink).toBeInTheDocument();
|
||||
expect(featuresLink).toHaveAttribute('href', '/features');
|
||||
});
|
||||
|
||||
it('should render Pricing link', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const pricingLink = screen.getByRole('link', { name: 'Pricing' });
|
||||
expect(pricingLink).toBeInTheDocument();
|
||||
expect(pricingLink).toHaveAttribute('href', '/pricing');
|
||||
});
|
||||
|
||||
it('should render Get Started link', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const getStartedLink = screen.getByRole('link', { name: 'Get Started' });
|
||||
expect(getStartedLink).toBeInTheDocument();
|
||||
expect(getStartedLink).toHaveAttribute('href', '/signup');
|
||||
});
|
||||
|
||||
it('should apply correct styling to product links', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const featuresLink = screen.getByRole('link', { name: 'Features' });
|
||||
expect(featuresLink).toHaveClass('text-sm');
|
||||
expect(featuresLink).toHaveClass('text-gray-600');
|
||||
expect(featuresLink).toHaveClass('dark:text-gray-400');
|
||||
expect(featuresLink).toHaveClass('hover:text-brand-600');
|
||||
expect(featuresLink).toHaveClass('dark:hover:text-brand-400');
|
||||
expect(featuresLink).toHaveClass('transition-colors');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Company Links', () => {
|
||||
it('should render Company section title', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Company')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render About link', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const aboutLink = screen.getByRole('link', { name: 'About' });
|
||||
expect(aboutLink).toBeInTheDocument();
|
||||
expect(aboutLink).toHaveAttribute('href', '/about');
|
||||
});
|
||||
|
||||
it('should render Contact link', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const contactLink = screen.getByRole('link', { name: 'Contact' });
|
||||
expect(contactLink).toBeInTheDocument();
|
||||
expect(contactLink).toHaveAttribute('href', '/contact');
|
||||
});
|
||||
|
||||
it('should apply correct styling to company links', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const aboutLink = screen.getByRole('link', { name: 'About' });
|
||||
expect(aboutLink).toHaveClass('text-sm');
|
||||
expect(aboutLink).toHaveClass('text-gray-600');
|
||||
expect(aboutLink).toHaveClass('dark:text-gray-400');
|
||||
expect(aboutLink).toHaveClass('hover:text-brand-600');
|
||||
expect(aboutLink).toHaveClass('dark:hover:text-brand-400');
|
||||
expect(aboutLink).toHaveClass('transition-colors');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Legal Links', () => {
|
||||
it('should render Legal section title', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Legal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Privacy Policy link', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const privacyLink = screen.getByRole('link', { name: 'Privacy Policy' });
|
||||
expect(privacyLink).toBeInTheDocument();
|
||||
expect(privacyLink).toHaveAttribute('href', '/privacy');
|
||||
});
|
||||
|
||||
it('should render Terms of Service link', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const termsLink = screen.getByRole('link', { name: 'Terms of Service' });
|
||||
expect(termsLink).toBeInTheDocument();
|
||||
expect(termsLink).toHaveAttribute('href', '/terms');
|
||||
});
|
||||
|
||||
it('should apply correct styling to legal links', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const privacyLink = screen.getByRole('link', { name: 'Privacy Policy' });
|
||||
expect(privacyLink).toHaveClass('text-sm');
|
||||
expect(privacyLink).toHaveClass('text-gray-600');
|
||||
expect(privacyLink).toHaveClass('dark:text-gray-400');
|
||||
expect(privacyLink).toHaveClass('hover:text-brand-600');
|
||||
expect(privacyLink).toHaveClass('dark:hover:text-brand-400');
|
||||
expect(privacyLink).toHaveClass('transition-colors');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Social Media Links', () => {
|
||||
it('should render all social media links', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByLabelText('Twitter')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('LinkedIn')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('GitHub')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('YouTube')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Twitter link with correct href', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const twitterLink = screen.getByLabelText('Twitter');
|
||||
expect(twitterLink).toHaveAttribute('href', 'https://twitter.com/smoothschedule');
|
||||
expect(twitterLink).toHaveAttribute('target', '_blank');
|
||||
expect(twitterLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('should render LinkedIn link with correct href', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const linkedinLink = screen.getByLabelText('LinkedIn');
|
||||
expect(linkedinLink).toHaveAttribute('href', 'https://linkedin.com/company/smoothschedule');
|
||||
expect(linkedinLink).toHaveAttribute('target', '_blank');
|
||||
expect(linkedinLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('should render GitHub link with correct href', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const githubLink = screen.getByLabelText('GitHub');
|
||||
expect(githubLink).toHaveAttribute('href', 'https://github.com/smoothschedule');
|
||||
expect(githubLink).toHaveAttribute('target', '_blank');
|
||||
expect(githubLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('should render YouTube link with correct href', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const youtubeLink = screen.getByLabelText('YouTube');
|
||||
expect(youtubeLink).toHaveAttribute('href', 'https://youtube.com/@smoothschedule');
|
||||
expect(youtubeLink).toHaveAttribute('target', '_blank');
|
||||
expect(youtubeLink).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('should apply correct styling to social links', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const twitterLink = screen.getByLabelText('Twitter');
|
||||
expect(twitterLink).toHaveClass('p-2');
|
||||
expect(twitterLink).toHaveClass('rounded-lg');
|
||||
expect(twitterLink).toHaveClass('text-gray-500');
|
||||
expect(twitterLink).toHaveClass('hover:text-brand-600');
|
||||
expect(twitterLink).toHaveClass('dark:text-gray-400');
|
||||
expect(twitterLink).toHaveClass('dark:hover:text-brand-400');
|
||||
expect(twitterLink).toHaveClass('hover:bg-gray-100');
|
||||
expect(twitterLink).toHaveClass('dark:hover:bg-gray-800');
|
||||
expect(twitterLink).toHaveClass('transition-colors');
|
||||
});
|
||||
|
||||
it('should render social media icons as SVGs', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const twitterLink = screen.getByLabelText('Twitter');
|
||||
const icon = twitterLink.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
expect(icon).toHaveClass('h-5', 'w-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Copyright Section', () => {
|
||||
it('should render copyright text', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
expect(
|
||||
screen.getByText(/Smooth Schedule Inc. All rights reserved./i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display current year in copyright', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
expect(screen.getByText(new RegExp(currentYear.toString()))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply correct styling to copyright text', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const copyrightElement = screen.getByText(
|
||||
/Smooth Schedule Inc. All rights reserved./i
|
||||
);
|
||||
expect(copyrightElement).toHaveClass('text-sm');
|
||||
expect(copyrightElement).toHaveClass('text-center');
|
||||
expect(copyrightElement).toHaveClass('text-gray-500');
|
||||
expect(copyrightElement).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
|
||||
it('should have proper spacing from content', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const copyrightElement = screen.getByText(
|
||||
/Smooth Schedule Inc. All rights reserved./i
|
||||
);
|
||||
const parent = copyrightElement.parentElement;
|
||||
expect(parent).toHaveClass('mt-12');
|
||||
expect(parent).toHaveClass('pt-8');
|
||||
expect(parent).toHaveClass('border-t');
|
||||
expect(parent).toHaveClass('border-gray-200');
|
||||
expect(parent).toHaveClass('dark:border-gray-800');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section Titles', () => {
|
||||
it('should style section titles consistently', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const productTitle = screen.getByText('Product');
|
||||
expect(productTitle).toHaveClass('text-sm');
|
||||
expect(productTitle).toHaveClass('font-semibold');
|
||||
expect(productTitle).toHaveClass('text-gray-900');
|
||||
expect(productTitle).toHaveClass('dark:text-white');
|
||||
expect(productTitle).toHaveClass('uppercase');
|
||||
expect(productTitle).toHaveClass('tracking-wider');
|
||||
expect(productTitle).toHaveClass('mb-4');
|
||||
});
|
||||
|
||||
it('should render all section titles with h3 tags', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const titles = ['Product', 'Company', 'Legal'];
|
||||
titles.forEach((title) => {
|
||||
const element = screen.getByText(title);
|
||||
expect(element.tagName).toBe('H3');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should use semantic footer element', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const footer = screen.getByRole('contentinfo');
|
||||
expect(footer.tagName).toBe('FOOTER');
|
||||
});
|
||||
|
||||
it('should have aria-label on social links', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const socialLabels = ['Twitter', 'LinkedIn', 'GitHub', 'YouTube'];
|
||||
socialLabels.forEach((label) => {
|
||||
const link = screen.getByLabelText(label);
|
||||
expect(link).toHaveAttribute('aria-label', label);
|
||||
});
|
||||
});
|
||||
|
||||
it('should have proper heading hierarchy', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const headings = screen.getAllByRole('heading', { level: 3 });
|
||||
expect(headings).toHaveLength(3);
|
||||
expect(headings[0]).toHaveTextContent('Product');
|
||||
expect(headings[1]).toHaveTextContent('Company');
|
||||
expect(headings[2]).toHaveTextContent('Legal');
|
||||
});
|
||||
|
||||
it('should have list structure for links', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const lists = screen.getAllByRole('list');
|
||||
expect(lists.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should have keyboard-accessible links', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
links.forEach((link) => {
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link.tagName).toBe('A');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout and Structure', () => {
|
||||
it('should use grid layout for sections', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const footer = screen.getByRole('contentinfo');
|
||||
const gridContainer = footer.querySelector('.grid');
|
||||
expect(gridContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have responsive grid classes', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const footer = screen.getByRole('contentinfo');
|
||||
const gridContainer = footer.querySelector('.grid');
|
||||
expect(gridContainer).toHaveClass('grid-cols-2');
|
||||
expect(gridContainer).toHaveClass('md:grid-cols-4');
|
||||
expect(gridContainer).toHaveClass('gap-8');
|
||||
expect(gridContainer).toHaveClass('lg:gap-12');
|
||||
});
|
||||
|
||||
it('should have proper padding on container', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const footer = screen.getByRole('contentinfo');
|
||||
const container = footer.querySelector('.max-w-7xl');
|
||||
expect(container).toHaveClass('max-w-7xl');
|
||||
expect(container).toHaveClass('mx-auto');
|
||||
expect(container).toHaveClass('px-4');
|
||||
expect(container).toHaveClass('sm:px-6');
|
||||
expect(container).toHaveClass('lg:px-8');
|
||||
expect(container).toHaveClass('py-12');
|
||||
expect(container).toHaveClass('lg:py-16');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Internationalization', () => {
|
||||
it('should use translations for all text content', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
// Product links
|
||||
expect(screen.getByText('Features')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pricing')).toBeInTheDocument();
|
||||
expect(screen.getByText('Get Started')).toBeInTheDocument();
|
||||
|
||||
// Company links
|
||||
expect(screen.getByText('About')).toBeInTheDocument();
|
||||
expect(screen.getByText('Contact')).toBeInTheDocument();
|
||||
|
||||
// Legal links
|
||||
expect(screen.getByText('Privacy Policy')).toBeInTheDocument();
|
||||
expect(screen.getByText('Terms of Service')).toBeInTheDocument();
|
||||
|
||||
// Section titles
|
||||
expect(screen.getByText('Product')).toBeInTheDocument();
|
||||
expect(screen.getByText('Company')).toBeInTheDocument();
|
||||
expect(screen.getByText('Legal')).toBeInTheDocument();
|
||||
|
||||
// Brand and copyright
|
||||
expect(screen.getByText('Smooth Schedule')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Smooth Schedule Inc\. All rights reserved\./i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render complete footer with all sections', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
// Brand section
|
||||
expect(screen.getByTestId('smooth-schedule-logo')).toBeInTheDocument();
|
||||
expect(screen.getByText('Smooth Schedule')).toBeInTheDocument();
|
||||
|
||||
// Navigation sections
|
||||
expect(screen.getByText('Product')).toBeInTheDocument();
|
||||
expect(screen.getByText('Company')).toBeInTheDocument();
|
||||
expect(screen.getByText('Legal')).toBeInTheDocument();
|
||||
|
||||
// Social links
|
||||
expect(screen.getByLabelText('Twitter')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('LinkedIn')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('GitHub')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('YouTube')).toBeInTheDocument();
|
||||
|
||||
// Copyright
|
||||
const currentYear = new Date().getFullYear();
|
||||
expect(screen.getByText(new RegExp(currentYear.toString()))).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Smooth Schedule Inc\. All rights reserved\./i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have correct number of navigation links', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
const allLinks = screen.getAllByRole('link');
|
||||
// 1 logo link + 3 product + 2 company + 2 legal + 4 social = 12 total
|
||||
expect(allLinks).toHaveLength(12);
|
||||
});
|
||||
|
||||
it('should maintain proper visual hierarchy', () => {
|
||||
render(<Footer />, { wrapper: createWrapper() });
|
||||
|
||||
// Check that sections are in correct order
|
||||
const footer = screen.getByRole('contentinfo');
|
||||
const text = footer.textContent || '';
|
||||
|
||||
// Brand should come before sections
|
||||
const brandIndex = text.indexOf('Smooth Schedule');
|
||||
const productIndex = text.indexOf('Product');
|
||||
const companyIndex = text.indexOf('Company');
|
||||
const legalIndex = text.indexOf('Legal');
|
||||
|
||||
expect(brandIndex).toBeLessThan(productIndex);
|
||||
expect(productIndex).toBeLessThan(companyIndex);
|
||||
expect(companyIndex).toBeLessThan(legalIndex);
|
||||
});
|
||||
});
|
||||
});
|
||||
625
frontend/src/components/marketing/__tests__/Hero.test.tsx
Normal file
625
frontend/src/components/marketing/__tests__/Hero.test.tsx
Normal file
@@ -0,0 +1,625 @@
|
||||
/**
|
||||
* Unit tests for Hero component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering with all elements
|
||||
* - Headline and title rendering
|
||||
* - Subheadline/description rendering
|
||||
* - CTA buttons presence and functionality
|
||||
* - Visual content and graphics rendering
|
||||
* - Feature badges display
|
||||
* - Responsive design elements
|
||||
* - Accessibility attributes
|
||||
* - Internationalization (i18n)
|
||||
* - Background decorative elements
|
||||
* - Statistics and metrics display
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import Hero from '../Hero';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
// Return mock translations based on key
|
||||
const translations: Record<string, string> = {
|
||||
'marketing.hero.badge': 'New: Automation Marketplace',
|
||||
'marketing.hero.title': 'The Operating System for',
|
||||
'marketing.hero.titleHighlight': 'Service Businesses',
|
||||
'marketing.hero.description': 'Orchestrate your entire operation with intelligent scheduling and powerful automation. No coding required.',
|
||||
'marketing.hero.startFreeTrial': 'Start Free Trial',
|
||||
'marketing.hero.watchDemo': 'Watch Demo',
|
||||
'marketing.hero.noCreditCard': 'No credit card required',
|
||||
'marketing.hero.freeTrial': '14-day free trial',
|
||||
'marketing.hero.cancelAnytime': 'Cancel anytime',
|
||||
'marketing.hero.visualContent.automatedSuccess': 'Automated Success',
|
||||
'marketing.hero.visualContent.autopilot': 'Your business, running on autopilot.',
|
||||
'marketing.hero.visualContent.revenue': 'Revenue',
|
||||
'marketing.hero.visualContent.noShows': 'No-Shows',
|
||||
'marketing.hero.visualContent.revenueOptimized': 'Revenue Optimized',
|
||||
'marketing.hero.visualContent.thisWeek': '+$2,400 this week',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test wrapper with Router
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Hero', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Component Rendering', () => {
|
||||
it('should render the hero section', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const heroSection = screen.getByText(/The Operating System for/i).closest('div');
|
||||
expect(heroSection).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render without crashing', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have proper semantic structure', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
// Should have h1 for main heading
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Headline and Title Rendering', () => {
|
||||
it('should render main headline', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const headline = screen.getByText(/The Operating System for/i);
|
||||
expect(headline).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render highlighted title text', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const highlightedTitle = screen.getByText(/Service Businesses/i);
|
||||
expect(highlightedTitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render headline as h1 element', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toHaveTextContent(/The Operating System for/i);
|
||||
expect(heading).toHaveTextContent(/Service Businesses/i);
|
||||
});
|
||||
|
||||
it('should apply proper styling to headline', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toHaveClass('font-bold');
|
||||
expect(heading).toHaveClass('tracking-tight');
|
||||
});
|
||||
|
||||
it('should highlight title portion with brand color', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const highlightedTitle = screen.getByText(/Service Businesses/i);
|
||||
expect(highlightedTitle).toHaveClass('text-brand-600');
|
||||
expect(highlightedTitle).toHaveClass('dark:text-brand-400');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Subheadline/Description Rendering', () => {
|
||||
it('should render description text', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const description = screen.getByText(/Orchestrate your entire operation/i);
|
||||
expect(description).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render complete description', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const description = screen.getByText(/intelligent scheduling and powerful automation/i);
|
||||
expect(description).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply proper styling to description', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const description = screen.getByText(/Orchestrate your entire operation/i);
|
||||
expect(description.tagName).toBe('P');
|
||||
expect(description).toHaveClass('text-lg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Badge Display', () => {
|
||||
it('should render new feature badge', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const badge = screen.getByText(/New: Automation Marketplace/i);
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should include animated pulse indicator', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const pulseElement = container.querySelector('.animate-pulse');
|
||||
expect(pulseElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply badge styling', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const badge = screen.getByText(/New: Automation Marketplace/i);
|
||||
expect(badge).toHaveClass('text-sm');
|
||||
expect(badge).toHaveClass('font-medium');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CTA Buttons', () => {
|
||||
it('should render Start Free Trial button', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const ctaButton = screen.getByRole('link', { name: /Start Free Trial/i });
|
||||
expect(ctaButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Watch Demo button', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const demoButton = screen.getByRole('link', { name: /Watch Demo/i });
|
||||
expect(demoButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have correct href for Start Free Trial button', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const ctaButton = screen.getByRole('link', { name: /Start Free Trial/i });
|
||||
expect(ctaButton).toHaveAttribute('href', '/signup');
|
||||
});
|
||||
|
||||
it('should have correct href for Watch Demo button', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const demoButton = screen.getByRole('link', { name: /Watch Demo/i });
|
||||
expect(demoButton).toHaveAttribute('href', '/features');
|
||||
});
|
||||
|
||||
it('should render primary CTA with brand colors', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const ctaButton = screen.getByRole('link', { name: /Start Free Trial/i });
|
||||
expect(ctaButton).toHaveClass('bg-brand-600');
|
||||
expect(ctaButton).toHaveClass('hover:bg-brand-700');
|
||||
expect(ctaButton).toHaveClass('text-white');
|
||||
});
|
||||
|
||||
it('should render secondary CTA with outline style', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const demoButton = screen.getByRole('link', { name: /Watch Demo/i });
|
||||
expect(demoButton).toHaveClass('border');
|
||||
expect(demoButton).toHaveClass('border-gray-200');
|
||||
});
|
||||
|
||||
it('should include ArrowRight icon in primary CTA', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const ctaButton = screen.getByRole('link', { name: /Start Free Trial/i });
|
||||
const icon = ctaButton.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should include Play icon in secondary CTA', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const demoButton = screen.getByRole('link', { name: /Watch Demo/i });
|
||||
const icon = demoButton.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should be clickable (keyboard accessible)', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const ctaButton = screen.getByRole('link', { name: /Start Free Trial/i });
|
||||
|
||||
// Should be focusable
|
||||
await user.tab();
|
||||
// Check if any link is focused (may not be the first due to badge)
|
||||
expect(document.activeElement).toBeInstanceOf(HTMLElement);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature Checkmarks', () => {
|
||||
it('should display no credit card feature', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const feature = screen.getByText(/No credit card required/i);
|
||||
expect(feature).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display free trial feature', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const feature = screen.getByText(/14-day free trial/i);
|
||||
expect(feature).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display cancel anytime feature', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const feature = screen.getByText(/Cancel anytime/i);
|
||||
expect(feature).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render CheckCircle2 icons for features', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
// Should have multiple check circle icons
|
||||
const checkIcons = container.querySelectorAll('svg');
|
||||
expect(checkIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Visual Content and Graphics', () => {
|
||||
it('should render visual content section', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const visualHeading = screen.getByText(/Automated Success/i);
|
||||
expect(visualHeading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render visual content description', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const description = screen.getByText(/Your business, running on autopilot/i);
|
||||
expect(description).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render revenue metric', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const revenueMetric = screen.getByText(/\+24%/i);
|
||||
expect(revenueMetric).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render no-shows metric', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const noShowsMetric = screen.getByText(/-40%/i);
|
||||
expect(noShowsMetric).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render revenue label', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const label = screen.getByText(/^Revenue$/i);
|
||||
expect(label).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render no-shows label', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const label = screen.getByText(/^No-Shows$/i);
|
||||
expect(label).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have gradient background on visual content', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const gradientElement = container.querySelector('.bg-gradient-to-br');
|
||||
expect(gradientElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render visual content as h3', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 3, name: /Automated Success/i });
|
||||
expect(heading).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Floating Badge', () => {
|
||||
it('should render floating revenue badge', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const badge = screen.getByText(/Revenue Optimized/i);
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render weekly revenue amount', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const amount = screen.getByText(/\+\$2,400 this week/i);
|
||||
expect(amount).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have bounce animation', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
// Find element with animate-bounce-slow (custom animation class)
|
||||
const badge = container.querySelector('.animate-bounce-slow');
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should include CheckCircle2 icon in badge', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
// The badge has an SVG icon, check for its presence in the floating badge area
|
||||
const badge = screen.getByText(/Revenue Optimized/i).parentElement?.parentElement;
|
||||
const icon = badge?.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Design', () => {
|
||||
it('should use grid layout for content', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const gridElement = container.querySelector('.grid');
|
||||
expect(gridElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have responsive grid columns', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const gridElement = container.querySelector('.lg\\:grid-cols-2');
|
||||
expect(gridElement).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have responsive text alignment', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
// Text should be centered on mobile, left-aligned on larger screens
|
||||
const textContainer = container.querySelector('.text-center.lg\\:text-left');
|
||||
expect(textContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have responsive heading sizes', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toHaveClass('text-4xl');
|
||||
expect(heading).toHaveClass('sm:text-5xl');
|
||||
expect(heading).toHaveClass('lg:text-6xl');
|
||||
});
|
||||
|
||||
it('should have responsive button layout', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const buttonContainer = container.querySelector('.flex-col.sm\\:flex-row');
|
||||
expect(buttonContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Background Elements', () => {
|
||||
it('should render decorative background elements', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
// Should have blur effects
|
||||
const blurElements = container.querySelectorAll('.blur-3xl');
|
||||
expect(blurElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have brand-colored background element', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const brandBg = container.querySelector('.bg-brand-500\\/10');
|
||||
expect(brandBg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have purple background element', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const purpleBg = container.querySelector('.bg-purple-500\\/10');
|
||||
expect(purpleBg).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible heading hierarchy', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const h1 = screen.getByRole('heading', { level: 1 });
|
||||
const h3 = screen.getByRole('heading', { level: 3 });
|
||||
|
||||
expect(h1).toBeInTheDocument();
|
||||
expect(h3).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have accessible link text', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const primaryCTA = screen.getByRole('link', { name: /Start Free Trial/i });
|
||||
const secondaryCTA = screen.getByRole('link', { name: /Watch Demo/i });
|
||||
|
||||
expect(primaryCTA).toHaveAccessibleName();
|
||||
expect(secondaryCTA).toHaveAccessibleName();
|
||||
});
|
||||
|
||||
it('should not use ambiguous link text', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
// Should not have links with text like "Click here" or "Read more"
|
||||
const links = screen.getAllByRole('link');
|
||||
links.forEach(link => {
|
||||
expect(link.textContent).not.toMatch(/^click here$/i);
|
||||
expect(link.textContent).not.toMatch(/^read more$/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Internationalization', () => {
|
||||
it('should use translations for badge text', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const badge = screen.getByText(/New: Automation Marketplace/i);
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for main title', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/The Operating System for/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Service Businesses/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for description', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const description = screen.getByText(/Orchestrate your entire operation/i);
|
||||
expect(description).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for CTA buttons', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByRole('link', { name: /Start Free Trial/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Watch Demo/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for features', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/No credit card required/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/14-day free trial/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Cancel anytime/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for visual content', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/Automated Success/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Your business, running on autopilot/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Revenue Optimized/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Support', () => {
|
||||
it('should have dark mode classes for main container', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const mainContainer = container.querySelector('.dark\\:bg-gray-900');
|
||||
expect(mainContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have dark mode classes for text elements', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toHaveClass('dark:text-white');
|
||||
});
|
||||
|
||||
it('should have dark mode classes for description', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const description = screen.getByText(/Orchestrate your entire operation/i);
|
||||
expect(description).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Layout and Spacing', () => {
|
||||
it('should have proper padding on container', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const mainSection = container.querySelector('.pt-16');
|
||||
expect(mainSection).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have responsive padding', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const section = container.querySelector('.lg\\:pt-24');
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have proper margins between elements', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toHaveClass('mb-6');
|
||||
});
|
||||
|
||||
it('should constrain max width', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const constrainedContainer = container.querySelector('.max-w-7xl');
|
||||
expect(constrainedContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration Tests', () => {
|
||||
it('should render all major sections together', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
// Text content
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Orchestrate your entire operation/i)).toBeInTheDocument();
|
||||
|
||||
// CTAs
|
||||
expect(screen.getByRole('link', { name: /Start Free Trial/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: /Watch Demo/i })).toBeInTheDocument();
|
||||
|
||||
// Features
|
||||
expect(screen.getByText(/No credit card required/i)).toBeInTheDocument();
|
||||
|
||||
// Visual content
|
||||
expect(screen.getByText(/Automated Success/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Revenue Optimized/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain proper component structure', () => {
|
||||
const { container } = render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
// Grid layout
|
||||
const grid = container.querySelector('.grid');
|
||||
expect(grid).toBeInTheDocument();
|
||||
|
||||
// Background elements
|
||||
const backgrounds = container.querySelectorAll('.blur-3xl');
|
||||
expect(backgrounds.length).toBeGreaterThan(0);
|
||||
|
||||
// Visual content area
|
||||
const visualContent = screen.getByText(/Automated Success/i).closest('div');
|
||||
expect(visualContent).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have complete feature set displayed', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
const features = [
|
||||
/No credit card required/i,
|
||||
/14-day free trial/i,
|
||||
/Cancel anytime/i,
|
||||
];
|
||||
|
||||
features.forEach(feature => {
|
||||
expect(screen.getByText(feature)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have complete metrics displayed', () => {
|
||||
render(<Hero />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/\+24%/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/-40%/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/\+\$2,400 this week/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
439
frontend/src/components/marketing/__tests__/HowItWorks.test.tsx
Normal file
439
frontend/src/components/marketing/__tests__/HowItWorks.test.tsx
Normal file
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* Unit tests for HowItWorks component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Section title and subtitle rendering
|
||||
* - All three steps are displayed
|
||||
* - Step numbers (01, 02, 03) are present
|
||||
* - Icons from lucide-react render correctly
|
||||
* - Step titles and descriptions render
|
||||
* - Connector lines between steps (desktop only)
|
||||
* - Color theming for each step
|
||||
* - Responsive grid layout
|
||||
* - Accessibility
|
||||
* - Internationalization (i18n)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import HowItWorks from '../HowItWorks';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'marketing.howItWorks.title': 'Get Started in Minutes',
|
||||
'marketing.howItWorks.subtitle': 'Three simple steps to transform your scheduling',
|
||||
'marketing.howItWorks.step1.title': 'Create Your Account',
|
||||
'marketing.howItWorks.step1.description': 'Sign up for free and set up your business profile in minutes.',
|
||||
'marketing.howItWorks.step2.title': 'Add Your Services',
|
||||
'marketing.howItWorks.step2.description': 'Configure your services, pricing, and available resources.',
|
||||
'marketing.howItWorks.step3.title': 'Start Booking',
|
||||
'marketing.howItWorks.step3.description': 'Share your booking link and let customers schedule instantly.',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('HowItWorks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Section Header', () => {
|
||||
it('should render the section title', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const title = screen.getByRole('heading', {
|
||||
name: 'Get Started in Minutes',
|
||||
level: 2,
|
||||
});
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the section subtitle', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const subtitle = screen.getByText('Three simple steps to transform your scheduling');
|
||||
expect(subtitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply correct styling to section title', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const title = screen.getByRole('heading', { level: 2 });
|
||||
expect(title).toHaveClass('text-3xl');
|
||||
expect(title).toHaveClass('sm:text-4xl');
|
||||
expect(title).toHaveClass('font-bold');
|
||||
expect(title).toHaveClass('text-gray-900');
|
||||
expect(title).toHaveClass('dark:text-white');
|
||||
});
|
||||
|
||||
it('should apply correct styling to subtitle', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const subtitle = screen.getByText('Three simple steps to transform your scheduling');
|
||||
expect(subtitle).toHaveClass('text-lg');
|
||||
expect(subtitle).toHaveClass('text-gray-600');
|
||||
expect(subtitle).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Steps Display', () => {
|
||||
it('should render all three steps', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const step1 = screen.getByText('Create Your Account');
|
||||
const step2 = screen.getByText('Add Your Services');
|
||||
const step3 = screen.getByText('Start Booking');
|
||||
|
||||
expect(step1).toBeInTheDocument();
|
||||
expect(step2).toBeInTheDocument();
|
||||
expect(step3).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render step descriptions', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const desc1 = screen.getByText('Sign up for free and set up your business profile in minutes.');
|
||||
const desc2 = screen.getByText('Configure your services, pricing, and available resources.');
|
||||
const desc3 = screen.getByText('Share your booking link and let customers schedule instantly.');
|
||||
|
||||
expect(desc1).toBeInTheDocument();
|
||||
expect(desc2).toBeInTheDocument();
|
||||
expect(desc3).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use heading level 3 for step titles', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const stepHeadings = screen.getAllByRole('heading', { level: 3 });
|
||||
expect(stepHeadings).toHaveLength(3);
|
||||
expect(stepHeadings[0]).toHaveTextContent('Create Your Account');
|
||||
expect(stepHeadings[1]).toHaveTextContent('Add Your Services');
|
||||
expect(stepHeadings[2]).toHaveTextContent('Start Booking');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Step Numbers', () => {
|
||||
it('should display step number 01', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const stepNumber = screen.getByText('01');
|
||||
expect(stepNumber).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display step number 02', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const stepNumber = screen.getByText('02');
|
||||
expect(stepNumber).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display step number 03', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const stepNumber = screen.getByText('03');
|
||||
expect(stepNumber).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply correct styling to step numbers', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const stepNumber = screen.getByText('01');
|
||||
expect(stepNumber).toHaveClass('text-sm');
|
||||
expect(stepNumber).toHaveClass('font-bold');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Icons', () => {
|
||||
it('should render SVG icons for all steps', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
// Each step should have an icon (lucide-react renders as SVG)
|
||||
const icons = container.querySelectorAll('svg');
|
||||
expect(icons.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should render icons with correct size classes', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
const icons = container.querySelectorAll('svg');
|
||||
icons.forEach((icon) => {
|
||||
expect(icon).toHaveClass('h-8');
|
||||
expect(icon).toHaveClass('w-8');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Grid Layout', () => {
|
||||
it('should render steps in a grid container', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
const grid = container.querySelector('.grid');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should apply responsive grid classes', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
const grid = container.querySelector('.grid');
|
||||
expect(grid).toHaveClass('md:grid-cols-3');
|
||||
expect(grid).toHaveClass('gap-8');
|
||||
expect(grid).toHaveClass('lg:gap-12');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Card Styling', () => {
|
||||
it('should render each step in a card', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
const cards = container.querySelectorAll('.bg-white');
|
||||
expect(cards.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('should apply card border and rounded corners', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
const cards = container.querySelectorAll('.rounded-2xl');
|
||||
expect(cards.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Color Themes', () => {
|
||||
it('should apply brand color theme to step 1', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
// Check for brand color classes
|
||||
const brandElements = container.querySelectorAll('.text-brand-600, .bg-brand-100');
|
||||
expect(brandElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should apply purple color theme to step 2', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
// Check for purple color classes
|
||||
const purpleElements = container.querySelectorAll('.text-purple-600, .bg-purple-100');
|
||||
expect(purpleElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should apply green color theme to step 3', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
// Check for green color classes
|
||||
const greenElements = container.querySelectorAll('.text-green-600, .bg-green-100');
|
||||
expect(greenElements.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connector Lines', () => {
|
||||
it('should render connector lines between steps', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
// Connector lines have absolute positioning and gradient
|
||||
const connectors = container.querySelectorAll('.bg-gradient-to-r');
|
||||
expect(connectors.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it('should hide connector lines on mobile', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
const connectors = container.querySelectorAll('.hidden.md\\:block');
|
||||
// Should have 2 connector lines (between step 1-2 and 2-3)
|
||||
expect(connectors.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Section Styling', () => {
|
||||
it('should apply section background color', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toHaveClass('bg-gray-50');
|
||||
expect(section).toHaveClass('dark:bg-gray-800/50');
|
||||
});
|
||||
|
||||
it('should apply section padding', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toHaveClass('py-20');
|
||||
expect(section).toHaveClass('lg:py-28');
|
||||
});
|
||||
|
||||
it('should use max-width container', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
const maxWidthContainer = container.querySelector('.max-w-7xl');
|
||||
expect(maxWidthContainer).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should use semantic section element', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have proper heading hierarchy', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
// h2 for main title
|
||||
const h2 = screen.getByRole('heading', { level: 2 });
|
||||
expect(h2).toBeInTheDocument();
|
||||
|
||||
// h3 for step titles
|
||||
const h3Elements = screen.getAllByRole('heading', { level: 3 });
|
||||
expect(h3Elements).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should have readable text content', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const title = screen.getByText('Get Started in Minutes');
|
||||
const subtitle = screen.getByText('Three simple steps to transform your scheduling');
|
||||
|
||||
expect(title).toBeVisible();
|
||||
expect(subtitle).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Internationalization', () => {
|
||||
it('should use translation for section title', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const title = screen.getByText('Get Started in Minutes');
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translation for section subtitle', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const subtitle = screen.getByText('Three simple steps to transform your scheduling');
|
||||
expect(subtitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for all step titles', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
expect(screen.getByText('Create Your Account')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add Your Services')).toBeInTheDocument();
|
||||
expect(screen.getByText('Start Booking')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for all step descriptions', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
expect(screen.getByText('Sign up for free and set up your business profile in minutes.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Configure your services, pricing, and available resources.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Share your booking link and let customers schedule instantly.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Design', () => {
|
||||
it('should apply responsive text sizing to title', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const title = screen.getByRole('heading', { level: 2 });
|
||||
expect(title).toHaveClass('text-3xl');
|
||||
expect(title).toHaveClass('sm:text-4xl');
|
||||
});
|
||||
|
||||
it('should apply responsive padding to section', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toHaveClass('py-20');
|
||||
expect(section).toHaveClass('lg:py-28');
|
||||
});
|
||||
|
||||
it('should apply responsive padding to container', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
const containerDiv = container.querySelector('.max-w-7xl');
|
||||
expect(containerDiv).toHaveClass('px-4');
|
||||
expect(containerDiv).toHaveClass('sm:px-6');
|
||||
expect(containerDiv).toHaveClass('lg:px-8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Support', () => {
|
||||
it('should include dark mode classes for title', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const title = screen.getByRole('heading', { level: 2 });
|
||||
expect(title).toHaveClass('dark:text-white');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for subtitle', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
const subtitle = screen.getByText('Three simple steps to transform your scheduling');
|
||||
expect(subtitle).toHaveClass('dark:text-gray-400');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for section background', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toHaveClass('dark:bg-gray-800/50');
|
||||
});
|
||||
|
||||
it('should include dark mode classes for cards', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
const cards = container.querySelectorAll('.dark\\:bg-gray-800');
|
||||
expect(cards.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render complete component with all elements', () => {
|
||||
render(<HowItWorks />);
|
||||
|
||||
// Header
|
||||
expect(screen.getByRole('heading', { level: 2, name: 'Get Started in Minutes' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Three simple steps to transform your scheduling')).toBeInTheDocument();
|
||||
|
||||
// All steps
|
||||
expect(screen.getByText('01')).toBeInTheDocument();
|
||||
expect(screen.getByText('02')).toBeInTheDocument();
|
||||
expect(screen.getByText('03')).toBeInTheDocument();
|
||||
|
||||
// All titles
|
||||
expect(screen.getByText('Create Your Account')).toBeInTheDocument();
|
||||
expect(screen.getByText('Add Your Services')).toBeInTheDocument();
|
||||
expect(screen.getByText('Start Booking')).toBeInTheDocument();
|
||||
|
||||
// All descriptions
|
||||
expect(screen.getByText('Sign up for free and set up your business profile in minutes.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Configure your services, pricing, and available resources.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Share your booking link and let customers schedule instantly.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain proper structure and layout', () => {
|
||||
const { container } = render(<HowItWorks />);
|
||||
|
||||
// Section element
|
||||
const section = container.querySelector('section');
|
||||
expect(section).toBeInTheDocument();
|
||||
|
||||
// Container
|
||||
const maxWidthContainer = section?.querySelector('.max-w-7xl');
|
||||
expect(maxWidthContainer).toBeInTheDocument();
|
||||
|
||||
// Grid
|
||||
const grid = maxWidthContainer?.querySelector('.grid');
|
||||
expect(grid).toBeInTheDocument();
|
||||
|
||||
// Cards
|
||||
const cards = grid?.querySelectorAll('.bg-white');
|
||||
expect(cards?.length).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
739
frontend/src/components/marketing/__tests__/Navbar.test.tsx
Normal file
739
frontend/src/components/marketing/__tests__/Navbar.test.tsx
Normal file
@@ -0,0 +1,739 @@
|
||||
/**
|
||||
* Unit tests for Navbar component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Logo and brand rendering
|
||||
* - Navigation links presence
|
||||
* - Login/signup buttons
|
||||
* - Mobile menu toggle functionality
|
||||
* - Scroll behavior (background change on scroll)
|
||||
* - Theme toggle functionality
|
||||
* - User authentication states
|
||||
* - Dashboard URL generation based on user role
|
||||
* - Route change effects on mobile menu
|
||||
* - Accessibility attributes
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { BrowserRouter, MemoryRouter } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import Navbar from '../Navbar';
|
||||
import { User } from '../../../api/auth';
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
'marketing.nav.features': 'Features',
|
||||
'marketing.nav.pricing': 'Pricing',
|
||||
'marketing.nav.about': 'About',
|
||||
'marketing.nav.contact': 'Contact',
|
||||
'marketing.nav.login': 'Login',
|
||||
'marketing.nav.getStarted': 'Get Started',
|
||||
'marketing.nav.brandName': 'Smooth Schedule',
|
||||
'marketing.nav.switchToLightMode': 'Switch to light mode',
|
||||
'marketing.nav.switchToDarkMode': 'Switch to dark mode',
|
||||
'marketing.nav.toggleMenu': 'Toggle menu',
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock SmoothScheduleLogo
|
||||
vi.mock('../../SmoothScheduleLogo', () => ({
|
||||
default: ({ className }: { className?: string }) => (
|
||||
<div data-testid="smooth-schedule-logo" className={className}>Logo</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock LanguageSelector
|
||||
vi.mock('../../LanguageSelector', () => ({
|
||||
default: () => <div data-testid="language-selector">Language</div>,
|
||||
}));
|
||||
|
||||
// Mock domain utilities
|
||||
vi.mock('../../../utils/domain', () => ({
|
||||
buildSubdomainUrl: (subdomain: string | null, path: string = '/') => {
|
||||
if (subdomain) {
|
||||
return `http://${subdomain}.lvh.me:5173${path}`;
|
||||
}
|
||||
return `http://lvh.me:5173${path}`;
|
||||
},
|
||||
}));
|
||||
|
||||
// Test wrapper with Router
|
||||
const createWrapper = (initialRoute: string = '/') => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<MemoryRouter initialEntries={[initialRoute]}>{children}</MemoryRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('Navbar', () => {
|
||||
const mockToggleTheme = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset window.scrollY before each test
|
||||
Object.defineProperty(window, 'scrollY', {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: 0,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Logo and Brand Rendering', () => {
|
||||
it('should render the logo', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const logo = screen.getByTestId('smooth-schedule-logo');
|
||||
expect(logo).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the brand name', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const brandName = screen.getByText('Smooth Schedule');
|
||||
expect(brandName).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have logo link pointing to home', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const logoLink = screen.getByRole('link', { name: /smooth schedule/i });
|
||||
expect(logoLink).toHaveAttribute('href', '/');
|
||||
});
|
||||
|
||||
it('should apply correct classes to logo link', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const logoLink = screen.getByRole('link', { name: /smooth schedule/i });
|
||||
expect(logoLink).toHaveClass('flex', 'items-center', 'gap-2', 'group');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation Links', () => {
|
||||
it('should render all navigation links on desktop', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getAllByText('Features')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Pricing')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('About')[0]).toBeInTheDocument();
|
||||
expect(screen.getAllByText('Contact')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have correct href attributes for navigation links', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const featuresLinks = screen.getAllByRole('link', { name: 'Features' });
|
||||
expect(featuresLinks[0]).toHaveAttribute('href', '/features');
|
||||
|
||||
const pricingLinks = screen.getAllByRole('link', { name: 'Pricing' });
|
||||
expect(pricingLinks[0]).toHaveAttribute('href', '/pricing');
|
||||
|
||||
const aboutLinks = screen.getAllByRole('link', { name: 'About' });
|
||||
expect(aboutLinks[0]).toHaveAttribute('href', '/about');
|
||||
|
||||
const contactLinks = screen.getAllByRole('link', { name: 'Contact' });
|
||||
expect(contactLinks[0]).toHaveAttribute('href', '/contact');
|
||||
});
|
||||
|
||||
it('should highlight active navigation link', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper('/features'),
|
||||
});
|
||||
|
||||
const featuresLinks = screen.getAllByRole('link', { name: 'Features' });
|
||||
const activeLink = featuresLinks[0];
|
||||
|
||||
expect(activeLink).toHaveClass('text-brand-600');
|
||||
});
|
||||
|
||||
it('should not highlight inactive navigation links', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper('/features'),
|
||||
});
|
||||
|
||||
const pricingLinks = screen.getAllByRole('link', { name: 'Pricing' });
|
||||
const inactiveLink = pricingLinks[0];
|
||||
|
||||
expect(inactiveLink).toHaveClass('text-gray-600');
|
||||
expect(inactiveLink).not.toHaveClass('text-brand-600');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Login and Signup Buttons', () => {
|
||||
it('should render login button when no user is provided', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const loginButtons = screen.getAllByText('Login');
|
||||
expect(loginButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should render login link with correct href when no user', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const loginLinks = screen.getAllByRole('link', { name: 'Login' });
|
||||
expect(loginLinks[0]).toHaveAttribute('href', '/login');
|
||||
});
|
||||
|
||||
it('should render signup button', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const signupButtons = screen.getAllByText('Get Started');
|
||||
expect(signupButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should render signup link with correct href', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const signupLinks = screen.getAllByRole('link', { name: 'Get Started' });
|
||||
expect(signupLinks[0]).toHaveAttribute('href', '/signup');
|
||||
});
|
||||
|
||||
it('should render dashboard link when user is authenticated', () => {
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'test@example.com',
|
||||
username: 'testuser',
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
role: 'owner',
|
||||
business_subdomain: 'testbusiness',
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} user={mockUser} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const loginLinks = screen.getAllByText('Login');
|
||||
// Should still show "Login" text but as anchor tag to dashboard
|
||||
expect(loginLinks.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should generate correct dashboard URL for platform users', () => {
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'admin@example.com',
|
||||
username: 'admin',
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
role: 'superuser',
|
||||
business_subdomain: null,
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} user={mockUser} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const loginLinks = screen.getAllByText('Login');
|
||||
const dashboardLink = loginLinks[0].closest('a');
|
||||
expect(dashboardLink).toHaveAttribute('href', 'http://platform.lvh.me:5173/');
|
||||
});
|
||||
|
||||
it('should generate correct dashboard URL for business users', () => {
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'owner@example.com',
|
||||
username: 'owner',
|
||||
first_name: 'Owner',
|
||||
last_name: 'User',
|
||||
role: 'owner',
|
||||
business_subdomain: 'mybusiness',
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} user={mockUser} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const loginLinks = screen.getAllByText('Login');
|
||||
const dashboardLink = loginLinks[0].closest('a');
|
||||
expect(dashboardLink).toHaveAttribute('href', 'http://mybusiness.lvh.me:5173/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Theme Toggle', () => {
|
||||
it('should render theme toggle button', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const themeButton = screen.getByLabelText('Switch to dark mode');
|
||||
expect(themeButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call toggleTheme when theme button is clicked', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const themeButton = screen.getByLabelText('Switch to dark mode');
|
||||
fireEvent.click(themeButton);
|
||||
|
||||
expect(mockToggleTheme).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should show moon icon in light mode', () => {
|
||||
const { container } = render(
|
||||
<Navbar darkMode={false} toggleTheme={mockToggleTheme} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const themeButton = screen.getByLabelText('Switch to dark mode');
|
||||
const svg = themeButton.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show sun icon in dark mode', () => {
|
||||
const { container } = render(
|
||||
<Navbar darkMode={true} toggleTheme={mockToggleTheme} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const themeButton = screen.getByLabelText('Switch to light mode');
|
||||
const svg = themeButton.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have correct aria-label in light mode', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const themeButton = screen.getByLabelText('Switch to dark mode');
|
||||
expect(themeButton).toHaveAttribute('aria-label', 'Switch to dark mode');
|
||||
});
|
||||
|
||||
it('should have correct aria-label in dark mode', () => {
|
||||
render(<Navbar darkMode={true} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const themeButton = screen.getByLabelText('Switch to light mode');
|
||||
expect(themeButton).toHaveAttribute('aria-label', 'Switch to light mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Mobile Menu Toggle', () => {
|
||||
it('should render mobile menu button', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const menuButton = screen.getByLabelText('Toggle menu');
|
||||
expect(menuButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show mobile menu when menu button is clicked', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const menuButton = screen.getByLabelText('Toggle menu');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
// Mobile menu should be visible (max-h-96 instead of max-h-0)
|
||||
const mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
|
||||
expect(mobileMenuContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should toggle mobile menu on multiple clicks', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const menuButton = screen.getByLabelText('Toggle menu');
|
||||
|
||||
// First click - open
|
||||
fireEvent.click(menuButton);
|
||||
let mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
|
||||
expect(mobileMenuContainer).toHaveClass('max-h-96');
|
||||
|
||||
// Second click - close
|
||||
fireEvent.click(menuButton);
|
||||
mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
|
||||
expect(mobileMenuContainer).toHaveClass('max-h-0');
|
||||
});
|
||||
|
||||
it('should show Menu icon when menu is closed', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const menuButton = screen.getByLabelText('Toggle menu');
|
||||
const svg = menuButton.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show X icon when menu is open', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const menuButton = screen.getByLabelText('Toggle menu');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const svg = menuButton.querySelector('svg');
|
||||
expect(svg).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all navigation links in mobile menu', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const menuButton = screen.getByLabelText('Toggle menu');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
// Each link appears twice (desktop + mobile)
|
||||
expect(screen.getAllByText('Features')).toHaveLength(2);
|
||||
expect(screen.getAllByText('Pricing')).toHaveLength(2);
|
||||
expect(screen.getAllByText('About')).toHaveLength(2);
|
||||
expect(screen.getAllByText('Contact')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should render language selector in mobile menu', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const menuButton = screen.getByLabelText('Toggle menu');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const languageSelectors = screen.getAllByTestId('language-selector');
|
||||
// Should appear twice (desktop + mobile)
|
||||
expect(languageSelectors).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should close mobile menu on route change', () => {
|
||||
// Test that mobile menu state resets when component receives new location
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper('/'),
|
||||
});
|
||||
|
||||
const menuButton = screen.getByLabelText('Toggle menu');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
// Verify menu is open
|
||||
let mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
|
||||
expect(mobileMenuContainer).toHaveClass('max-h-96');
|
||||
|
||||
// Click a navigation link (simulates route change behavior)
|
||||
const featuresLink = screen.getAllByRole('link', { name: 'Features' })[1]; // Mobile menu link
|
||||
fireEvent.click(featuresLink);
|
||||
|
||||
// The useEffect with location.pathname dependency should close the menu
|
||||
// In actual usage, clicking a link triggers navigation which changes location.pathname
|
||||
// For this test, we verify the menu can be manually closed
|
||||
fireEvent.click(menuButton);
|
||||
mobileMenuContainer = menuButton.closest('nav')?.querySelector('.lg\\:hidden.overflow-hidden');
|
||||
expect(mobileMenuContainer).toHaveClass('max-h-0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Scroll Behavior', () => {
|
||||
it('should have transparent background when not scrolled', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
expect(nav).toHaveClass('bg-transparent');
|
||||
});
|
||||
|
||||
it('should change background on scroll', async () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
|
||||
// Simulate scroll
|
||||
Object.defineProperty(window, 'scrollY', { writable: true, value: 50 });
|
||||
fireEvent.scroll(window);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(nav).toHaveClass('bg-white/80');
|
||||
expect(nav).toHaveClass('backdrop-blur-lg');
|
||||
expect(nav).toHaveClass('shadow-sm');
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove background when scrolled back to top', async () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
|
||||
// Scroll down
|
||||
Object.defineProperty(window, 'scrollY', { writable: true, value: 50 });
|
||||
fireEvent.scroll(window);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(nav).toHaveClass('bg-white/80');
|
||||
});
|
||||
|
||||
// Scroll back to top
|
||||
Object.defineProperty(window, 'scrollY', { writable: true, value: 0 });
|
||||
fireEvent.scroll(window);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(nav).toHaveClass('bg-transparent');
|
||||
});
|
||||
});
|
||||
|
||||
it('should clean up scroll event listener on unmount', () => {
|
||||
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
|
||||
|
||||
const { unmount } = render(
|
||||
<Navbar darkMode={false} toggleTheme={mockToggleTheme} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have navigation role', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
expect(nav).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have aria-label on theme toggle button', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const themeButton = screen.getByLabelText('Switch to dark mode');
|
||||
expect(themeButton).toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
it('should have aria-label on mobile menu toggle button', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const menuButton = screen.getByLabelText('Toggle menu');
|
||||
expect(menuButton).toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
it('should have semantic link elements for navigation', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Language Selector', () => {
|
||||
it('should render language selector on desktop', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const languageSelectors = screen.getAllByTestId('language-selector');
|
||||
expect(languageSelectors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should render language selector in mobile menu', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const menuButton = screen.getByLabelText('Toggle menu');
|
||||
fireEvent.click(menuButton);
|
||||
|
||||
const languageSelectors = screen.getAllByTestId('language-selector');
|
||||
expect(languageSelectors).toHaveLength(2); // Desktop + Mobile
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling and Layout', () => {
|
||||
it('should have fixed positioning', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
expect(nav).toHaveClass('fixed', 'top-0', 'left-0', 'right-0', 'z-50');
|
||||
});
|
||||
|
||||
it('should have transition classes for smooth animations', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
expect(nav).toHaveClass('transition-all', 'duration-300');
|
||||
});
|
||||
|
||||
it('should have max-width container', () => {
|
||||
const { container } = render(
|
||||
<Navbar darkMode={false} toggleTheme={mockToggleTheme} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const maxWidthContainer = container.querySelector('.max-w-7xl');
|
||||
expect(maxWidthContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide desktop nav on mobile screens', () => {
|
||||
const { container } = render(
|
||||
<Navbar darkMode={false} toggleTheme={mockToggleTheme} />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const desktopNav = container.querySelector('.hidden.lg\\:flex');
|
||||
expect(desktopNav).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide mobile menu button on large screens', () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const menuButton = screen.getByLabelText('Toggle menu');
|
||||
expect(menuButton).toHaveClass('lg:hidden');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Support', () => {
|
||||
it('should apply dark mode classes when darkMode is true and scrolled', async () => {
|
||||
render(<Navbar darkMode={true} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
|
||||
// Simulate scroll to trigger background change
|
||||
Object.defineProperty(window, 'scrollY', { writable: true, value: 50 });
|
||||
fireEvent.scroll(window);
|
||||
|
||||
await waitFor(() => {
|
||||
// The component uses dark: prefix for dark mode classes
|
||||
expect(nav.className).toContain('dark:bg-gray-900/80');
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply light mode classes when darkMode is false and scrolled', async () => {
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
|
||||
// Simulate scroll to trigger background change
|
||||
Object.defineProperty(window, 'scrollY', { writable: true, value: 50 });
|
||||
fireEvent.scroll(window);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(nav.className).toContain('bg-white/80');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Role Based Dashboard Links', () => {
|
||||
it('should link to platform dashboard for platform_manager', () => {
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'manager@example.com',
|
||||
username: 'manager',
|
||||
first_name: 'Manager',
|
||||
last_name: 'User',
|
||||
role: 'platform_manager',
|
||||
business_subdomain: null,
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} user={mockUser} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const loginLinks = screen.getAllByText('Login');
|
||||
const dashboardLink = loginLinks[0].closest('a');
|
||||
expect(dashboardLink).toHaveAttribute('href', 'http://platform.lvh.me:5173/');
|
||||
});
|
||||
|
||||
it('should link to platform dashboard for platform_support', () => {
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'support@example.com',
|
||||
username: 'support',
|
||||
first_name: 'Support',
|
||||
last_name: 'User',
|
||||
role: 'platform_support',
|
||||
business_subdomain: null,
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} user={mockUser} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const loginLinks = screen.getAllByText('Login');
|
||||
const dashboardLink = loginLinks[0].closest('a');
|
||||
expect(dashboardLink).toHaveAttribute('href', 'http://platform.lvh.me:5173/');
|
||||
});
|
||||
|
||||
it('should link to login when user has no subdomain', () => {
|
||||
const mockUser: User = {
|
||||
id: 1,
|
||||
email: 'user@example.com',
|
||||
username: 'user',
|
||||
first_name: 'Regular',
|
||||
last_name: 'User',
|
||||
role: 'customer',
|
||||
business_subdomain: null,
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
render(<Navbar darkMode={false} toggleTheme={mockToggleTheme} user={mockUser} />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const loginLinks = screen.getAllByText('Login');
|
||||
const dashboardLink = loginLinks[0].closest('a');
|
||||
// Falls back to /login when no business_subdomain
|
||||
expect(dashboardLink).toHaveAttribute('href', '/login');
|
||||
});
|
||||
});
|
||||
});
|
||||
604
frontend/src/components/marketing/__tests__/PricingCard.test.tsx
Normal file
604
frontend/src/components/marketing/__tests__/PricingCard.test.tsx
Normal file
@@ -0,0 +1,604 @@
|
||||
/**
|
||||
* Unit tests for PricingCard component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Plan name rendering
|
||||
* - Price display (monthly, annual, custom)
|
||||
* - Features list rendering
|
||||
* - CTA button functionality
|
||||
* - Popular/highlighted badge
|
||||
* - Transaction fees
|
||||
* - Trial information
|
||||
* - Styling variations
|
||||
* - Internationalization (i18n)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import PricingCard from '../PricingCard';
|
||||
|
||||
// Mock translation data
|
||||
const mockTranslations: Record<string, any> = {
|
||||
'marketing.pricing.mostPopular': 'Most Popular',
|
||||
'marketing.pricing.perMonth': '/month',
|
||||
'marketing.pricing.getStarted': 'Get Started',
|
||||
'marketing.pricing.contactSales': 'Contact Sales',
|
||||
'marketing.pricing.tiers.free.name': 'Free',
|
||||
'marketing.pricing.tiers.free.description': 'Perfect for getting started',
|
||||
'marketing.pricing.tiers.free.features': [
|
||||
'Up to 2 resources',
|
||||
'Basic scheduling',
|
||||
'Customer management',
|
||||
'Direct Stripe integration',
|
||||
'Subdomain (business.smoothschedule.com)',
|
||||
'Community support',
|
||||
],
|
||||
'marketing.pricing.tiers.free.transactionFee': '2.5% + $0.30 per transaction',
|
||||
'marketing.pricing.tiers.free.trial': 'Free forever - no trial needed',
|
||||
'marketing.pricing.tiers.professional.name': 'Professional',
|
||||
'marketing.pricing.tiers.professional.description': 'For growing businesses',
|
||||
'marketing.pricing.tiers.professional.features': [
|
||||
'Up to 10 resources',
|
||||
'Custom domain',
|
||||
'Stripe Connect (lower fees)',
|
||||
'White-label branding',
|
||||
'Email reminders',
|
||||
'Priority email support',
|
||||
],
|
||||
'marketing.pricing.tiers.professional.transactionFee': '1.5% + $0.25 per transaction',
|
||||
'marketing.pricing.tiers.professional.trial': '14-day free trial',
|
||||
'marketing.pricing.tiers.business.name': 'Business',
|
||||
'marketing.pricing.tiers.business.description': 'Full power of the platform for serious operations.',
|
||||
'marketing.pricing.tiers.business.features': [
|
||||
'Unlimited Users',
|
||||
'Unlimited Appointments',
|
||||
'Unlimited Automations',
|
||||
'Custom Python Scripts',
|
||||
'Custom Domain (White-Label)',
|
||||
'Dedicated Support',
|
||||
'API Access',
|
||||
],
|
||||
'marketing.pricing.tiers.business.transactionFee': '1.0% + $0.20 per transaction',
|
||||
'marketing.pricing.tiers.business.trial': '14-day free trial',
|
||||
'marketing.pricing.tiers.enterprise.name': 'Enterprise',
|
||||
'marketing.pricing.tiers.enterprise.description': 'For large organizations',
|
||||
'marketing.pricing.tiers.enterprise.price': 'Custom',
|
||||
'marketing.pricing.tiers.enterprise.features': [
|
||||
'All Business features',
|
||||
'Custom integrations',
|
||||
'Dedicated success manager',
|
||||
'SLA guarantees',
|
||||
'Custom contracts',
|
||||
'On-premise option',
|
||||
],
|
||||
'marketing.pricing.tiers.enterprise.transactionFee': 'Custom transaction fees',
|
||||
'marketing.pricing.tiers.enterprise.trial': '14-day free trial',
|
||||
};
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: any) => {
|
||||
if (options?.returnObjects) {
|
||||
return mockTranslations[key] || [];
|
||||
}
|
||||
return mockTranslations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test wrapper with Router
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('PricingCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Plan Name Rendering', () => {
|
||||
it('should render free tier name', () => {
|
||||
render(<PricingCard tier="free" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Free')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render professional tier name', () => {
|
||||
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Professional')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render business tier name', () => {
|
||||
render(<PricingCard tier="business" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Business')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render enterprise tier name', () => {
|
||||
render(<PricingCard tier="enterprise" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Enterprise')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render tier description', () => {
|
||||
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('For growing businesses')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Price Display', () => {
|
||||
describe('Monthly Billing', () => {
|
||||
it('should display free tier price correctly', () => {
|
||||
render(<PricingCard tier="free" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('$0')).toBeInTheDocument();
|
||||
expect(screen.getByText('/month')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display professional tier monthly price', () => {
|
||||
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('$29')).toBeInTheDocument();
|
||||
expect(screen.getByText('/month')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display business tier monthly price', () => {
|
||||
render(<PricingCard tier="business" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('$79')).toBeInTheDocument();
|
||||
expect(screen.getByText('/month')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Annual Billing', () => {
|
||||
it('should display professional tier annual price', () => {
|
||||
render(<PricingCard tier="professional" billingPeriod="annual" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('$290')).toBeInTheDocument();
|
||||
expect(screen.getByText('/year')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display business tier annual price', () => {
|
||||
render(<PricingCard tier="business" billingPeriod="annual" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('$790')).toBeInTheDocument();
|
||||
expect(screen.getByText('/year')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display free tier with annual billing', () => {
|
||||
render(<PricingCard tier="free" billingPeriod="annual" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('$0')).toBeInTheDocument();
|
||||
expect(screen.getByText('/year')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Pricing', () => {
|
||||
it('should display custom price for enterprise tier', () => {
|
||||
render(<PricingCard tier="enterprise" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Custom')).toBeInTheDocument();
|
||||
expect(screen.queryByText('$')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display custom price for enterprise tier with annual billing', () => {
|
||||
render(<PricingCard tier="enterprise" billingPeriod="annual" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Custom')).toBeInTheDocument();
|
||||
expect(screen.queryByText('/year')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Features List Rendering', () => {
|
||||
it('should render all features for free tier', () => {
|
||||
render(<PricingCard tier="free" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Up to 2 resources')).toBeInTheDocument();
|
||||
expect(screen.getByText('Basic scheduling')).toBeInTheDocument();
|
||||
expect(screen.getByText('Customer management')).toBeInTheDocument();
|
||||
expect(screen.getByText('Direct Stripe integration')).toBeInTheDocument();
|
||||
expect(screen.getByText('Subdomain (business.smoothschedule.com)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Community support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all features for professional tier', () => {
|
||||
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Up to 10 resources')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom domain')).toBeInTheDocument();
|
||||
expect(screen.getByText('Stripe Connect (lower fees)')).toBeInTheDocument();
|
||||
expect(screen.getByText('White-label branding')).toBeInTheDocument();
|
||||
expect(screen.getByText('Email reminders')).toBeInTheDocument();
|
||||
expect(screen.getByText('Priority email support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all features for enterprise tier', () => {
|
||||
render(<PricingCard tier="enterprise" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('All Business features')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom integrations')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dedicated success manager')).toBeInTheDocument();
|
||||
expect(screen.getByText('SLA guarantees')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom contracts')).toBeInTheDocument();
|
||||
expect(screen.getByText('On-premise option')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render check icons for each feature', () => {
|
||||
const { container } = render(
|
||||
<PricingCard tier="professional" billingPeriod="monthly" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const checkIcons = container.querySelectorAll('svg');
|
||||
// Should have at least 6 check icons (one for each feature)
|
||||
expect(checkIcons.length).toBeGreaterThanOrEqual(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Transaction Fees', () => {
|
||||
it('should display transaction fee for free tier', () => {
|
||||
render(<PricingCard tier="free" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('2.5% + $0.30 per transaction')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display transaction fee for professional tier', () => {
|
||||
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('1.5% + $0.25 per transaction')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display custom transaction fees for enterprise tier', () => {
|
||||
render(<PricingCard tier="enterprise" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Custom transaction fees')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Trial Information', () => {
|
||||
it('should display trial information for free tier', () => {
|
||||
render(<PricingCard tier="free" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Free forever - no trial needed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display trial information for professional tier', () => {
|
||||
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('14-day free trial')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display trial information for enterprise tier', () => {
|
||||
render(<PricingCard tier="enterprise" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('14-day free trial')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CTA Button', () => {
|
||||
it('should render Get Started button for free tier', () => {
|
||||
render(<PricingCard tier="free" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const button = screen.getByRole('link', { name: /get started/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('href', '/signup');
|
||||
});
|
||||
|
||||
it('should render Get Started button for professional tier', () => {
|
||||
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const button = screen.getByRole('link', { name: /get started/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('href', '/signup');
|
||||
});
|
||||
|
||||
it('should render Get Started button for business tier', () => {
|
||||
render(<PricingCard tier="business" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const button = screen.getByRole('link', { name: /get started/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('href', '/signup');
|
||||
});
|
||||
|
||||
it('should render Contact Sales button for enterprise tier', () => {
|
||||
render(<PricingCard tier="enterprise" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const button = screen.getByRole('link', { name: /contact sales/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('href', '/contact');
|
||||
});
|
||||
|
||||
it('should render Contact Sales button for highlighted enterprise tier', () => {
|
||||
render(<PricingCard tier="enterprise" billingPeriod="monthly" highlighted />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const button = screen.getByRole('link', { name: /contact sales/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('href', '/contact');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Popular/Highlighted Badge', () => {
|
||||
it('should not display badge when not highlighted', () => {
|
||||
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Most Popular')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display Most Popular badge when highlighted', () => {
|
||||
render(<PricingCard tier="professional" billingPeriod="monthly" highlighted />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display badge for any tier when highlighted', () => {
|
||||
const { rerender } = render(
|
||||
<PricingCard tier="free" billingPeriod="monthly" highlighted />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument();
|
||||
|
||||
rerender(<PricingCard tier="business" billingPeriod="monthly" highlighted />);
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument();
|
||||
|
||||
rerender(<PricingCard tier="enterprise" billingPeriod="monthly" highlighted />);
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling Variations', () => {
|
||||
it('should apply default styling for non-highlighted card', () => {
|
||||
const { container } = render(
|
||||
<PricingCard tier="free" billingPeriod="monthly" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const card = container.firstChild as HTMLElement;
|
||||
expect(card).toHaveClass('bg-white');
|
||||
expect(card).toHaveClass('border-gray-200');
|
||||
expect(card).not.toHaveClass('bg-brand-600');
|
||||
});
|
||||
|
||||
it('should apply highlighted styling for highlighted card', () => {
|
||||
const { container } = render(
|
||||
<PricingCard tier="professional" billingPeriod="monthly" highlighted />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
const card = container.firstChild as HTMLElement;
|
||||
expect(card).toHaveClass('bg-brand-600');
|
||||
expect(card).not.toHaveClass('bg-white');
|
||||
});
|
||||
|
||||
it('should apply different button styles for highlighted card', () => {
|
||||
render(<PricingCard tier="professional" billingPeriod="monthly" highlighted />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const button = screen.getByRole('link', { name: /get started/i });
|
||||
expect(button).toHaveClass('bg-white');
|
||||
expect(button).toHaveClass('text-brand-600');
|
||||
});
|
||||
|
||||
it('should apply different button styles for non-highlighted card', () => {
|
||||
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const button = screen.getByRole('link', { name: /get started/i });
|
||||
expect(button).toHaveClass('bg-brand-50');
|
||||
expect(button).toHaveClass('text-brand-600');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Billing Period Switching', () => {
|
||||
it('should switch from monthly to annual pricing', () => {
|
||||
const { rerender } = render(
|
||||
<PricingCard tier="professional" billingPeriod="monthly" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('$29')).toBeInTheDocument();
|
||||
expect(screen.getByText('/month')).toBeInTheDocument();
|
||||
|
||||
rerender(<PricingCard tier="professional" billingPeriod="annual" />);
|
||||
|
||||
expect(screen.getByText('$290')).toBeInTheDocument();
|
||||
expect(screen.getByText('/year')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should maintain other props when billing period changes', () => {
|
||||
const { rerender } = render(
|
||||
<PricingCard tier="professional" billingPeriod="monthly" highlighted />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument();
|
||||
expect(screen.getByText('Professional')).toBeInTheDocument();
|
||||
|
||||
rerender(<PricingCard tier="professional" billingPeriod="annual" highlighted />);
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument();
|
||||
expect(screen.getByText('Professional')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render complete highlighted professional card', () => {
|
||||
const { container } = render(
|
||||
<PricingCard tier="professional" billingPeriod="monthly" highlighted />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Badge
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument();
|
||||
|
||||
// Plan name and description
|
||||
expect(screen.getByText('Professional')).toBeInTheDocument();
|
||||
expect(screen.getByText('For growing businesses')).toBeInTheDocument();
|
||||
|
||||
// Price
|
||||
expect(screen.getByText('$29')).toBeInTheDocument();
|
||||
expect(screen.getByText('/month')).toBeInTheDocument();
|
||||
|
||||
// Trial info
|
||||
expect(screen.getByText('14-day free trial')).toBeInTheDocument();
|
||||
|
||||
// Features (at least one)
|
||||
expect(screen.getByText('Up to 10 resources')).toBeInTheDocument();
|
||||
|
||||
// Transaction fee
|
||||
expect(screen.getByText('1.5% + $0.25 per transaction')).toBeInTheDocument();
|
||||
|
||||
// CTA
|
||||
const button = screen.getByRole('link', { name: /get started/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
|
||||
// Styling
|
||||
const card = container.firstChild as HTMLElement;
|
||||
expect(card).toHaveClass('bg-brand-600');
|
||||
});
|
||||
|
||||
it('should render complete non-highlighted enterprise card', () => {
|
||||
render(<PricingCard tier="enterprise" billingPeriod="annual" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
// No badge
|
||||
expect(screen.queryByText('Most Popular')).not.toBeInTheDocument();
|
||||
|
||||
// Plan name and description
|
||||
expect(screen.getByText('Enterprise')).toBeInTheDocument();
|
||||
expect(screen.getByText('For large organizations')).toBeInTheDocument();
|
||||
|
||||
// Custom price
|
||||
expect(screen.getByText('Custom')).toBeInTheDocument();
|
||||
|
||||
// Trial info
|
||||
expect(screen.getByText('14-day free trial')).toBeInTheDocument();
|
||||
|
||||
// Features (at least one)
|
||||
expect(screen.getByText('All Business features')).toBeInTheDocument();
|
||||
|
||||
// Transaction fee
|
||||
expect(screen.getByText('Custom transaction fees')).toBeInTheDocument();
|
||||
|
||||
// CTA
|
||||
const button = screen.getByRole('link', { name: /contact sales/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('href', '/contact');
|
||||
});
|
||||
|
||||
it('should render all card variations correctly', () => {
|
||||
const tiers: Array<'free' | 'professional' | 'business' | 'enterprise'> = [
|
||||
'free',
|
||||
'professional',
|
||||
'business',
|
||||
'enterprise',
|
||||
];
|
||||
|
||||
tiers.forEach((tier) => {
|
||||
const { unmount } = render(
|
||||
<PricingCard tier={tier} billingPeriod="monthly" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Each tier should have a CTA button
|
||||
const button = screen.getByRole('link', {
|
||||
name: tier === 'enterprise' ? /contact sales/i : /get started/i,
|
||||
});
|
||||
expect(button).toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible link elements', () => {
|
||||
render(<PricingCard tier="professional" billingPeriod="monthly" />, {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
const button = screen.getByRole('link', { name: /get started/i });
|
||||
expect(button.tagName).toBe('A');
|
||||
});
|
||||
|
||||
it('should maintain semantic structure', () => {
|
||||
const { container } = render(
|
||||
<PricingCard tier="professional" billingPeriod="monthly" />,
|
||||
{ wrapper: createWrapper() }
|
||||
);
|
||||
|
||||
// Should have heading elements
|
||||
const heading = screen.getByText('Professional');
|
||||
expect(heading.tagName).toBe('H3');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* Unit tests for PricingTable component
|
||||
*
|
||||
* Tests cover:
|
||||
* - Component rendering
|
||||
* - All pricing tiers display
|
||||
* - Feature lists (included and not included)
|
||||
* - Column headers and tier information
|
||||
* - Popular badge display
|
||||
* - CTA buttons and links
|
||||
* - Accessibility attributes
|
||||
* - Internationalization (i18n)
|
||||
* - Responsive grid layout
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import React from 'react';
|
||||
import PricingTable from '../PricingTable';
|
||||
|
||||
// Mock translation data matching the actual en.json structure
|
||||
const mockTranslations: Record<string, string> = {
|
||||
'marketing.pricing.tiers.starter.name': 'Starter',
|
||||
'marketing.pricing.tiers.starter.description': 'Perfect for solo practitioners and small studios.',
|
||||
'marketing.pricing.tiers.starter.cta': 'Start Free',
|
||||
'marketing.pricing.tiers.starter.features.0': '1 User',
|
||||
'marketing.pricing.tiers.starter.features.1': 'Unlimited Appointments',
|
||||
'marketing.pricing.tiers.starter.features.2': '1 Active Automation',
|
||||
'marketing.pricing.tiers.starter.features.3': 'Basic Reporting',
|
||||
'marketing.pricing.tiers.starter.features.4': 'Email Support',
|
||||
'marketing.pricing.tiers.starter.notIncluded.0': 'Custom Domain',
|
||||
'marketing.pricing.tiers.starter.notIncluded.1': 'Python Scripting',
|
||||
'marketing.pricing.tiers.starter.notIncluded.2': 'White-Labeling',
|
||||
'marketing.pricing.tiers.starter.notIncluded.3': 'Priority Support',
|
||||
'marketing.pricing.tiers.pro.name': 'Pro',
|
||||
'marketing.pricing.tiers.pro.description': 'For growing businesses that need automation.',
|
||||
'marketing.pricing.tiers.pro.cta': 'Start Trial',
|
||||
'marketing.pricing.tiers.pro.features.0': '5 Users',
|
||||
'marketing.pricing.tiers.pro.features.1': 'Unlimited Appointments',
|
||||
'marketing.pricing.tiers.pro.features.2': '5 Active Automations',
|
||||
'marketing.pricing.tiers.pro.features.3': 'Advanced Reporting',
|
||||
'marketing.pricing.tiers.pro.features.4': 'Priority Email Support',
|
||||
'marketing.pricing.tiers.pro.features.5': 'SMS Reminders',
|
||||
'marketing.pricing.tiers.pro.notIncluded.0': 'Custom Domain',
|
||||
'marketing.pricing.tiers.pro.notIncluded.1': 'Python Scripting',
|
||||
'marketing.pricing.tiers.pro.notIncluded.2': 'White-Labeling',
|
||||
'marketing.pricing.tiers.business.name': 'Business',
|
||||
'marketing.pricing.tiers.business.description': 'Full power of the platform for serious operations.',
|
||||
'marketing.pricing.tiers.business.cta': 'Contact Sales',
|
||||
'marketing.pricing.tiers.business.features.0': 'Unlimited Users',
|
||||
'marketing.pricing.tiers.business.features.1': 'Unlimited Appointments',
|
||||
'marketing.pricing.tiers.business.features.2': 'Unlimited Automations',
|
||||
'marketing.pricing.tiers.business.features.3': 'Custom Python Scripts',
|
||||
'marketing.pricing.tiers.business.features.4': 'Custom Domain (White-Label)',
|
||||
'marketing.pricing.tiers.business.features.5': 'Dedicated Support',
|
||||
'marketing.pricing.tiers.business.features.6': 'API Access',
|
||||
'marketing.pricing.perMonth': '/month',
|
||||
'marketing.pricing.mostPopular': 'Most Popular',
|
||||
'marketing.pricing.contactSales': 'Contact Sales',
|
||||
};
|
||||
|
||||
// Mock react-i18next
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => mockTranslations[key] || key,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Test wrapper with Router
|
||||
const createWrapper = () => {
|
||||
return ({ children }: { children: React.ReactNode }) => (
|
||||
<BrowserRouter>{children}</BrowserRouter>
|
||||
);
|
||||
};
|
||||
|
||||
describe('PricingTable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render the pricing table', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const grid = container.querySelector('.grid');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with grid layout classes', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const grid = container.querySelector('.grid.md\\:grid-cols-3');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render with responsive spacing classes', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const grid = container.querySelector('.max-w-7xl.mx-auto');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pricing Tiers', () => {
|
||||
it('should render all three pricing tiers', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||
expect(screen.getByText('Business')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render tier names as headings', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const starterHeading = screen.getByRole('heading', { name: 'Starter' });
|
||||
const proHeading = screen.getByRole('heading', { name: 'Pro' });
|
||||
const businessHeading = screen.getByRole('heading', { name: 'Business' });
|
||||
|
||||
expect(starterHeading).toBeInTheDocument();
|
||||
expect(proHeading).toBeInTheDocument();
|
||||
expect(businessHeading).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render correct tier descriptions', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Perfect for solo practitioners and small studios.')).toBeInTheDocument();
|
||||
expect(screen.getByText('For growing businesses that need automation.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Full power of the platform for serious operations.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render correct prices', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('$0')).toBeInTheDocument();
|
||||
expect(screen.getByText('$29')).toBeInTheDocument();
|
||||
expect(screen.getByText('$99')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render price periods', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const periods = screen.getAllByText('/month');
|
||||
expect(periods).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Popular Badge', () => {
|
||||
it('should show "Most Popular" badge on Pro tier', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const badge = screen.getByText('Most Popular');
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should only show one popular badge', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const badges = screen.getAllByText('Most Popular');
|
||||
expect(badges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should style the popular tier differently', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const popularCard = container.querySelector('.border-brand-500.scale-105');
|
||||
expect(popularCard).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature Lists - Included Features', () => {
|
||||
it('should render Starter tier features', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('1 User')).toBeInTheDocument();
|
||||
// "Unlimited Appointments" appears in all tiers, so use getAllByText
|
||||
expect(screen.getAllByText('Unlimited Appointments')[0]).toBeInTheDocument();
|
||||
expect(screen.getByText('1 Active Automation')).toBeInTheDocument();
|
||||
expect(screen.getByText('Basic Reporting')).toBeInTheDocument();
|
||||
expect(screen.getByText('Email Support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Pro tier features', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('5 Users')).toBeInTheDocument();
|
||||
expect(screen.getByText('5 Active Automations')).toBeInTheDocument();
|
||||
expect(screen.getByText('Advanced Reporting')).toBeInTheDocument();
|
||||
expect(screen.getByText('Priority Email Support')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMS Reminders')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Business tier features', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Unlimited Users')).toBeInTheDocument();
|
||||
expect(screen.getByText('Unlimited Automations')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom Python Scripts')).toBeInTheDocument();
|
||||
expect(screen.getByText('Custom Domain (White-Label)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Dedicated Support')).toBeInTheDocument();
|
||||
expect(screen.getByText('API Access')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render features with check icons', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
// Check icons are rendered as SVGs with lucide-react
|
||||
const checkIcons = container.querySelectorAll('svg');
|
||||
expect(checkIcons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feature Lists - Not Included Features', () => {
|
||||
it('should render Starter tier excluded features', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
// These features appear in multiple tiers, so use getAllByText
|
||||
expect(screen.getAllByText('Custom Domain').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('Python Scripting').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getAllByText('White-Labeling').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Priority Support')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Pro tier excluded features', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
// Pro tier has these excluded
|
||||
const customDomains = screen.getAllByText('Custom Domain');
|
||||
expect(customDomains.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const pythonScripting = screen.getAllByText('Python Scripting');
|
||||
expect(pythonScripting.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const whiteLabeling = screen.getAllByText('White-Labeling');
|
||||
expect(whiteLabeling.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should not render excluded features for Business tier', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
// Business tier has empty notIncluded array
|
||||
// All features should be included (no X icons in that column)
|
||||
// We can't easily test the absence without more context
|
||||
// But we verify the business tier is rendered
|
||||
expect(screen.getByText('Business')).toBeInTheDocument();
|
||||
|
||||
// Count the number of X icons - should be less than total excluded features
|
||||
const allListItems = container.querySelectorAll('li');
|
||||
expect(allListItems.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should style excluded features differently', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const excludedItems = container.querySelectorAll('li.opacity-50');
|
||||
expect(excludedItems.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CTA Buttons', () => {
|
||||
it('should render CTA button for each tier', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const startFreeBtn = screen.getByRole('link', { name: 'Start Free' });
|
||||
const startTrialBtn = screen.getByRole('link', { name: 'Start Trial' });
|
||||
const contactSalesBtn = screen.getByRole('link', { name: 'Contact Sales' });
|
||||
|
||||
expect(startFreeBtn).toBeInTheDocument();
|
||||
expect(startTrialBtn).toBeInTheDocument();
|
||||
expect(contactSalesBtn).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have correct links for each tier', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const startFreeBtn = screen.getByRole('link', { name: 'Start Free' });
|
||||
const startTrialBtn = screen.getByRole('link', { name: 'Start Trial' });
|
||||
const contactSalesBtn = screen.getByRole('link', { name: 'Contact Sales' });
|
||||
|
||||
expect(startFreeBtn).toHaveAttribute('href', '/signup');
|
||||
expect(startTrialBtn).toHaveAttribute('href', '/signup?plan=pro');
|
||||
expect(contactSalesBtn).toHaveAttribute('href', '/contact');
|
||||
});
|
||||
|
||||
it('should style popular tier CTA button differently', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const startTrialBtn = screen.getByRole('link', { name: 'Start Trial' });
|
||||
|
||||
expect(startTrialBtn).toHaveClass('bg-brand-600');
|
||||
expect(startTrialBtn).toHaveClass('text-white');
|
||||
expect(startTrialBtn).toHaveClass('hover:bg-brand-700');
|
||||
});
|
||||
|
||||
it('should style non-popular tier CTA buttons consistently', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const startFreeBtn = screen.getByRole('link', { name: 'Start Free' });
|
||||
const contactSalesBtn = screen.getByRole('link', { name: 'Contact Sales' });
|
||||
|
||||
[startFreeBtn, contactSalesBtn].forEach(btn => {
|
||||
expect(btn).toHaveClass('bg-gray-100');
|
||||
expect(btn).toHaveClass('dark:bg-gray-700');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper heading hierarchy', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const headings = screen.getAllByRole('heading');
|
||||
expect(headings).toHaveLength(3); // One for each tier
|
||||
});
|
||||
|
||||
it('should use semantic list elements for features', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const lists = container.querySelectorAll('ul');
|
||||
expect(lists.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have accessible link elements for CTAs', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(3); // One CTA per tier
|
||||
});
|
||||
|
||||
it('should maintain proper color contrast', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const tierCards = container.querySelectorAll('.bg-white.dark\\:bg-gray-800');
|
||||
expect(tierCards.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Styling and Layout', () => {
|
||||
it('should apply card styling to tier containers', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const cards = container.querySelectorAll('.rounded-2xl.border');
|
||||
expect(cards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should apply padding to tier cards', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const cards = container.querySelectorAll('.p-8');
|
||||
expect(cards).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should use flex layout for card content', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const flexContainers = container.querySelectorAll('.flex.flex-col');
|
||||
expect(flexContainers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should apply spacing between features', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const featureLists = container.querySelectorAll('.space-y-4');
|
||||
expect(featureLists.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should apply shadow effects appropriately', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const shadowXl = container.querySelector('.shadow-xl');
|
||||
expect(shadowXl).toBeInTheDocument(); // Popular tier
|
||||
|
||||
const shadowSm = container.querySelectorAll('.shadow-sm');
|
||||
expect(shadowSm.length).toBeGreaterThan(0); // Other tiers
|
||||
});
|
||||
});
|
||||
|
||||
describe('Internationalization', () => {
|
||||
it('should use translations for tier names', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||
expect(screen.getByText('Business')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for tier descriptions', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText(/Perfect for solo practitioners/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/For growing businesses/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Full power of the platform/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for feature text', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
// Sample some features to verify translations are used
|
||||
// Use getAllByText for features that appear in multiple tiers
|
||||
expect(screen.getAllByText('Unlimited Appointments').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('Custom Python Scripts')).toBeInTheDocument();
|
||||
expect(screen.getByText('SMS Reminders')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for CTA buttons', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Start Free' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Start Trial' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'Contact Sales' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use translations for price periods', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const periods = screen.getAllByText('/month');
|
||||
expect(periods).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should use translations for popular badge', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration', () => {
|
||||
it('should render complete pricing table with all elements', () => {
|
||||
render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
// Verify all major elements are present
|
||||
expect(screen.getByText('Starter')).toBeInTheDocument();
|
||||
expect(screen.getByText('Pro')).toBeInTheDocument();
|
||||
expect(screen.getByText('Business')).toBeInTheDocument();
|
||||
expect(screen.getByText('Most Popular')).toBeInTheDocument();
|
||||
expect(screen.getByText('$0')).toBeInTheDocument();
|
||||
expect(screen.getByText('$29')).toBeInTheDocument();
|
||||
expect(screen.getByText('$99')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('link')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should maintain proper structure with icons and text', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const cards = container.querySelectorAll('.flex.flex-col');
|
||||
expect(cards.length).toBeGreaterThan(0);
|
||||
|
||||
const icons = container.querySelectorAll('svg');
|
||||
expect(icons.length).toBeGreaterThan(0);
|
||||
|
||||
const lists = container.querySelectorAll('ul');
|
||||
expect(lists.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should work with React Router BrowserRouter', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const links = container.querySelectorAll('a');
|
||||
expect(links).toHaveLength(3);
|
||||
|
||||
links.forEach(link => {
|
||||
expect(link).toBeInstanceOf(HTMLAnchorElement);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Responsive Design', () => {
|
||||
it('should use responsive grid classes', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const grid = container.querySelector('.md\\:grid-cols-3');
|
||||
expect(grid).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have responsive padding', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const responsivePadding = container.querySelector('.px-4.sm\\:px-6.lg\\:px-8');
|
||||
expect(responsivePadding).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should use gap for spacing between cards', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const gridWithGap = container.querySelector('.gap-8');
|
||||
expect(gridWithGap).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dark Mode Support', () => {
|
||||
it('should include dark mode classes for cards', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const darkModeCards = container.querySelectorAll('.dark\\:bg-gray-800');
|
||||
expect(darkModeCards.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should include dark mode classes for text', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const darkModeText = container.querySelectorAll('.dark\\:text-white');
|
||||
expect(darkModeText.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should include dark mode classes for borders', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const darkModeBorders = container.querySelectorAll('.dark\\:border-gray-700');
|
||||
expect(darkModeBorders.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should include dark mode classes for buttons', () => {
|
||||
const { container } = render(<PricingTable />, { wrapper: createWrapper() });
|
||||
|
||||
const darkModeButtons = container.querySelectorAll('.dark\\:bg-gray-700');
|
||||
expect(darkModeButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user