Compare commits

..

8 Commits

Author SHA1 Message Date
Gitea Actions
4e927f48bd ci: Bump version to 0.2.10 [skip ci] 2025-12-28 11:55:35 +05:00
af5644d17a add backoffs etc
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m40s
2025-12-27 22:54:51 -08:00
Gitea Actions
016c0a883a ci: Bump version to 0.2.9 [skip ci] 2025-12-28 11:28:27 +05:00
c6a5f889b4 unit test fixes
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 13m12s
2025-12-27 22:27:39 -08:00
Gitea Actions
c895ecdb28 ci: Bump version to 0.2.8 [skip ci] 2025-12-28 10:30:44 +05:00
05e3f8a61c minor fix
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 12m10s
2025-12-27 21:29:37 -08:00
Gitea Actions
f79a2abc65 ci: Bump version to 0.2.7 [skip ci] 2025-12-28 10:17:24 +05:00
a726c270bb 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
2025-12-27 21:13:15 -08:00
24 changed files with 569 additions and 447 deletions

View File

@@ -90,10 +90,11 @@ jobs:
# integration test suite can launch its own, fresh server instance.
# '|| true' ensures the workflow doesn't fail if the process isn't running.
run: |
pm2 stop flyer-crawler-api-test || true
pm2 stop flyer-crawler-worker-test || true
pm2 delete flyer-crawler-api-test || true
pm2 delete flyer-crawler-worker-test || true
echo "--- Stopping and deleting all test processes ---"
# Use a script to parse pm2's JSON output and delete any process whose name ends with '-test'.
# This is safer than 'pm2 delete all' and more robust than naming each process individually.
# It prevents the accumulation of duplicate processes from previous test runs.
node -e "const exec = require('child_process').execSync; try { const list = JSON.parse(exec('pm2 jlist').toString()); list.forEach(p => { if (p.name && p.name.endsWith('-test')) { console.log('Deleting test process: ' + p.name + ' (' + p.pm2_env.pm_id + ')'); try { exec('pm2 delete ' + p.pm2_env.pm_id); } catch(e) { console.error('Failed to delete ' + p.pm2_env.pm_id, e.message); } } }); console.log('✅ Test process cleanup complete.'); } catch (e) { if (e.stdout.toString().includes('No process found')) { console.log('No PM2 processes running, cleanup not needed.'); } else { console.error('Error cleaning up test processes:', e.message); } }" || true
- name: Run All Tests and Generate Merged Coverage Report
# This single step runs both unit and integration tests, then merges their

View File

