Refactor the "God Component" (App.tsx) Your App.tsx has lower branch coverage (77%) and uncovered lines. This usually means it's doing too much: managing routing, auth state checks, theme toggling, and global error handling. Move Logic to "Initialization Hooks": Create a useAppInitialization hook that handles the OAuth token check, version check, and theme sync. Use Layouts for Routing: Move the "What's New" modal and "Anonymous Banner" into the MainLayout or a specialized AppGuard component, leaving App.tsx as a clean list of Routes.
Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 56s

This commit is contained in:
2025-12-27 21:13:15 -08:00
parent 8a4965c45b
commit a726c270bb
19 changed files with 526 additions and 416 deletions

View File

@@ -0,0 +1,40 @@
// src/components/AnonymousUserBanner.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { AnonymousUserBanner } from './AnonymousUserBanner';
// Mock the icon to ensure it is rendered correctly
vi.mock('./icons/InformationCircleIcon', () => ({
InformationCircleIcon: (props: React.SVGProps<SVGSVGElement>) => (
<svg data-testid="info-icon" {...props} />
),
}));
describe('AnonymousUserBanner', () => {
it('should render the banner with the correct text content and accessibility role', () => {
const mockOnOpenProfile = vi.fn();
render(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
// Check for accessibility role
expect(screen.getByRole('alert')).toBeInTheDocument();
expect(screen.getByText(/you're viewing as a guest/i)).toBeInTheDocument();
expect(
screen.getByText(/to save your flyers, create a watchlist, and access more features/i),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: /sign up or log in/i })).toBeInTheDocument();
expect(screen.getByTestId('info-icon')).toBeInTheDocument();
expect(screen.getByTestId('info-icon')).toHaveClass('text-blue-500');
});
it('should call onOpenProfile when the "sign up or log in" button is clicked', () => {
const mockOnOpenProfile = vi.fn();
render(<AnonymousUserBanner onOpenProfile={mockOnOpenProfile} />);
const loginButton = screen.getByRole('button', { name: /sign up or log in/i });
fireEvent.click(loginButton);
expect(mockOnOpenProfile).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,41 @@
// src/components/AnonymousUserBanner.tsx
import React from 'react';
import { InformationCircleIcon } from './icons/InformationCircleIcon';
interface AnonymousUserBannerProps {
/**
* A callback function to open the login/signup modal.
*/
onOpenProfile: () => void;
}
/**
* A banner displayed to anonymous users to encourage them to sign up or log in.
*/
export const AnonymousUserBanner: React.FC<AnonymousUserBannerProps> = ({ onOpenProfile }) => {
return (
<div
className="bg-blue-100 dark:bg-blue-900/30 border-l-4 border-blue-500 text-blue-700 dark:text-blue-300 p-4 rounded-r-lg"
role="alert"
>
<div className="flex items-center">
<div className="py-1">
<InformationCircleIcon className="h-6 w-6 text-blue-500 mr-4" />
</div>
<div>
<p className="font-bold">You're viewing as a guest.</p>
<p className="text-sm">
To save your flyers, create a watchlist, and access more features, please{' '}
<button
onClick={onOpenProfile}
className="font-bold underline hover:text-blue-600 dark:hover:text-blue-200"
>
sign up or log in
</button>
.
</p>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,93 @@
// src/components/AppGuard.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { AppGuard } from './AppGuard';
import { useAppInitialization } from '../hooks/useAppInitialization';
import { useModal } from '../hooks/useModal';
// Mock dependencies
vi.mock('../hooks/useAppInitialization');
vi.mock('../hooks/useModal');
vi.mock('./WhatsNewModal', () => ({
WhatsNewModal: ({ isOpen }: { isOpen: boolean }) =>
isOpen ? <div data-testid="whats-new-modal-mock" /> : null,
}));
vi.mock('../config', () => ({
default: {
app: { version: '1.0.0', commitMessage: 'Test commit' },
},
}));
const mockedUseAppInitialization = vi.mocked(useAppInitialization);
const mockedUseModal = vi.mocked(useModal);
describe('AppGuard', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mocks
mockedUseAppInitialization.mockReturnValue({
isDarkMode: false,
unitSystem: 'imperial',
});
mockedUseModal.mockReturnValue({
isModalOpen: vi.fn().mockReturnValue(false),
openModal: vi.fn(),
closeModal: vi.fn(),
});
});
it('should render children', () => {
render(
<AppGuard>
<div>Child Content</div>
</AppGuard>,
);
expect(screen.getByText('Child Content')).toBeInTheDocument();
});
it('should render WhatsNewModal when it is open', () => {
mockedUseModal.mockReturnValue({
...mockedUseModal(),
isModalOpen: (modalId) => modalId === 'whatsNew',
});
render(
<AppGuard>
<div>Child</div>
</AppGuard>,
);
expect(screen.getByTestId('whats-new-modal-mock')).toBeInTheDocument();
});
it('should set dark mode styles for toaster', async () => {
mockedUseAppInitialization.mockReturnValue({
isDarkMode: true,
unitSystem: 'imperial',
});
render(
<AppGuard>
<div>Child</div>
</AppGuard>,
);
await waitFor(() => {
const styleTag = document.querySelector('style');
expect(styleTag).not.toBeNull();
expect(styleTag!.innerHTML).toContain('--toast-bg: #4B5563');
expect(styleTag!.innerHTML).toContain('--toast-color: #F9FAFB');
});
});
it('should set light mode styles for toaster', async () => {
render(
<AppGuard>
<div>Child</div>
</AppGuard>,
);
await waitFor(() => {
const styleTag = document.querySelector('style');
expect(styleTag).not.toBeNull();
expect(styleTag!.innerHTML).toContain('--toast-bg: #FFFFFF');
expect(styleTag!.innerHTML).toContain('--toast-color: #1F2937');
});
});
});

View File

@@ -0,0 +1,47 @@
// src/components/AppGuard.tsx
import React, { useCallback } from 'react';
import { Toaster } from 'react-hot-toast';
import { useAppInitialization } from '../hooks/useAppInitialization';
import { useModal } from '../hooks/useModal';
import { WhatsNewModal } from './WhatsNewModal';
import config from '../config';
interface AppGuardProps {
children: React.ReactNode;
}
export const AppGuard: React.FC<AppGuardProps> = ({ children }) => {
// This hook handles OAuth tokens, version checks, and returns theme state.
const { isDarkMode } = useAppInitialization();
const { isModalOpen, closeModal } = useModal();
const handleCloseWhatsNew = useCallback(() => closeModal('whatsNew'), [closeModal]);
const appVersion = config.app.version;
const commitMessage = config.app.commitMessage;
return (
<div className="bg-gray-100 dark:bg-gray-950 min-h-screen font-sans text-gray-800 dark:text-gray-200">
{/* Toaster component for displaying notifications. It's placed at the top level. */}
<Toaster position="top-center" reverseOrder={false} />
{/* Add CSS variables for toast theming based on dark mode */}
<style>{`
:root {
--toast-bg: ${isDarkMode ? '#4B5563' : '#FFFFFF'};
--toast-color: ${isDarkMode ? '#F9FAFB' : '#1F2937'};
}
`}</style>
{appVersion && commitMessage && (
<WhatsNewModal
isOpen={isModalOpen('whatsNew')}
onClose={handleCloseWhatsNew}
version={appVersion}
commitMessage={commitMessage}
/>
)}
{children}
</div>
);
};

View File

@@ -0,0 +1,93 @@
// src/components/PasswordInput.test.tsx
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { PasswordInput } from './PasswordInput';
// Mock the child PasswordStrengthIndicator component to isolate the test (relative to new location)
vi.mock('./PasswordStrengthIndicator', () => ({
PasswordStrengthIndicator: ({ password }: { password?: string }) => (
<div data-testid="strength-indicator">{`Strength for: ${password}`}</div>
),
}));
describe('PasswordInput (in auth feature)', () => {
it('should render as a password input by default', () => {
render(<PasswordInput placeholder="Enter password" />);
const input = screen.getByPlaceholderText('Enter password');
expect(input).toHaveAttribute('type', 'password');
});
it('should toggle input type between password and text when the eye icon is clicked', () => {
render(<PasswordInput placeholder="Enter password" />);
const input = screen.getByPlaceholderText('Enter password');
const toggleButton = screen.getByRole('button', { name: /show password/i });
// Initial state
expect(input).toHaveAttribute('type', 'password');
// Click to show
fireEvent.click(toggleButton);
expect(input).toHaveAttribute('type', 'text');
expect(toggleButton).toHaveAttribute('aria-label', 'Hide password');
// Click to hide again
fireEvent.click(toggleButton);
expect(input).toHaveAttribute('type', 'password');
expect(toggleButton).toHaveAttribute('aria-label', 'Show password');
});
it('should pass through standard input attributes', () => {
const handleChange = vi.fn();
render(
<PasswordInput
value="test"
onChange={handleChange}
placeholder="Enter password"
className="extra-class"
/>,
);
const input = screen.getByPlaceholderText('Enter password');
expect(input).toHaveValue('test');
expect(input).toHaveClass('extra-class');
fireEvent.change(input, { target: { value: 'new value' } });
expect(handleChange).toHaveBeenCalledTimes(1);
});
it('should not show strength indicator by default', () => {
render(<PasswordInput value="some-password" onChange={() => {}} />);
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
});
it('should show strength indicator when showStrength is true and there is a value', () => {
render(<PasswordInput value="some-password" showStrength onChange={() => {}} />);
const indicator = screen.getByTestId('strength-indicator');
expect(indicator).toBeInTheDocument();
expect(indicator).toHaveTextContent('Strength for: some-password');
});
it('should not show strength indicator when showStrength is true but value is empty', () => {
render(<PasswordInput value="" showStrength onChange={() => {}} />);
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
});
it('should handle undefined className gracefully', () => {
render(<PasswordInput placeholder="No class" />);
const input = screen.getByPlaceholderText('No class');
expect(input.className).not.toContain('undefined');
expect(input.className).toContain('block w-full');
});
it('should not show strength indicator if value is undefined', () => {
render(<PasswordInput showStrength onChange={() => {}} />);
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
});
it('should not show strength indicator if value is not a string', () => {
// Force a non-string value to test the typeof check
const props = { value: 12345, showStrength: true, onChange: () => {} } as any;
render(<PasswordInput {...props} />);
expect(screen.queryByTestId('strength-indicator')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,54 @@
// src/pages/admin/components/PasswordInput.tsx
// src/components/PasswordInput.tsx
import React, { useState } from 'react';
import { EyeIcon } from './icons/EyeIcon';
import { EyeSlashIcon } from './icons/EyeSlashIcon';
import { EyeIcon } from './icons/EyeIcon';
import { EyeSlashIcon } from './icons/EyeSlashIcon';
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
/**
* Props for the PasswordInput component.
* It extends standard HTML input attributes and adds a custom prop to show a strength indicator.
*/
interface PasswordInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
showStrength?: boolean;
}
/**
* A reusable password input component with a show/hide toggle
* and an optional password strength indicator.
*/
export const PasswordInput: React.FC<PasswordInputProps> = ({
showStrength = false,
className,
...props
}) => {
const [showPassword, setShowPassword] = useState(false);
return (
<div>
<div className="relative">
<input
{...props}
type={showPassword ? 'text' : 'password'}
// Combine passed classNames with default styling
className={`block w-full px-3 py-2 pr-10 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-brand-primary focus:border-brand-primary sm:text-sm ${className || ''}`}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
aria-label={showPassword ? 'Hide password' : 'Show password'}
>
{showPassword ? <EyeSlashIcon className="h-5 w-5" /> : <EyeIcon className="h-5 w-5" />}
</button>
</div>
{showStrength && typeof props.value === 'string' && props.value.length > 0 && (
<div className="pt-2">
<PasswordStrengthIndicator password={props.value} />
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,109 @@
// src/pages/admin/components/PasswordStrengthIndicator.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, type Mock } from 'vitest';
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
import zxcvbn from 'zxcvbn';
// Mock the zxcvbn library to control its output for testing
vi.mock('zxcvbn');
describe('PasswordStrengthIndicator', () => {
it('should render 5 gray bars when no password is provided', () => {
(zxcvbn as Mock).mockReturnValue({ score: -1, feedback: { warning: '', suggestions: [] } });
const { container } = render(<PasswordStrengthIndicator password="" />);
const bars = container.querySelectorAll('.h-1\\.5');
expect(bars).toHaveLength(5);
bars.forEach((bar) => {
expect(bar).toHaveClass('bg-gray-200');
});
expect(screen.queryByText(/Very Weak/i)).not.toBeInTheDocument();
});
it.each([
{ score: 0, label: 'Very Weak', color: 'bg-red-500', bars: 1 },
{ score: 1, label: 'Weak', color: 'bg-red-500', bars: 2 },
{ score: 2, label: 'Fair', color: 'bg-orange-500', bars: 3 },
{ score: 3, label: 'Good', color: 'bg-yellow-500', bars: 4 },
{ score: 4, label: 'Strong', color: 'bg-green-500', bars: 5 },
])('should render correctly for score $score ($label)', ({ score, label, color, bars }) => {
(zxcvbn as Mock).mockReturnValue({ score, feedback: { warning: '', suggestions: [] } });
const { container } = render(<PasswordStrengthIndicator password="some-password" />);
// Check the label
expect(screen.getByText(label)).toBeInTheDocument();
// Check the bar colors
const barElements = container.querySelectorAll('.h-1\\.5');
expect(barElements).toHaveLength(5);
for (let i = 0; i < 5; i++) {
if (i < bars) {
expect(barElements[i]).toHaveClass(color);
} else {
expect(barElements[i]).toHaveClass('bg-gray-200');
}
}
});
it('should display a warning from zxcvbn', () => {
(zxcvbn as Mock).mockReturnValue({
score: 1,
feedback: {
warning: 'This is a very common password',
suggestions: [],
},
});
render(<PasswordStrengthIndicator password="password" />);
expect(screen.getByText(/this is a very common password/i)).toBeInTheDocument();
});
it('should display a suggestion from zxcvbn', () => {
(zxcvbn as Mock).mockReturnValue({
score: 1,
feedback: {
warning: '',
suggestions: ['Add another word or two'],
},
});
render(<PasswordStrengthIndicator password="pass" />);
expect(screen.getByText(/add another word or two/i)).toBeInTheDocument();
});
it('should prioritize warning over suggestions', () => {
(zxcvbn as Mock).mockReturnValue({
score: 1,
feedback: { warning: 'A warning here', suggestions: ['A suggestion here'] },
});
render(<PasswordStrengthIndicator password="password" />);
expect(screen.getByText(/a warning here/i)).toBeInTheDocument();
expect(screen.queryByText(/a suggestion here/i)).not.toBeInTheDocument();
});
it('should use default empty string if password prop is undefined', () => {
(zxcvbn as Mock).mockReturnValue({ score: 0, feedback: { warning: '', suggestions: [] } });
const { container } = render(<PasswordStrengthIndicator />);
const bars = container.querySelectorAll('.h-1\\.5');
expect(bars).toHaveLength(5);
bars.forEach((bar) => {
expect(bar).toHaveClass('bg-gray-200');
});
expect(screen.queryByText(/Very Weak/i)).not.toBeInTheDocument();
});
it('should handle out-of-range scores gracefully (defensive)', () => {
// Mock a score that isn't 0-4 to hit default switch cases
(zxcvbn as Mock).mockReturnValue({ score: 99, feedback: { warning: '', suggestions: [] } });
const { container } = render(<PasswordStrengthIndicator password="test" />);
// Check bars - should hit default case in getBarColor which returns gray
const bars = container.querySelectorAll('.h-1\\.5');
bars.forEach((bar) => {
expect(bar).toHaveClass('bg-gray-200');
});
// Check label - should hit default case in getStrengthLabel which returns empty string
const labelSpan = container.querySelector('span.font-bold');
expect(labelSpan).toHaveTextContent('');
});
});

View File

@@ -0,0 +1,93 @@
// src/pages/admin/components/PasswordStrengthIndicator.tsx
// src/components/PasswordStrengthIndicator.tsx
import React from 'react';
import zxcvbn from 'zxcvbn';
interface PasswordStrengthIndicatorProps {
password?: string;
}
/**
* A component that visually indicates the strength of a password using zxcvbn.
* It displays a colored bar and provides feedback to the user.
*/
export const PasswordStrengthIndicator: React.FC<PasswordStrengthIndicatorProps> = ({
password = '',
}) => {
const result = zxcvbn(password);
const score = result.score; // Score from 0 (worst) to 4 (best)
// Function to determine the color of each segment of the strength bar
const getBarColor = (index: number) => {
if (password.length === 0) return 'bg-gray-200 dark:bg-gray-600';
if (index > score) return 'bg-gray-200 dark:bg-gray-600';
switch (score) {
case 0:
return 'bg-red-500';
case 1:
return 'bg-red-500';
case 2:
return 'bg-orange-500';
case 3:
return 'bg-yellow-500';
case 4:
return 'bg-green-500';
default:
return 'bg-gray-200 dark:bg-gray-600';
}
};
// Function to get a human-readable strength label
const getStrengthLabel = () => {
switch (score) {
case 0:
return 'Very Weak';
case 1:
return 'Weak';
case 2:
return 'Fair';
case 3:
return 'Good';
case 4:
return 'Strong';
default:
return '';
}
};
return (
<div className="mt-2 space-y-1">
<div className="flex space-x-1">
{/* Create 5 segments for the strength bar */}
{Array.from(Array(5).keys()).map((index) => (
<div
key={index}
className={`h-1.5 flex-1 rounded-full ${getBarColor(index)} transition-colors`}
></div>
))}
</div>
{password.length > 0 && (
<div className="flex justify-between items-center text-xs">
<span
className={`font-bold ${
score < 2 ? 'text-red-500' : score < 3 ? 'text-orange-500' : 'text-green-500'
}`}
>
{getStrengthLabel()}
</span>
{/* Display feedback from zxcvbn if available */}
{(result.feedback.warning || result.feedback.suggestions.length > 0) && (
<span className="text-gray-500 dark:text-gray-400 text-right">
{/* Prioritize the warning over suggestions. */}
{result.feedback.warning ? (
<span>{result.feedback.warning}</span>
) : (
<span>{result.feedback.suggestions[0]}</span>
)}
</span>
)}
</div>
)}
</div>
);
};