- 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>
512 lines
17 KiB
TypeScript
512 lines
17 KiB
TypeScript
/**
|
|
* Unit tests for TrialBanner component
|
|
*
|
|
* Tests the trial status banner that appears at the top of the business layout.
|
|
* Covers:
|
|
* - Rendering with different days remaining
|
|
* - Urgent state (3 days or less)
|
|
* - Upgrade button navigation
|
|
* - Dismiss functionality
|
|
* - Hidden states (dismissed, not active, no days left)
|
|
* - Trial end date formatting
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, fireEvent, within } from '@testing-library/react';
|
|
import { BrowserRouter } from 'react-router-dom';
|
|
import TrialBanner from '../TrialBanner';
|
|
import { Business } from '../../types';
|
|
|
|
// Mock react-router-dom's useNavigate
|
|
const mockNavigate = vi.fn();
|
|
vi.mock('react-router-dom', async () => {
|
|
const actual = await vi.importActual('react-router-dom');
|
|
return {
|
|
...actual,
|
|
useNavigate: () => mockNavigate,
|
|
};
|
|
});
|
|
|
|
// Mock i18next
|
|
vi.mock('react-i18next', () => ({
|
|
useTranslation: () => ({
|
|
t: (key: string, params?: Record<string, unknown>) => {
|
|
// Simulate translation behavior
|
|
const translations: Record<string, string> = {
|
|
'trial.banner.title': 'Trial Active',
|
|
'trial.banner.daysLeft': `${params?.days} days left in trial`,
|
|
'trial.banner.expiresOn': `Trial expires on ${params?.date}`,
|
|
'trial.banner.upgradeNow': 'Upgrade Now',
|
|
'trial.banner.dismiss': 'Dismiss',
|
|
};
|
|
return translations[key] || key;
|
|
},
|
|
}),
|
|
}));
|
|
|
|
// Test data factory for Business objects
|
|
const createMockBusiness = (overrides?: Partial<Business>): Business => ({
|
|
id: '1',
|
|
name: 'Test Business',
|
|
subdomain: 'testbiz',
|
|
primaryColor: '#3B82F6',
|
|
secondaryColor: '#1E40AF',
|
|
whitelabelEnabled: false,
|
|
paymentsEnabled: true,
|
|
requirePaymentMethodToBook: false,
|
|
cancellationWindowHours: 24,
|
|
lateCancellationFeePercent: 50,
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 10,
|
|
trialEnd: '2025-12-17T23:59:59Z',
|
|
...overrides,
|
|
});
|
|
|
|
// Wrapper component that provides router context
|
|
const renderWithRouter = (ui: React.ReactElement) => {
|
|
return render(<BrowserRouter>{ui}</BrowserRouter>);
|
|
};
|
|
|
|
describe('TrialBanner', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('Rendering', () => {
|
|
it('should render banner with trial information when trial is active', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 10,
|
|
trialEnd: '2025-12-17T23:59:59Z',
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/10 days left in trial/i)).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /upgrade now/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display the trial end date', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 5,
|
|
trialEnd: '2025-12-17T00:00:00Z',
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
// Check that the date is displayed (format may vary by locale)
|
|
expect(screen.getByText(/trial expires on/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render Sparkles icon when more than 3 days left', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 7,
|
|
});
|
|
|
|
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
// The Sparkles icon should be rendered (not the Clock icon)
|
|
// Check for the non-urgent styling
|
|
const banner = container.querySelector('.bg-gradient-to-r.from-blue-600');
|
|
expect(banner).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render Clock icon with pulse animation when 3 days or less left', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 3,
|
|
});
|
|
|
|
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
// Check for urgent styling
|
|
const banner = container.querySelector('.bg-gradient-to-r.from-red-500');
|
|
expect(banner).toBeInTheDocument();
|
|
|
|
// Check for pulse animation on the icon
|
|
const pulsingIcon = container.querySelector('.animate-pulse');
|
|
expect(pulsingIcon).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render Upgrade Now button with arrow icon', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 10,
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
|
|
expect(upgradeButton).toBeInTheDocument();
|
|
expect(upgradeButton).toHaveClass('bg-white', 'text-blue-600');
|
|
});
|
|
|
|
it('should render dismiss button with aria-label', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 10,
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
|
expect(dismissButton).toBeInTheDocument();
|
|
expect(dismissButton).toHaveAttribute('aria-label', 'Dismiss');
|
|
});
|
|
});
|
|
|
|
describe('Urgent State (3 days or less)', () => {
|
|
it('should apply urgent styling when 3 days left', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 3,
|
|
});
|
|
|
|
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
const banner = container.querySelector('.bg-gradient-to-r.from-red-500.to-orange-500');
|
|
expect(banner).toBeInTheDocument();
|
|
});
|
|
|
|
it('should apply urgent styling when 2 days left', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 2,
|
|
});
|
|
|
|
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
const banner = container.querySelector('.bg-gradient-to-r.from-red-500.to-orange-500');
|
|
expect(banner).toBeInTheDocument();
|
|
});
|
|
|
|
it('should apply urgent styling when 1 day left', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 1,
|
|
});
|
|
|
|
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
expect(screen.getByText(/1 days left in trial/i)).toBeInTheDocument();
|
|
const banner = container.querySelector('.bg-gradient-to-r.from-red-500.to-orange-500');
|
|
expect(banner).toBeInTheDocument();
|
|
});
|
|
|
|
it('should NOT apply urgent styling when 4 days left', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 4,
|
|
});
|
|
|
|
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
const banner = container.querySelector('.bg-gradient-to-r.from-blue-600.to-blue-500');
|
|
expect(banner).toBeInTheDocument();
|
|
expect(container.querySelector('.from-red-500')).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('User Interactions', () => {
|
|
it('should navigate to /upgrade when Upgrade Now button is clicked', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 10,
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
|
|
fireEvent.click(upgradeButton);
|
|
|
|
expect(mockNavigate).toHaveBeenCalledWith('/upgrade');
|
|
expect(mockNavigate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('should hide banner when dismiss button is clicked', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 10,
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
// Banner should be visible initially
|
|
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
|
|
|
// Click dismiss button
|
|
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
|
fireEvent.click(dismissButton);
|
|
|
|
// Banner should be hidden
|
|
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should keep banner hidden after dismissing even when multiple clicks', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 10,
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
|
fireEvent.click(dismissButton);
|
|
|
|
// Banner should remain hidden
|
|
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Hidden States', () => {
|
|
it('should not render when trial is not active', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: false,
|
|
daysLeftInTrial: 10,
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should not render when daysLeftInTrial is undefined', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: undefined,
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should not render when daysLeftInTrial is 0', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 0,
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should not render when daysLeftInTrial is null', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: null as unknown as number,
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
it('should not render when already dismissed', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 10,
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
// Dismiss the banner
|
|
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
|
fireEvent.click(dismissButton);
|
|
|
|
// Banner should not be visible
|
|
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Edge Cases', () => {
|
|
it('should handle missing trialEnd date gracefully', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 5,
|
|
trialEnd: undefined,
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
// Banner should still render
|
|
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/5 days left in trial/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should handle invalid trialEnd date gracefully', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 5,
|
|
trialEnd: 'invalid-date',
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
// Banner should still render despite invalid date
|
|
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should display correct styling for boundary case of exactly 3 days', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 3,
|
|
});
|
|
|
|
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
// Should use urgent styling at exactly 3 days
|
|
const banner = container.querySelector('.bg-gradient-to-r.from-red-500');
|
|
expect(banner).toBeInTheDocument();
|
|
});
|
|
|
|
it('should handle very large number of days remaining', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 999,
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
expect(screen.getByText(/999 days left in trial/i)).toBeInTheDocument();
|
|
// Should use non-urgent styling
|
|
const { container } = render(<TrialBanner business={business} />, { wrapper: BrowserRouter });
|
|
const banner = container.querySelector('.bg-gradient-to-r.from-blue-600');
|
|
expect(banner).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Accessibility', () => {
|
|
it('should have proper button roles and labels', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 10,
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
const upgradeButton = screen.getByRole('button', { name: /upgrade now/i });
|
|
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
|
|
|
expect(upgradeButton).toBeInTheDocument();
|
|
expect(dismissButton).toBeInTheDocument();
|
|
expect(dismissButton).toHaveAttribute('aria-label');
|
|
});
|
|
|
|
it('should have readable text content for screen readers', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 7,
|
|
trialEnd: '2025-12-24T23:59:59Z',
|
|
});
|
|
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
// All important text should be accessible
|
|
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/7 days left in trial/i)).toBeInTheDocument();
|
|
expect(screen.getByText(/trial expires on/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Responsive Behavior', () => {
|
|
it('should render trial end date with hidden class for small screens', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 10,
|
|
trialEnd: '2025-12-17T23:59:59Z',
|
|
});
|
|
|
|
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
// The trial end date paragraph should have 'hidden sm:block' classes
|
|
const endDateElement = container.querySelector('.hidden.sm\\:block');
|
|
expect(endDateElement).toBeInTheDocument();
|
|
});
|
|
|
|
it('should render all key elements in the banner', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 10,
|
|
});
|
|
|
|
const { container } = renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
// Icon container
|
|
const iconContainer = container.querySelector('.p-2.rounded-full');
|
|
expect(iconContainer).toBeInTheDocument();
|
|
|
|
// Buttons container
|
|
const buttonsContainer = screen.getByRole('button', { name: /upgrade now/i }).parentElement;
|
|
expect(buttonsContainer).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
describe('Component Integration', () => {
|
|
it('should work with different business configurations', () => {
|
|
const businesses = [
|
|
createMockBusiness({ daysLeftInTrial: 1, isTrialActive: true }),
|
|
createMockBusiness({ daysLeftInTrial: 7, isTrialActive: true }),
|
|
createMockBusiness({ daysLeftInTrial: 14, isTrialActive: true }),
|
|
];
|
|
|
|
businesses.forEach((business) => {
|
|
const { unmount } = renderWithRouter(<TrialBanner business={business} />);
|
|
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
|
unmount();
|
|
});
|
|
});
|
|
|
|
it('should maintain state across re-renders when not dismissed', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 10,
|
|
});
|
|
|
|
const { rerender } = renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
|
|
|
// Re-render with updated days
|
|
const updatedBusiness = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 9,
|
|
});
|
|
|
|
rerender(
|
|
<BrowserRouter>
|
|
<TrialBanner business={updatedBusiness} />
|
|
</BrowserRouter>
|
|
);
|
|
|
|
expect(screen.getByText(/9 days left in trial/i)).toBeInTheDocument();
|
|
});
|
|
|
|
it('should reset dismissed state on component unmount and remount', () => {
|
|
const business = createMockBusiness({
|
|
isTrialActive: true,
|
|
daysLeftInTrial: 10,
|
|
});
|
|
|
|
const { unmount } = renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
// Dismiss the banner
|
|
const dismissButton = screen.getByRole('button', { name: /dismiss/i });
|
|
fireEvent.click(dismissButton);
|
|
|
|
expect(screen.queryByText(/trial active/i)).not.toBeInTheDocument();
|
|
|
|
// Unmount and remount
|
|
unmount();
|
|
renderWithRouter(<TrialBanner business={business} />);
|
|
|
|
// Banner should reappear (dismissed state is not persisted)
|
|
expect(screen.getByText(/trial active/i)).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|