@@ -7,19 +7,21 @@ module.exports = {
apps: [
{
// --- API Server ---
// The name is now dynamically set based on the environment.
// This is a common pattern but requires you to call pm2 with the correct name.
// The deploy script handles this by using 'flyer-crawler-api' for prod and 'flyer-crawler-api-test' for test.
name: 'flyer-crawler-api',
script: './node_modules/.bin/tsx',
args: 'server.ts', // tsx will execute this file
max_memory_restart: '500M', // Restart if memory usage exceeds 500MB
args: 'server.ts',
max_memory_restart: '500M',
// Restart Logic
max_restarts: 40,
exp_backoff_restart_delay: 100,
min_uptime: '10s',
// Production Environment Settings
env_production: {
NODE_ENV: 'production', // Set the Node.js environment to production
NODE_ENV: 'production',
name: 'flyer-crawler-api',
cwd: '/var/www/flyer-crawler.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -39,10 +41,9 @@ module.exports = {
},
// Test Environment Settings
env_test: {
NODE_ENV: 'test', // Set to 'test' to match the environment purpose and disable pino-pretty
NODE_ENV: 'test',
name: 'flyer-crawler-api-test',
cwd: '/var/www/flyer-crawler-test.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -66,7 +67,6 @@ module.exports = {
name: 'flyer-crawler-api-dev',
watch: true,
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -89,14 +89,19 @@ module.exports = {
// --- General Worker ---
name: 'flyer-crawler-worker',
script: './node_modules/.bin/tsx',
args: 'src/services/worker.ts', // tsx will execute this file
max_memory_restart: '1G', // Restart if memory usage exceeds 1GB
args: 'src/services/worker.ts',
max_memory_restart: '1G',
// Restart Logic
max_restarts: 40,
exp_backoff_restart_delay: 100,
min_uptime: '10s',
// Production Environment Settings
env_production: {
NODE_ENV: 'production',
name: 'flyer-crawler-worker',
cwd: '/var/www/flyer-crawler.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -119,7 +124,6 @@ module.exports = {
NODE_ENV: 'test',
name: 'flyer-crawler-worker-test',
cwd: '/var/www/flyer-crawler-test.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -143,7 +147,6 @@ module.exports = {
name: 'flyer-crawler-worker-dev',
watch: true,
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -166,14 +169,19 @@ module.exports = {
// --- Analytics Worker ---
name: 'flyer-crawler-analytics-worker',
script: './node_modules/.bin/tsx',
args: 'src/services/worker.ts', // tsx will execute this file
max_memory_restart: '1G', // Restart if memory usage exceeds 1GB
args: 'src/services/worker.ts',
max_memory_restart: '1G',
// Restart Logic
max_restarts: 40,
exp_backoff_restart_delay: 100,
min_uptime: '10s',
// Production Environment Settings
env_production: {
NODE_ENV: 'production',
name: 'flyer-crawler-analytics-worker',
cwd: '/var/www/flyer-crawler.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -196,7 +204,6 @@ module.exports = {
NODE_ENV: 'test',
name: 'flyer-crawler-analytics-worker-test',
cwd: '/var/www/flyer-crawler-test.projectium.com',
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,
@@ -220,7 +227,6 @@ module.exports = {
name: 'flyer-crawler-analytics-worker-dev',
watch: true,
ignore_watch: ['node_modules', 'logs', '*.log', 'flyer-images', '.git'],
// Inherit secrets from the deployment environment
DB_HOST: process.env.DB_HOST,
DB_USER: process.env.DB_USER,
DB_PASSWORD: process.env.DB_PASSWORD,

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.2.6",
"version": "0.2.10",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.2.6",
"version": "0.2.10",
"dependencies": {
"@bull-board/api": "^6.14.2",
"@bull-board/express": "^6.14.2",

View File

@@ -1,7 +1,7 @@
{
"name": "flyer-crawler",
"private": true,
"version": "0.2.6",
"version": "0.2.10",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -20,6 +20,7 @@ import {
mockUseUserData,
mockUseFlyerItems,
} from './tests/setup/mockHooks';
import { useAppInitialization } from './hooks/useAppInitialization';
// Mock top-level components rendered by App's routes
@@ -52,6 +53,9 @@ vi.mock('./hooks/useFlyerItems', async () => {
return { useFlyerItems: hooks.mockUseFlyerItems };
});
vi.mock('./hooks/useAppInitialization');
const mockedUseAppInitialization = vi.mocked(useAppInitialization);
vi.mock('./hooks/useAuth', async () => {
const hooks = await import('./tests/setup/mockHooks');
return { useAuth: hooks.mockUseAuth };
@@ -122,7 +126,23 @@ vi.mock('./layouts/MainLayout', async () => {
return { MainLayout: MockMainLayout };
});
const mockedAiApiClient = vi.mocked(aiApiClient); // Mock aiApiClient
vi.mock('./components/AppGuard', async () => {
// We need to use the real useModal hook inside our mock AppGuard
const { useModal } = await vi.importActual<typeof import('./hooks/useModal')>('./hooks/useModal');
return {
AppGuard: ({ children }: { children: React.ReactNode }) => {
const { isModalOpen } = useModal();
return (
<div data-testid="app-guard-mock">
{children}
{isModalOpen('whatsNew') && <div data-testid="whats-new-modal-mock" />}
</div>
);
},
};
});
const mockedAiApiClient = vi.mocked(aiApiClient);
const mockedApiClient = vi.mocked(apiClient);
const mockFlyers: Flyer[] = [
@@ -131,33 +151,6 @@ const mockFlyers: Flyer[] = [
];
describe('App Component', () => {
// Mock localStorage
let storage: { [key: string]: string } = {};
const localStorageMock = {
getItem: vi.fn((key: string) => storage[key] || null),
setItem: vi.fn((key: string, value: string) => {
storage[key] = value;
}),
removeItem: vi.fn((key: string) => {
delete storage[key];
}),
clear: vi.fn(() => {
storage = {};
}),
};
// Mock matchMedia
const matchMediaMock = vi.fn().mockImplementation((query) => ({
matches: false, // Default to light mode
media: query,
onchange: null,
addListener: vi.fn(), // deprecated
removeListener: vi.fn(), // deprecated
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
beforeEach(() => {
console.log('[TEST DEBUG] beforeEach: Clearing mocks and setting up defaults');
vi.clearAllMocks();
@@ -205,11 +198,9 @@ describe('App Component', () => {
mockUseFlyerItems.mockReturnValue({
flyerItems: [],
isLoading: false,
error: null,
});
// Clear local storage to prevent state from leaking between tests.
localStorage.clear();
Object.defineProperty(window, 'localStorage', { value: localStorageMock, configurable: true });
Object.defineProperty(window, 'matchMedia', { value: matchMediaMock, configurable: true });
mockedUseAppInitialization.mockReturnValue({ isDarkMode: false, unitSystem: 'imperial' });
// Default mocks for API calls
// Use mockImplementation to create a new Response object for each call,
@@ -261,6 +252,7 @@ describe('App Component', () => {
it('should render the main layout and header', async () => {
// Simulate the auth hook finishing its initial check
mockedUseAppInitialization.mockReturnValue({ isDarkMode: false, unitSystem: 'imperial' });
mockUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
@@ -272,6 +264,7 @@ describe('App Component', () => {
renderApp();
await waitFor(() => {
expect(screen.getByTestId('app-guard-mock')).toBeInTheDocument();
expect(screen.getByTestId('header-mock')).toBeInTheDocument();
// Check that the main layout and home page are rendered for the root path
expect(screen.getByTestId('main-layout-mock')).toBeInTheDocument();
@@ -364,193 +357,6 @@ describe('App Component', () => {
});
});
describe('Theme and Unit System Synchronization', () => {
it('should set dark mode based on user profile preferences', async () => {
console.log(
'[TEST DEBUG] Test Start: should set dark mode based on user profile preferences',
);
const profileWithDarkMode: UserProfile = createMockUserProfile({
user: createMockUser({ user_id: 'user-1', email: 'dark@mode.com' }),
role: 'user',
points: 0,
preferences: { darkMode: true },
});
mockUseAuth.mockReturnValue({
userProfile: profileWithDarkMode,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
console.log('[TEST DEBUG] Rendering App');
renderApp();
// The useEffect that sets the theme is asynchronous. We must wait for the update.
await waitFor(() => {
console.log(
'[TEST DEBUG] Checking for dark class. Current classes:',
document.documentElement.className,
);
expect(document.documentElement).toHaveClass('dark');
});
});
it('should set light mode based on user profile preferences', async () => {
const profileWithLightMode: UserProfile = createMockUserProfile({
user: createMockUser({ user_id: 'user-1', email: 'light@mode.com' }),
role: 'user',
points: 0,
preferences: { darkMode: false },
});
mockUseAuth.mockReturnValue({
userProfile: profileWithLightMode,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
renderApp();
await waitFor(() => {
expect(document.documentElement).not.toHaveClass('dark');
});
});
it('should set dark mode based on localStorage if profile has no preference', async () => {
localStorageMock.setItem('darkMode', 'true');
renderApp();
await waitFor(() => {
expect(document.documentElement).toHaveClass('dark');
});
});
it('should set dark mode based on system preference if no other setting exists', async () => {
matchMediaMock.mockImplementationOnce((query) => ({ matches: true, media: query }));
renderApp();
await waitFor(() => {
expect(document.documentElement).toHaveClass('dark');
});
});
it('should set unit system based on user profile preferences', async () => {
const profileWithMetric: UserProfile = createMockUserProfile({
user: createMockUser({ user_id: 'user-1', email: 'metric@user.com' }),
role: 'user',
points: 0,
preferences: { unitSystem: 'metric' },
});
mockUseAuth.mockReturnValue({
userProfile: profileWithMetric,
authStatus: 'AUTHENTICATED',
isLoading: false,
login: vi.fn(),
logout: vi.fn(),
updateProfile: vi.fn(),
});
renderApp();
// The unit system is passed as a prop to Header, which is mocked.
// We can't directly see the result in the DOM easily, so we trust the state is set.
// A more integrated test would be needed to verify the Header receives the prop.
// For now, this test ensures the useEffect logic runs without crashing.
await waitFor(() => {
expect(screen.getByTestId('header-mock')).toBeInTheDocument();
});
});
});
describe('OAuth Token Handling', () => {
it('should call login when a googleAuthToken is in the URL', async () => {
console.log(
'[TEST DEBUG] Test Start: should call login when a googleAuthToken is in the URL',
);
const mockLogin = vi.fn().mockResolvedValue(undefined);
mockUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: mockLogin,
logout: vi.fn(),
updateProfile: vi.fn(),
});
console.log('[TEST DEBUG] Rendering App with googleAuthToken');
renderApp(['/?googleAuthToken=test-google-token']);
await waitFor(() => {
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
expect(mockLogin).toHaveBeenCalledWith('test-google-token');
});
});
it('should call login when a githubAuthToken is in the URL', async () => {
console.log(
'[TEST DEBUG] Test Start: should call login when a githubAuthToken is in the URL',
);
const mockLogin = vi.fn().mockResolvedValue(undefined);
mockUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: mockLogin,
logout: vi.fn(),
updateProfile: vi.fn(),
});
console.log('[TEST DEBUG] Rendering App with githubAuthToken');
renderApp(['/?githubAuthToken=test-github-token']);
await waitFor(() => {
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
expect(mockLogin).toHaveBeenCalledWith('test-github-token');
});
});
it('should log an error if login with a GitHub token fails', async () => {
console.log(
'[TEST DEBUG] Test Start: should log an error if login with a GitHub token fails',
);
const mockLogin = vi.fn().mockRejectedValue(new Error('GitHub login failed'));
mockUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: mockLogin,
logout: vi.fn(),
updateProfile: vi.fn(),
});
console.log('[TEST DEBUG] Rendering App with githubAuthToken');
renderApp(['/?githubAuthToken=bad-token']);
await waitFor(() => {
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
expect(mockLogin).toHaveBeenCalled();
});
});
it('should log an error if login with a token fails', async () => {
console.log('[TEST DEBUG] Test Start: should log an error if login with a token fails');
const mockLogin = vi.fn().mockRejectedValue(new Error('Token login failed'));
mockUseAuth.mockReturnValue({
userProfile: null,
authStatus: 'SIGNED_OUT',
isLoading: false,
login: mockLogin,
logout: vi.fn(),
updateProfile: vi.fn(),
});
console.log('[TEST DEBUG] Rendering App with googleAuthToken');
renderApp(['/?googleAuthToken=bad-token']);
await waitFor(() => {
console.log('[TEST DEBUG] Checking mockLogin calls:', mockLogin.mock.calls);
expect(mockLogin).toHaveBeenCalled();
});
});
});
describe('Flyer Selection from URL', () => {
it('should select a flyer when flyerId is present in the URL', async () => {
renderApp(['/flyers/2']);
@@ -583,23 +389,9 @@ describe('App Component', () => {
});
});
describe('Version and "What\'s New" Modal', () => {
it('should show the "What\'s New" modal if the app version is new', async () => {
// Mock the config module for this specific test
vi.mock('./config', () => ({
default: {
app: { version: '20250101-1200:abc1234:1.0.1', commitMessage: 'New feature!', commitUrl: '#' },
google: { mapsEmbedApiKey: 'mock-key' },
},
}));
localStorageMock.setItem('lastSeenVersion', '20250101-1200:abc1234:1.0.0');
renderApp();
await expect(screen.findByTestId('whats-new-modal-mock')).resolves.toBeInTheDocument();
});
});
describe('Modal Interactions', () => {
it('should open and close the ProfileManager modal', async () => {
console.log('[TEST DEBUG] Test Start: should open and close the ProfileManager modal');
renderApp();
expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument();
@@ -607,11 +399,13 @@ describe('App Component', () => {
fireEvent.click(screen.getByText('Open Profile'));
expect(await screen.findByTestId('profile-manager-mock')).toBeInTheDocument();
console.log('[TEST DEBUG] ProfileManager modal opened. Now closing...');
// Close modal
fireEvent.click(screen.getByText('Close Profile'));
await waitFor(() => {
expect(screen.queryByTestId('profile-manager-mock')).not.toBeInTheDocument();
});
console.log('[TEST DEBUG] ProfileManager modal closed.');
});
it('should open and close the VoiceAssistant modal for authenticated users', async () => {
@@ -636,7 +430,7 @@ describe('App Component', () => {
fireEvent.click(screen.getByText('Open Voice Assistant'));
console.log('[TEST DEBUG] Waiting for voice-assistant-mock');
expect(await screen.findByTestId('voice-assistant-mock')).toBeInTheDocument();
expect(await screen.findByTestId('voice-assistant-mock', {}, { timeout: 3000 })).toBeInTheDocument();
// Close modal
fireEvent.click(screen.getByText('Close Voice Assistant'));
@@ -735,64 +529,6 @@ describe('App Component', () => {
});
});
describe("Version Display and What's New", () => {
beforeEach(() => {
// Also mock the config module to reflect this change
vi.mock('./config', () => ({
default: {
app: {
version: '20250101-1200:abc1234:2.0.0',
commitMessage: 'A new version!',
commitUrl: 'http://example.com/commit/2.0.0',
},
google: { mapsEmbedApiKey: 'mock-key' },
},
}));
});
it('should display the version number and commit link', () => {
renderApp();
const versionLink = screen.getByText(`Version: 20250101-1200:abc1234:2.0.0`);
expect(versionLink).toBeInTheDocument();
expect(versionLink).toHaveAttribute('href', 'http://example.com/commit/2.0.0');
});
it('should open the "What\'s New" modal when the question mark icon is clicked', async () => {
// Pre-set the localStorage to prevent the modal from opening automatically
localStorageMock.setItem('lastSeenVersion', '20250101-1200:abc1234:2.0.0');
renderApp();
expect(screen.queryByTestId('whats-new-modal-mock')).not.toBeInTheDocument();
const openButton = await screen.findByTitle("Show what's new in this version");
fireEvent.click(openButton);
expect(await screen.findByTestId('whats-new-modal-mock')).toBeInTheDocument();
});
});
describe('Dynamic Toaster Styles', () => {
it('should render the correct CSS variables for toast styling in light mode', async () => {
renderApp();
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');
});
});
it('should render the correct CSS variables for toast styling in dark mode', async () => {
localStorageMock.setItem('darkMode', 'true');
renderApp();
await waitFor(() => {
const styleTag = document.querySelector('style');
expect(styleTag).not.toBeNull();
expect(styleTag!.innerHTML).toContain('--toast-bg: #4B5563');
});
});
});
describe('Profile and Login Handlers', () => {
it('should call updateProfile when handleProfileUpdate is triggered', async () => {
console.log(
@@ -841,12 +577,19 @@ describe('App Component', () => {
logout: vi.fn(),
updateProfile: vi.fn(),
});
// Mock the login function to simulate a successful login. Signature: (token, profile)
const mockLoginSuccess = vi.fn(async (_token: string, _profile?: UserProfile) => {
// Simulate fetching profile after login
const profileResponse = await mockedApiClient.getAuthenticatedUserProfile();
const userProfileData: UserProfile = await profileResponse.json();
mockUseAuth.mockReturnValue({ ...mockUseAuth(), userProfile: userProfileData, authStatus: 'AUTHENTICATED' });
});
console.log('[TEST DEBUG] Rendering App');
renderApp();
console.log('[TEST DEBUG] Opening Profile');
fireEvent.click(screen.getByText('Open Profile'));
const loginButton = await screen.findByText('Login');
const loginButton = await screen.findByRole('button', { name: 'Login' });
console.log('[TEST DEBUG] Clicking Login');
fireEvent.click(loginButton);
@@ -857,4 +600,33 @@ describe('App Component', () => {
});
});
});
describe("Version Display and What's New", () => {
beforeEach(() => {
vi.mock('./config', () => ({
default: {
app: {
version: '2.0.0',
commitMessage: 'A new version!',
commitUrl: 'http://example.com/commit/2.0.0',
},
},
}));
});
it('should display the version number and commit link', () => {
renderApp();
const versionLink = screen.getByText(`Version: 2.0.0`);
expect(versionLink).toBeInTheDocument();
expect(versionLink).toHaveAttribute('href', 'http://example.com/commit/2.0.0');
});
it('should open the "What\'s New" modal when the question mark icon is clicked', async () => {
renderApp();
const openButton = await screen.findByTitle("Show what's new in this version");
fireEvent.click(openButton);
// The mock AppGuard now renders the modal when it's open
expect(await screen.findByTestId('whats-new-modal-mock')).toBeInTheDocument();
});
});
});

View File

@@ -1,10 +1,9 @@
// src/App.tsx
import React, { useState, useCallback, useEffect } from 'react';
import { Routes, Route, useParams, useLocation, useNavigate } from 'react-router-dom';
import { Routes, Route, useParams } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';
import * as pdfjsLib from 'pdfjs-dist';
import { Footer } from './components/Footer'; // Assuming this is where your Footer component will live
import { Footer } from './components/Footer';
import { Header } from './components/Header';
import { logger } from './services/logger.client';
import type { Flyer, Profile, UserProfile } from './types';
@@ -16,16 +15,17 @@ import { CorrectionsPage } from './pages/admin/CorrectionsPage';
import { AdminStatsPage } from './pages/admin/AdminStatsPage';
import { ResetPasswordPage } from './pages/ResetPasswordPage';
import { VoiceLabPage } from './pages/VoiceLabPage';
import { WhatsNewModal } from './components/WhatsNewModal';
import { FlyerCorrectionTool } from './components/FlyerCorrectionTool';
import { QuestionMarkCircleIcon } from './components/icons/QuestionMarkCircleIcon';
import { useAuth } from './hooks/useAuth';
import { useFlyers } from './hooks/useFlyers'; // Assuming useFlyers fetches all flyers
import { useFlyerItems } from './hooks/useFlyerItems'; // Import the new hook for flyer items
import { useFlyers } from './hooks/useFlyers';
import { useFlyerItems } from './hooks/useFlyerItems';
import { useModal } from './hooks/useModal';
import { MainLayout } from './layouts/MainLayout';
import config from './config';
import { HomePage } from './pages/HomePage';
import { AppGuard } from './components/AppGuard';
import { useAppInitialization } from './hooks/useAppInitialization';
// pdf.js worker configuration
// This is crucial for allowing pdf.js to process PDFs in a separate thread, preventing the UI from freezing.
@@ -44,10 +44,12 @@ function App() {
const { flyers } = useFlyers();
const [selectedFlyer, setSelectedFlyer] = useState<Flyer | null>(null);
const { openModal, closeModal, isModalOpen } = useModal();
const location = useLocation();
const navigate = useNavigate();
const params = useParams<{ flyerId?: string }>();
// This hook now handles initialization effects (OAuth, version check, theme)
// and returns the theme/unit state needed by other components.
const { isDarkMode, unitSystem } = useAppInitialization();
// Debugging: Log renders to identify infinite loops
useEffect(() => {
if (process.env.NODE_ENV === 'test') {
@@ -57,14 +59,11 @@ function App() {
paramsFlyerId: params?.flyerId, // This was a duplicate, fixed.
authStatus,
profileId: userProfile?.user.user_id,
locationSearch: location.search,
});
}
});
const [isDarkMode, setIsDarkMode] = useState(false);
const { flyerItems } = useFlyerItems(selectedFlyer);
const [unitSystem, setUnitSystem] = useState<'metric' | 'imperial'>('imperial');
// Define modal handlers with useCallback at the top level to avoid Rules of Hooks violations
const handleOpenProfile = useCallback(() => openModal('profile'), [openModal]);
@@ -109,37 +108,6 @@ function App() {
// --- State Synchronization and Error Handling ---
// Effect to set initial theme based on user profile, local storage, or system preference
useEffect(() => {
if (process.env.NODE_ENV === 'test')
console.log('[App] Effect: Theme Update', { profileId: userProfile?.user.user_id });
if (userProfile && userProfile.preferences?.darkMode !== undefined) {
// Preference from DB
const dbDarkMode = userProfile.preferences.darkMode;
setIsDarkMode(dbDarkMode);
document.documentElement.classList.toggle('dark', dbDarkMode);
} else {
// Fallback to local storage or system preference
const savedMode = localStorage.getItem('darkMode');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const initialDarkMode = savedMode !== null ? savedMode === 'true' : prefersDark;
setIsDarkMode(initialDarkMode);
document.documentElement.classList.toggle('dark', initialDarkMode);
}
}, [userProfile?.preferences?.darkMode, userProfile?.user.user_id]);
// Effect to set initial unit system based on user profile or local storage
useEffect(() => {
if (userProfile && userProfile.preferences?.unitSystem) {
setUnitSystem(userProfile.preferences.unitSystem);
} else {
const savedSystem = localStorage.getItem('unitSystem') as 'metric' | 'imperial' | null;
if (savedSystem) {
setUnitSystem(savedSystem);
}
}
}, [userProfile?.preferences?.unitSystem, userProfile?.user.user_id]);
// This is the login handler that will be passed to the ProfileManager component.
const handleLoginSuccess = useCallback(
async (userProfile: UserProfile, token: string, _rememberMe: boolean) => {
@@ -157,36 +125,6 @@ function App() {
[login],
);
// Effect to handle the token from Google OAuth redirect
useEffect(() => {
const urlParams = new URLSearchParams(location.search);
const googleToken = urlParams.get('googleAuthToken');
if (googleToken) {
logger.info('Received Google Auth token from URL. Authenticating...');
// The login flow is now handled by the useAuth hook. We just need to trigger it.
// We pass only the token; the AuthProvider will fetch the user profile.
login(googleToken).catch((err) =>
logger.error('Failed to log in with Google token', { error: err }),
);
// Clean the token from the URL
navigate(location.pathname, { replace: true });
}
const githubToken = urlParams.get('githubAuthToken');
if (githubToken) {
logger.info('Received GitHub Auth token from URL. Authenticating...');
login(githubToken).catch((err) => {
logger.error('Failed to log in with GitHub token', { error: err });
// Optionally, redirect to a page with an error message
// navigate('/login?error=github_auth_failed');
});
// Clean the token from the URL
navigate(location.pathname, { replace: true });
}
}, [login, location.search, navigate, location.pathname]);
const handleFlyerSelect = useCallback(async (flyer: Flyer) => {
setSelectedFlyer(flyer);
}, []);
@@ -214,31 +152,10 @@ function App() {
// Read the application version injected at build time.
// This will only be available in the production build, not during local development.
const appVersion = config.app.version;
const commitMessage = config.app.commitMessage;
useEffect(() => {
if (appVersion) {
logger.info(`Application version: ${appVersion}`);
const lastSeenVersion = localStorage.getItem('lastSeenVersion');
// If the current version is new, show the "What's New" modal.
if (appVersion !== lastSeenVersion) {
openModal('whatsNew');
localStorage.setItem('lastSeenVersion', appVersion);
}
}
}, [appVersion]);
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>
// AppGuard now handles the main page wrapper, theme styles, and "What's New" modal
<AppGuard>
<Header
isDarkMode={isDarkMode}
unitSystem={unitSystem}
@@ -265,15 +182,6 @@ function App() {
/>
)}
{appVersion && commitMessage && (
<WhatsNewModal
isOpen={isModalOpen('whatsNew')}
onClose={handleCloseWhatsNew}
version={appVersion}
commitMessage={commitMessage}
/>
)}
{selectedFlyer && (
<FlyerCorrectionTool
isOpen={isModalOpen('correctionTool')}
@@ -345,7 +253,7 @@ function App() {
)}
<Footer />
</div>
</AppGuard>
);
}

View File

@@ -5,7 +5,7 @@ import { describe, it, expect, vi } from 'vitest';
import { AnonymousUserBanner } from './AnonymousUserBanner';
// Mock the icon to ensure it is rendered correctly
vi.mock('../../../components/icons/InformationCircleIcon', () => ({
vi.mock('./icons/InformationCircleIcon', () => ({
InformationCircleIcon: (props: React.SVGProps<SVGSVGElement>) => (
<svg data-testid="info-icon" {...props} />
),

View File

@@ -1,6 +1,6 @@
// src/pages/admin/components/AnonymousUserBanner.tsx
// src/components/AnonymousUserBanner.tsx
import React from 'react';
import { InformationCircleIcon } from '../../../components/icons/InformationCircleIcon';
import { InformationCircleIcon } from './icons/InformationCircleIcon';
interface AnonymousUserBannerProps {
/**

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

@@ -1,7 +1,7 @@
// src/pages/admin/components/PasswordInput.tsx
// src/components/PasswordInput.tsx
import React, { useState } from 'react';
import { EyeIcon } from '../../../components/icons/EyeIcon';
import { EyeSlashIcon } from '../../../components/icons/EyeSlashIcon';
import { EyeIcon } from './icons/EyeIcon';
import { EyeSlashIcon } from './icons/EyeSlashIcon';
import { PasswordStrengthIndicator } from './PasswordStrengthIndicator';
/**

View File

@@ -1,4 +1,5 @@
// src/pages/admin/components/PasswordStrengthIndicator.tsx
// src/components/PasswordStrengthIndicator.tsx
import React from 'react';
import zxcvbn from 'zxcvbn';

View File

@@ -0,0 +1,173 @@
// src/hooks/useAppInitialization.test.tsx
import { renderHook, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter, useNavigate } from 'react-router-dom';
import { useAppInitialization } from './useAppInitialization';
import { useAuth } from './useAuth';
import { useModal } from './useModal';
import { createMockUserProfile } from '../tests/utils/mockFactories';
// Mock dependencies
vi.mock('./useAuth');
vi.mock('./useModal');
vi.mock('react-router-dom', async (importOriginal) => {
const actual = await importOriginal<typeof import('react-router-dom')>();
return {
...actual,
useNavigate: vi.fn(),
};
});
vi.mock('../services/logger.client');
vi.mock('../config', () => ({
default: {
app: { version: '1.0.1' },
},
}));
const mockedUseAuth = vi.mocked(useAuth);
const mockedUseModal = vi.mocked(useModal);
const mockedUseNavigate = vi.mocked(useNavigate);
const mockLogin = vi.fn().mockResolvedValue(undefined);
const mockNavigate = vi.fn();
const mockOpenModal = vi.fn();
// Wrapper with MemoryRouter is needed because the hook uses useLocation and useNavigate
const wrapper = ({
children,
initialEntries = ['/'],
}: {
children: React.ReactNode;
initialEntries?: string[];
}) => <MemoryRouter initialEntries={initialEntries}>{children}</MemoryRouter>;
describe('useAppInitialization Hook', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedUseNavigate.mockReturnValue(mockNavigate);
mockedUseAuth.mockReturnValue({
userProfile: null,
login: mockLogin,
authStatus: 'SIGNED_OUT',
isLoading: false,
logout: vi.fn(),
updateProfile: vi.fn(),
});
mockedUseModal.mockReturnValue({
openModal: mockOpenModal,
closeModal: vi.fn(),
isModalOpen: vi.fn(),
});
// Mock localStorage
Object.defineProperty(window, 'localStorage', {
value: {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
},
writable: true,
});
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
value: vi.fn().mockImplementation((query) => ({
matches: false, // default to light mode
})),
writable: true,
});
});
it('should call login when googleAuthToken is in URL', async () => {
renderHook(() => useAppInitialization(), {
wrapper: (props) => wrapper({ ...props, initialEntries: ['/?googleAuthToken=test-token'] }),
});
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith('test-token');
});
});
it('should call login when githubAuthToken is in URL', async () => {
renderHook(() => useAppInitialization(), {
wrapper: (props) => wrapper({ ...props, initialEntries: ['/?githubAuthToken=test-token'] }),
});
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith('test-token');
});
});
it('should call navigate to clean the URL after processing a token', async () => {
renderHook(() => useAppInitialization(), {
wrapper: (props) => wrapper({ ...props, initialEntries: ['/some/path?googleAuthToken=test-token'] }),
});
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith('test-token');
});
expect(mockNavigate).toHaveBeenCalledWith('/some/path', { replace: true });
});
it("should open \"What's New\" modal if version is new", () => {
vi.spyOn(window.localStorage, 'getItem').mockReturnValue('1.0.0');
renderHook(() => useAppInitialization(), { wrapper });
expect(mockOpenModal).toHaveBeenCalledWith('whatsNew');
expect(window.localStorage.setItem).toHaveBeenCalledWith('lastSeenVersion', '1.0.1');
});
it("should not open \"What's New\" modal if version is the same", () => {
vi.spyOn(window.localStorage, 'getItem').mockReturnValue('1.0.1');
renderHook(() => useAppInitialization(), { wrapper });
expect(mockOpenModal).not.toHaveBeenCalled();
});
it('should set dark mode from user profile', async () => {
mockedUseAuth.mockReturnValue({
...mockedUseAuth(),
userProfile: createMockUserProfile({ preferences: { darkMode: true } }),
});
const { result } = renderHook(() => useAppInitialization(), { wrapper });
await waitFor(() => {
expect(result.current.isDarkMode).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
it('should set dark mode from localStorage', async () => {
vi.spyOn(window.localStorage, 'getItem').mockImplementation((key) =>
key === 'darkMode' ? 'true' : null,
);
const { result } = renderHook(() => useAppInitialization(), { wrapper });
await waitFor(() => {
expect(result.current.isDarkMode).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
it('should set dark mode from system preference', async () => {
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true } as any);
const { result } = renderHook(() => useAppInitialization(), { wrapper });
await waitFor(() => {
expect(result.current.isDarkMode).toBe(true);
expect(document.documentElement.classList.contains('dark')).toBe(true);
});
});
it('should set unit system from user profile', async () => {
mockedUseAuth.mockReturnValue({
...mockedUseAuth(),
userProfile: createMockUserProfile({ preferences: { unitSystem: 'metric' } }),
});
const { result } = renderHook(() => useAppInitialization(), { wrapper });
await waitFor(() => {
expect(result.current.unitSystem).toBe('metric');
});
});
it('should set unit system from localStorage', async () => {
vi.spyOn(window.localStorage, 'getItem').mockImplementation((key) =>
key === 'unitSystem' ? 'metric' : null,
);
const { result } = renderHook(() => useAppInitialization(), { wrapper });
await waitFor(() => {
expect(result.current.unitSystem).toBe('metric');
});
});
});

View File

@@ -0,0 +1,88 @@
// src/hooks/useAppInitialization.ts
import { useState, useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './useAuth';
import { useModal } from './useModal';
import { logger } from '../services/logger.client';
import config from '../config';
export const useAppInitialization = () => {
const { userProfile, login } = useAuth();
const { openModal } = useModal();
const location = useLocation();
const navigate = useNavigate();
const [isDarkMode, setIsDarkMode] = useState(false);
const [unitSystem, setUnitSystem] = useState<'metric' | 'imperial'>('imperial');
// Effect to handle the token from Google/GitHub OAuth redirect
useEffect(() => {
const urlParams = new URLSearchParams(location.search);
const googleToken = urlParams.get('googleAuthToken');
if (googleToken) {
logger.info('Received Google Auth token from URL. Authenticating...');
login(googleToken).catch((err) =>
logger.error('Failed to log in with Google token', { error: err }),
);
navigate(location.pathname, { replace: true });
}
const githubToken = urlParams.get('githubAuthToken');
if (githubToken) {
logger.info('Received GitHub Auth token from URL. Authenticating...');
login(githubToken).catch((err) => {
logger.error('Failed to log in with GitHub token', { error: err });
});
navigate(location.pathname, { replace: true });
}
}, [login, location.search, navigate, location.pathname]);
// Effect to handle "What's New" modal
useEffect(() => {
const appVersion = config.app.version;
if (appVersion) {
logger.info(`Application version: ${appVersion}`);
const lastSeenVersion = localStorage.getItem('lastSeenVersion');
if (appVersion !== lastSeenVersion) {
openModal('whatsNew');
localStorage.setItem('lastSeenVersion', appVersion);
}
}
}, [openModal]);
// Effect to set initial theme based on user profile, local storage, or system preference
useEffect(() => {
let darkModeValue: boolean;
if (userProfile && userProfile.preferences?.darkMode !== undefined) {
// Preference from DB
darkModeValue = userProfile.preferences.darkMode;
} else {
// Fallback to local storage or system preference
const savedMode = localStorage.getItem('darkMode');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
darkModeValue = savedMode !== null ? savedMode === 'true' : prefersDark;
}
setIsDarkMode(darkModeValue);
document.documentElement.classList.toggle('dark', darkModeValue);
// Also save to local storage if coming from profile, to persist on logout
if (userProfile && userProfile.preferences?.darkMode !== undefined) {
localStorage.setItem('darkMode', String(userProfile.preferences.darkMode));
}
}, [userProfile]);
// Effect to set initial unit system based on user profile or local storage
useEffect(() => {
if (userProfile && userProfile.preferences?.unitSystem) {
setUnitSystem(userProfile.preferences.unitSystem);
localStorage.setItem('unitSystem', userProfile.preferences.unitSystem);
} else {
const savedSystem = localStorage.getItem('unitSystem') as 'metric' | 'imperial' | null;
if (savedSystem) {
setUnitSystem(savedSystem);
}
}
}, [userProfile?.preferences?.unitSystem, userProfile?.user.user_id]);
return { isDarkMode, unitSystem };
};

View File

@@ -79,7 +79,7 @@ vi.mock('../pages/admin/ActivityLog', async () => {
),
};
});
vi.mock('../pages/admin/components/AnonymousUserBanner', () => ({
vi.mock('../components/AnonymousUserBanner', () => ({
AnonymousUserBanner: () => <div data-testid="anonymous-banner" />,
}));
vi.mock('../components/ErrorDisplay', () => ({

View File

@@ -16,7 +16,7 @@ import { PriceChart } from '../features/charts/PriceChart';
import { PriceHistoryChart } from '../features/charts/PriceHistoryChart';
import Leaderboard from '../components/Leaderboard';
import { ActivityLog, ActivityLogClickHandler } from '../pages/admin/ActivityLog';
import { AnonymousUserBanner } from '../pages/admin/components/AnonymousUserBanner';
import { AnonymousUserBanner } from '../components/AnonymousUserBanner';
import { ErrorDisplay } from '../components/ErrorDisplay';
export interface MainLayoutProps {

View File

@@ -4,7 +4,7 @@ import { useParams, useNavigate, Link } from 'react-router-dom';
import * as apiClient from '../services/apiClient';
import { logger } from '../services/logger.client';
import { LoadingSpinner } from '../components/LoadingSpinner';
import { PasswordInput } from './admin/components/PasswordInput';
import { PasswordInput } from '../components/PasswordInput';
export const ResetPasswordPage: React.FC = () => {
const { token } = useParams<{ token: string }>();

View File

@@ -1,6 +1,6 @@
// src/pages/admin/components/AuthView.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { AuthView } from './AuthView';
import * as apiClient from '../../../services/apiClient';
@@ -12,6 +12,11 @@ const mockedApiClient = vi.mocked(apiClient, true);
const mockOnClose = vi.fn();
const mockOnLoginSuccess = vi.fn();
vi.mock('../../../components/PasswordInput', () => ({
// Mock the moved component
PasswordInput: (props: any) => <input {...props} data-testid="password-input" />,
}));
const defaultProps = {
onClose: mockOnClose,
onLoginSuccess: mockOnLoginSuccess,
@@ -353,4 +358,27 @@ describe('AuthView', () => {
expect(screen.queryByText('Send Reset Link')).not.toBeInTheDocument();
});
});
it('should show loading state during registration submission', async () => {
// Mock a promise that doesn't resolve immediately
(mockedApiClient.registerUser as Mock).mockReturnValue(new Promise(() => {}));
render(<AuthView {...defaultProps} />);
// Switch to registration view
fireEvent.click(screen.getByRole('button', { name: /don't have an account\? register/i }));
fireEvent.change(screen.getByLabelText(/email address/i), {
target: { value: 'test@example.com' },
});
fireEvent.change(screen.getByTestId('password-input'), { target: { value: 'password' } });
fireEvent.submit(screen.getByTestId('auth-form'));
await waitFor(() => {
const submitButton = screen.getByTestId('auth-form').querySelector('button[type="submit"]');
expect(submitButton).toBeInTheDocument();
expect(submitButton).toBeDisabled();
// Verify the text 'Register' is gone from any button
expect(screen.queryByRole('button', { name: 'Register' })).not.toBeInTheDocument();
});
});
});

View File

@@ -7,7 +7,7 @@ import { notifySuccess } from '../../../services/notificationService';
import { LoadingSpinner } from '../../../components/LoadingSpinner';
import { GoogleIcon } from '../../../components/icons/GoogleIcon';
import { GithubIcon } from '../../../components/icons/GithubIcon';
import { PasswordInput } from './PasswordInput';
import { PasswordInput } from '../../../components/PasswordInput';
interface AuthResponse {
userprofile: UserProfile;

View File

@@ -1,7 +1,7 @@
// src/pages/admin/components/ProfileManager.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach, type Mock, test } from 'vitest';
import { ProfileManager } from './ProfileManager';
import * as apiClient from '../../../services/apiClient';
import { notifySuccess, notifyError } from '../../../services/notificationService';
@@ -16,6 +16,11 @@ import {
// Unmock the component to test the real implementation
vi.unmock('./ProfileManager');
vi.mock('../../../components/PasswordInput', () => ({
// Mock the moved component
PasswordInput: (props: any) => <input {...props} data-testid="password-input" />,
}));
const mockedApiClient = vi.mocked(apiClient, true);
vi.mock('../../../services/notificationService');
@@ -537,7 +542,7 @@ describe('ProfileManager', () => {
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
target: { value: 'short' },
});
fireEvent.submit(screen.getByTestId('update-password-form'));
fireEvent.submit(screen.getByTestId('update-password-form'), {});
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Password must be at least 6 characters long.');
@@ -551,7 +556,7 @@ describe('ProfileManager', () => {
fireEvent.click(screen.getByRole('button', { name: /data & privacy/i }));
fireEvent.click(screen.getByRole('button', { name: /delete my account/i }));
fireEvent.change(screen.getByPlaceholderText(/enter your password/i), {
fireEvent.change(screen.getByTestId('password-input'), {
target: { value: 'password' },
});
fireEvent.submit(screen.getByTestId('delete-account-form'));
@@ -688,7 +693,7 @@ describe('ProfileManager', () => {
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
target: { value: 'newpassword123' },
});
fireEvent.submit(screen.getByTestId('update-password-form'));
fireEvent.submit(screen.getByTestId('update-password-form'), {});
await waitFor(() => {
expect(mockedApiClient.updateUserPassword).toHaveBeenCalledWith(
@@ -709,7 +714,7 @@ describe('ProfileManager', () => {
fireEvent.change(screen.getByLabelText('Confirm New Password'), {
target: { value: 'mismatch' },
});
fireEvent.submit(screen.getByTestId('update-password-form'));
fireEvent.submit(screen.getByTestId('update-password-form'), {});
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Passwords do not match.');
@@ -750,7 +755,7 @@ describe('ProfileManager', () => {
).toBeInTheDocument();
// Fill password and submit to open modal
fireEvent.change(screen.getByPlaceholderText(/enter your password/i), {
fireEvent.change(screen.getByTestId('password-input'), {
target: { value: 'correctpassword' },
});
fireEvent.submit(screen.getByTestId('delete-account-form'));

View File

@@ -9,8 +9,8 @@ import { LoadingSpinner } from '../../../components/LoadingSpinner';
import { XMarkIcon } from '../../../components/icons/XMarkIcon';
import { GoogleIcon } from '../../../components/icons/GoogleIcon';
import { GithubIcon } from '../../../components/icons/GithubIcon';
import { ConfirmationModal } from '../../../components/ConfirmationModal';
import { PasswordInput } from './PasswordInput';
import { ConfirmationModal } from '../../../components/ConfirmationModal'; // This path is correct
import { PasswordInput } from '../../../components/PasswordInput';
import { MapView } from '../../../components/MapView';
import type { AuthStatus } from '../../../hooks/useAuth';
import { AuthView } from './AuthView';

View File

@@ -149,8 +149,8 @@ describe('User Routes (/api/users)', () => {
// Assert
expect(logger.error).toHaveBeenCalledWith(
{ err: mkdirError },
'Failed to create avatar upload directory',
{ error: mkdirError },
'Failed to create multer storage directories on startup.',
);
vi.doUnmock('node:fs/promises'); // Clean up
});