MORE UNIT TESTS - approc 94% before - 96% now?
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 50m37s

This commit is contained in:
2025-12-18 18:55:10 -08:00
parent 07df85f72f
commit ac304106f5
20 changed files with 876 additions and 72 deletions

View File

@@ -18,8 +18,21 @@ import type { ActivityLogItem, UserProfile } from '../types';
// Mock child components to simplify testing and focus on the layout's logic
vi.mock('../features/flyer/FlyerList', () => ({ FlyerList: () => <div data-testid="flyer-list" /> }));
vi.mock('../features/flyer/FlyerUploader', () => ({ FlyerUploader: () => <div data-testid="flyer-uploader" /> }));
vi.mock('../features/shopping/ShoppingList', () => ({ ShoppingListComponent: (props: { onSelectList: (id: number) => void }) => <div data-testid="shopping-list" onClick={() => props.onSelectList(2)} /> }));
vi.mock('../features/shopping/WatchedItemsList', () => ({ WatchedItemsList: () => <div data-testid="watched-items-list" /> }));
vi.mock('../features/shopping/ShoppingList', () => ({
ShoppingListComponent: (props: any) => (
<div data-testid="shopping-list">
<button onClick={() => props.onSelectList(2)}>Select List</button>
<button onClick={() => props.onAddItem({ customItemName: 'Test Item' })}>Add Item</button>
</div>
),
}));
vi.mock('../features/shopping/WatchedItemsList', () => ({
WatchedItemsList: (props: any) => (
<div data-testid="watched-items-list">
<button onClick={() => props.onAddItemToList(101)}>Add to List</button>
</div>
),
}));
vi.mock('../features/charts/PriceChart', () => ({ PriceChart: () => <div data-testid="price-chart" /> }));
vi.mock('../features/charts/PriceHistoryChart', () => ({ PriceHistoryChart: () => <div data-testid="price-history-chart" /> }));
vi.mock('../components/Leaderboard', () => ({ default: () => <div data-testid="leaderboard" /> }));
@@ -231,5 +244,61 @@ describe('MainLayout Component', () => {
expect(mockSetActiveListId).not.toHaveBeenCalled();
});
it('calls addItemToList when an item is added from ShoppingListComponent and a list is active', () => {
const mockAddItemToList = vi.fn();
mockedUseShoppingLists.mockReturnValueOnce({
...defaultUseShoppingListsReturn,
activeListId: 1,
addItemToList: mockAddItemToList,
});
renderWithRouter(<MainLayout {...defaultProps} />);
fireEvent.click(screen.getByText('Add Item'));
expect(mockAddItemToList).toHaveBeenCalledWith(1, { customItemName: 'Test Item' });
});
it('does not call addItemToList from ShoppingListComponent if no list is active', () => {
const mockAddItemToList = vi.fn();
mockedUseShoppingLists.mockReturnValueOnce({
...defaultUseShoppingListsReturn,
activeListId: null,
addItemToList: mockAddItemToList,
});
renderWithRouter(<MainLayout {...defaultProps} />);
fireEvent.click(screen.getByText('Add Item'));
expect(mockAddItemToList).not.toHaveBeenCalled();
});
it('calls addItemToList when an item is added from WatchedItemsList and a list is active', () => {
const mockAddItemToList = vi.fn();
mockedUseShoppingLists.mockReturnValueOnce({
...defaultUseShoppingListsReturn,
activeListId: 5,
addItemToList: mockAddItemToList,
});
renderWithRouter(<MainLayout {...defaultProps} />);
fireEvent.click(screen.getByText('Add to List'));
expect(mockAddItemToList).toHaveBeenCalledWith(5, { masterItemId: 101 });
});
it('does not call addItemToList from WatchedItemsList if no list is active', () => {
const mockAddItemToList = vi.fn();
mockedUseShoppingLists.mockReturnValueOnce({
...defaultUseShoppingListsReturn,
activeListId: null,
addItemToList: mockAddItemToList,
});
renderWithRouter(<MainLayout {...defaultProps} />);
fireEvent.click(screen.getByText('Add to List'));
expect(mockAddItemToList).not.toHaveBeenCalled();
});
});
});

View File

@@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
import supertest from 'supertest';
import express, { Request, Response, NextFunction } from 'express';
import { errorHandler } from './errorHandler'; // This was a duplicate, fixed.
import { DatabaseError, ForeignKeyConstraintError, UniqueConstraintError, ValidationError } from '../services/db/errors.db';
import { DatabaseError, ForeignKeyConstraintError, UniqueConstraintError, ValidationError, NotFoundError } from '../services/db/errors.db';
import type { Logger } from 'pino';
// Create a mock logger that we can inject into requests and assert against.
@@ -47,6 +47,10 @@ app.get('/http-error-404', (req, res, next) => {
next(err);
});
app.get('/not-found-error', (req, res, next) => {
next(new NotFoundError('Specific resource missing'));
});
app.get('/fk-error', (req, res, next) => {
next(new ForeignKeyConstraintError('The referenced item does not exist.'));
});
@@ -131,6 +135,26 @@ describe('errorHandler Middleware', () => {
);
});
it('should handle a NotFoundError with a 404 status', async () => {
const response = await supertest(app).get('/not-found-error');
expect(response.status).toBe(404);
expect(response.body).toEqual({ message: 'Specific resource missing' });
expect(mockLogger.error).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalledWith(
{
err: expect.any(NotFoundError),
validationErrors: undefined,
statusCode: 404,
},
'Client Error on GET /not-found-error: Specific resource missing'
);
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('--- [TEST] UNHANDLED ERROR ---'),
expect.any(NotFoundError)
);
});
it('should handle a ForeignKeyConstraintError with a 400 status and the specific error message', async () => {
const response = await supertest(app).get('/fk-error');
@@ -212,6 +236,22 @@ describe('errorHandler Middleware', () => {
);
});
it('should handle an UnauthorizedError with default 401 status', async () => {
const response = await supertest(app).get('/unauthorized-error-no-status');
expect(response.status).toBe(401);
expect(response.body).toEqual({ message: 'Invalid Token' });
// 4xx errors log as warn
expect(mockLogger.warn).toHaveBeenCalled();
});
it('should handle an UnauthorizedError with explicit status', async () => {
const response = await supertest(app).get('/unauthorized-error-with-status');
expect(response.status).toBe(401);
expect(response.body).toEqual({ message: 'Invalid Token' });
});
it('should call next(err) if headers have already been sent', () => {
// Supertest doesn't easily allow simulating res.headersSent = true mid-request
// We need to mock the express response object directly for this specific test.
@@ -248,5 +288,12 @@ describe('errorHandler Middleware', () => {
expect(response.status).toBe(500);
expect(response.body.message).toMatch(/An unexpected server error occurred. Please reference error ID: \w+/);
});
it('should return the actual error message for client errors (4xx) in production', async () => {
const response = await supertest(app).get('/http-error-404');
expect(response.status).toBe(404);
expect(response.body.message).toBe('Resource not found');
});
});
});

View File

@@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import MyDealsPage from './MyDealsPage';
import * as apiClient from '../services/apiClient';
import { WatchedItemDeal } from '../types';
import { logger } from '../services/logger.client';
// Mock the apiClient. The component now directly uses `fetchBestSalePrices`.
// By mocking the entire module, we can control the behavior of `fetchBestSalePrices`
@@ -13,7 +14,7 @@ vi.mock('../services/apiClient');
const mockedApiClient = apiClient as Mocked<typeof apiClient>;
// Mock the logger
vi.mock('../services/logger', () => ({
vi.mock('../services/logger.client', () => ({
logger: {
error: vi.fn(),
},
@@ -47,6 +48,31 @@ describe('MyDealsPage', () => {
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('Failed to fetch deals. Please try again later.')).toBeInTheDocument();
});
expect(logger.error).toHaveBeenCalledWith('Error fetching watched item deals:', 'Failed to fetch deals. Please try again later.');
});
it('should handle network errors and log them', async () => {
const networkError = new Error('Network connection failed');
mockedApiClient.fetchBestSalePrices.mockRejectedValue(networkError);
render(<MyDealsPage />);
await waitFor(() => {
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('Network connection failed')).toBeInTheDocument();
});
expect(logger.error).toHaveBeenCalledWith('Error fetching watched item deals:', 'Network connection failed');
});
it('should handle unknown errors and log them', async () => {
// Mock a rejection with a non-Error object (e.g., a string) to trigger the fallback error message
mockedApiClient.fetchBestSalePrices.mockRejectedValue('Unknown failure');
render(<MyDealsPage />);
await waitFor(() => {
expect(screen.getByText('Error')).toBeInTheDocument();
expect(screen.getByText('An unknown error occurred.')).toBeInTheDocument();
});
expect(logger.error).toHaveBeenCalledWith('Error fetching watched item deals:', 'An unknown error occurred.');
});
it('should display a message when no deals are found', async () => {

View File

@@ -1,14 +1,22 @@
// src/pages/ResetPasswordPage.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { ResetPasswordPage } from './ResetPasswordPage';
import * as apiClient from '../services/apiClient';
import { logger } from '../services/logger.client';
// The apiClient and logger are now mocked globally.
const mockedApiClient = vi.mocked(apiClient);
vi.mock('../services/logger.client', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
// Helper function to render the component within a router context
const renderWithRouter = (token: string) => {
return render(
@@ -24,6 +32,11 @@ const renderWithRouter = (token: string) => {
describe('ResetPasswordPage', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('should render the form with password fields and a submit button', () => {
@@ -48,9 +61,16 @@ describe('ResetPasswordPage', () => {
expect(screen.getByText(/password reset was successful!/i)).toBeInTheDocument();
expect(screen.getByText(/return to home/i)).toBeInTheDocument();
});
expect(logger.info).toHaveBeenCalledWith('Password has been successfully reset.');
// Check that form is cleared
expect(screen.queryByPlaceholderText('New Password')).not.toBeInTheDocument();
// Test navigation after timeout
act(() => {
vi.advanceTimersByTime(4000);
});
expect(screen.getByText('Home Page')).toBeInTheDocument();
});
it('should show an error message if passwords do not match', async () => {
@@ -77,33 +97,61 @@ describe('ResetPasswordPage', () => {
await waitFor(() => {
expect(screen.getByText('Invalid or expired token.')).toBeInTheDocument();
});
expect(logger.error).toHaveBeenCalledWith({ err: expect.any(Error) }, 'Failed to reset password.');
});
it.todo('TODO: should show a loading spinner while submitting', () => {
// This test uses a manually-resolved promise pattern that is still causing test hangs and memory leaks.
// Disabling to get the pipeline passing.
});
/*
it('should show a loading spinner while submitting', async () => {
let resolvePromise: (value: Response) => void;
const mockPromise = new Promise<Response>(resolve => {
resolvePromise = resolve;
});
mockedApiClient.resetPassword.mockReturnValueOnce(mockPromise);
mockedApiClient.resetPassword.mockReturnValue(mockPromise);
renderWithRouter('test-token');
fireEvent.change(screen.getByPlaceholderText('New Password'), { target: { value: 'newSecurePassword123' } });
fireEvent.change(screen.getByPlaceholderText('Confirm New Password'), { target: { value: 'newSecurePassword123' } });
fireEvent.click(screen.getByRole('button', { name: /reset password/i }));
const button = await screen.findByRole('button', { name: /reset password/i });
expect(button).toBeDisabled();
expect(button.querySelector('svg')).toBeInTheDocument();
// Expect button to be disabled and text to be gone (replaced by spinner)
expect(screen.getByRole('button')).toBeDisabled();
expect(screen.queryByText('Reset Password')).not.toBeInTheDocument();
await act(async () => {
resolvePromise(new Response(JSON.stringify({ message: 'Success' })));
await mockPromise;
resolvePromise!(new Response(JSON.stringify({ message: 'Success' })));
});
await waitFor(() => {
expect(screen.getByText(/password reset was successful!/i)).toBeInTheDocument();
});
});
*/
it('should show an error if no token is provided', async () => {
render(
<MemoryRouter initialEntries={['/reset-password']}>
<Routes>
<Route path="/reset-password" element={<ResetPasswordPage />} />
</Routes>
</MemoryRouter>
);
fireEvent.click(screen.getByRole('button', { name: /reset password/i }));
await waitFor(() => {
expect(screen.getByText('No reset token provided. Please use the link from your email.')).toBeInTheDocument();
});
});
it('should handle unknown errors', async () => {
mockedApiClient.resetPassword.mockRejectedValue('Unknown error');
renderWithRouter('test-token');
fireEvent.change(screen.getByPlaceholderText('New Password'), { target: { value: 'newSecurePassword123' } });
fireEvent.change(screen.getByPlaceholderText('Confirm New Password'), { target: { value: 'newSecurePassword123' } });
fireEvent.click(screen.getByRole('button', { name: /reset password/i }));
await waitFor(() => {
expect(screen.getByText('An unknown error occurred.')).toBeInTheDocument();
});
expect(logger.error).toHaveBeenCalledWith({ err: 'Unknown error' }, 'Failed to reset password.');
});
});

View File

@@ -8,7 +8,12 @@ import { UserProfile, Achievement, UserAchievement } from '../types';
// Mock dependencies
vi.mock('../services/apiClient'); // This was correct
vi.mock('../services/logger');
vi.mock('../services/logger.client', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../services/notificationService');
vi.mock('../services/aiApiClient'); // Mock aiApiClient as it's used in the component
const mockedNotificationService = vi.mocked(await import('../services/notificationService'));
@@ -78,6 +83,16 @@ describe('UserProfilePage', () => {
});
});
it('should handle unknown errors during fetch', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue('Unknown error string');
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(mockAchievements)));
render(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByText('Error: An unknown error occurred.')).toBeInTheDocument();
});
});
it('should render the profile and achievements on successful fetch', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(new Response(JSON.stringify(mockProfile)));
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(mockAchievements)));
@@ -119,6 +134,36 @@ describe('UserProfilePage', () => {
});
});
it('should use email for avatar seed if full_name is missing', async () => {
const profileNoName = { ...mockProfile, full_name: null };
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(new Response(JSON.stringify(profileNoName)));
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(mockAchievements)));
render(<UserProfilePage />);
await waitFor(() => {
const avatar = screen.getByAltText('User Avatar');
// seed should be the email
expect(avatar.getAttribute('src')).toContain(`seed=${profileNoName.user.email}`);
});
});
it('should trigger file input click when avatar is clicked', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(new Response(JSON.stringify(mockProfile)));
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(mockAchievements)));
render(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const clickSpy = vi.spyOn(fileInput, 'click');
const avatarContainer = screen.getByAltText('User Avatar');
fireEvent.click(avatarContainer);
expect(clickSpy).toHaveBeenCalled();
});
describe('Name Editing', () => {
beforeEach(() => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(new Response(JSON.stringify(mockProfile)));
@@ -168,6 +213,21 @@ describe('UserProfilePage', () => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith('Validation failed');
});
});
it('should handle unknown errors when saving name', async () => {
mockedApiClient.updateUserProfile.mockRejectedValue('Unknown update error');
render(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const nameInput = screen.getByRole('textbox');
fireEvent.change(nameInput, { target: { value: 'New Name' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith('An unknown error occurred.');
});
});
});
describe('Avatar Upload', () => {
@@ -249,5 +309,19 @@ describe('UserProfilePage', () => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith('File too large');
});
});
it('should handle unknown errors when uploading avatar', async () => {
mockedApiClient.uploadAvatar.mockRejectedValue('Unknown upload error');
render(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'error.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith('An unknown error occurred.');
});
});
});
});

View File

@@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { VoiceLabPage } from './VoiceLabPage';
import * as aiApiClient from '../services/aiApiClient';
import { notifyError } from '../services/notificationService';
import { logger } from '../services/logger.client';
vi.mock('../services/notificationService');
@@ -13,6 +14,14 @@ vi.mock('../services/aiApiClient');
// 2. Get a typed reference to the mocked module to control its functions in tests.
const mockedAiApiClient = vi.mocked(aiApiClient);
// Mock the logger
vi.mock('../services/logger.client', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
},
}));
// Define mock at module level so it can be referenced in the implementation
const mockAudioPlay = vi.fn(() => {
console.log('[TEST MOCK] mockAudioPlay called');
@@ -128,6 +137,7 @@ describe('VoiceLabPage', () => {
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Speech generation failed: AI service is down');
});
expect(logger.error).toHaveBeenCalledWith({ err: expect.any(Error) }, 'Failed to generate speech:');
});
it('should show an error if API returns no audio data', async () => {
@@ -152,6 +162,7 @@ describe('VoiceLabPage', () => {
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Speech generation failed: An unknown error occurred.');
});
expect(logger.error).toHaveBeenCalledWith({ err: 'A simple string error' }, 'Failed to generate speech:');
});
it('should allow replaying the generated audio', async () => {
@@ -193,6 +204,38 @@ describe('VoiceLabPage', () => {
expect(mockedAiApiClient.startVoiceSession).toHaveBeenCalled();
expect(notifyError).toHaveBeenCalledWith('Could not start voice session: WebSocket proxy not implemented.');
});
expect(logger.error).toHaveBeenCalledWith({ err: expect.any(Error) }, 'Failed to start voice session:');
});
it('should handle unknown errors in startVoiceSession', async () => {
mockedAiApiClient.startVoiceSession.mockImplementation(() => {
throw 'Unknown session error';
});
render(<VoiceLabPage />);
fireEvent.click(screen.getByRole('button', { name: /attempt to start session/i }));
await waitFor(() => {
expect(notifyError).toHaveBeenCalledWith('Could not start voice session: An unknown error occurred.');
});
expect(logger.error).toHaveBeenCalledWith({ err: 'Unknown session error' }, 'Failed to start voice session:');
});
it('should handle successful startVoiceSession and log messages', async () => {
mockedAiApiClient.startVoiceSession.mockImplementation((config) => {
if (config && config.onmessage) {
// Cast to `any` as we don't need a full LiveServerMessage object for this test
config.onmessage('Test message from socket' as any);
}
});
render(<VoiceLabPage />);
fireEvent.click(screen.getByRole('button', { name: /attempt to start session/i }));
await waitFor(() => {
expect(mockedAiApiClient.startVoiceSession).toHaveBeenCalled();
});
expect(logger.info).toHaveBeenCalledWith('Received voice session message:', 'Test message from socket');
});
});
});

View File

@@ -1,6 +1,6 @@
// src/pages/admin/ActivityLog.test.tsx
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ActivityLog } from './ActivityLog';
import * as apiClient from '../../services/apiClient';
@@ -27,7 +27,7 @@ const mockLogs: ActivityLogItem[] = [
user_id: 'user-123',
action: 'flyer_processed',
display_text: 'Processed a new flyer for Walmart.',
details: { flyerId: 1, store_name: 'Walmart', user_avatar_url: 'http://example.com/avatar.png', user_full_name: 'Test User' },
details: { flyer_id: 1, store_name: 'Walmart', user_avatar_url: 'http://example.com/avatar.png', user_full_name: 'Test User' },
created_at: new Date().toISOString(),
},
{
@@ -35,7 +35,7 @@ const mockLogs: ActivityLogItem[] = [
user_id: 'user-456',
action: 'recipe_created',
display_text: 'Jane Doe added a new recipe: Pasta Carbonara',
details: { recipe_name: 'Pasta Carbonara', user_full_name: 'Jane Doe' },
details: { recipe_id: 1, recipe_name: 'Pasta Carbonara', user_full_name: 'Jane Doe' },
created_at: new Date().toISOString(),
},
{
@@ -46,6 +46,30 @@ const mockLogs: ActivityLogItem[] = [
details: { list_name: 'Weekly Groceries', shopping_list_id: 10, user_full_name: 'John Smith', shared_with_name: 'Test User' },
created_at: new Date().toISOString(),
},
{
activity_log_id: 4,
user_id: 'user-101',
action: 'user_registered',
display_text: 'New user joined',
details: { full_name: 'Newbie User' }, // No avatar provided to test fallback
created_at: new Date().toISOString(),
},
{
activity_log_id: 5,
user_id: 'user-102',
action: 'recipe_favorited',
display_text: 'User favorited a recipe',
details: { recipe_name: 'Best Pizza', user_full_name: 'Pizza Lover', user_avatar_url: 'http://example.com/pizza.png' },
created_at: new Date().toISOString(),
},
{
activity_log_id: 6,
user_id: 'user-103',
action: 'unknown_action' as any, // Force unknown action to test default case
display_text: 'Something happened',
details: {} as any,
created_at: new Date().toISOString(),
},
];
describe('ActivityLog', () => {
@@ -58,25 +82,22 @@ describe('ActivityLog', () => {
expect(container).toBeEmptyDOMElement();
});
it.todo('TODO: should show a loading state initially', () => {
// This test uses a manually-resolved promise pattern that is still causing test hangs and memory leaks.
// Disabling to get the pipeline passing.
});
/*
it('should show a loading state initially', async () => {
let resolvePromise: (value: Response) => void;
const mockPromise = new Promise<Response>(resolve => {
resolvePromise = resolve;
});
mockedApiClient.fetchActivityLog.mockReturnValue(mockPromise);
// Cast to any to bypass strict type checking for the mock return value vs Promise
mockedApiClient.fetchActivityLog.mockReturnValue(mockPromise as any);
render(<ActivityLog user={mockUser} onLogClick={vi.fn()} />);
expect(screen.getByText('Loading activity...')).toBeInTheDocument();
await act(async () => {
resolvePromise(new Response(JSON.stringify([])));
await mockPromise;
resolvePromise!(new Response(JSON.stringify([])));
});
});
*/
it('should display an error message if fetching logs fails', async () => {
mockedApiClient.fetchActivityLog.mockRejectedValue(new Error('API is down'));
@@ -94,7 +115,7 @@ describe('ActivityLog', () => {
});
});
it('should render a list of activities successfully', async () => {
it('should render a list of activities successfully covering all types', async () => {
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs)));
render(<ActivityLog user={mockUser} />);
await waitFor(() => {
@@ -102,6 +123,9 @@ describe('ActivityLog', () => {
expect(screen.getByText('Walmart')).toBeInTheDocument(); // From flyer_processed
expect(screen.getByText('Pasta Carbonara')).toBeInTheDocument(); // From recipe_created
expect(screen.getByText('Weekly Groceries')).toBeInTheDocument(); // From list_shared
expect(screen.getByText('Newbie User')).toBeInTheDocument(); // From user_registered
expect(screen.getByText('Best Pizza')).toBeInTheDocument(); // From recipe_favorited
expect(screen.getByText('An unknown activity occurred.')).toBeInTheDocument(); // From unknown_action
// Check for user names
expect(screen.getByText('Jane Doe', { exact: false })).toBeInTheDocument();
@@ -111,6 +135,14 @@ describe('ActivityLog', () => {
expect(avatar).toBeInTheDocument();
expect(avatar).toHaveAttribute('src', 'http://example.com/avatar.png');
// Check for fallback avatar (Newbie User has no avatar)
// The fallback is an SVG inside a span. We can check for the span's class or the SVG.
// The container for fallback has specific classes.
// We can look for the container associated with the "Newbie User" item.
const newbieItem = screen.getByText('Newbie User').closest('li');
const fallbackIcon = newbieItem?.querySelector('svg');
expect(fallbackIcon).toBeInTheDocument();
// Check for the mocked date
expect(screen.getAllByText('about 5 hours ago')).toHaveLength(mockLogs.length);
});
@@ -122,18 +154,36 @@ describe('ActivityLog', () => {
render(<ActivityLog user={mockUser} onLogClick={onLogClickMock} />);
await waitFor(() => {
// Find the clickable element (the recipe name in this case)
// Recipe Created
const clickableRecipe = screen.getByText('Pasta Carbonara');
fireEvent.click(clickableRecipe);
expect(onLogClickMock).toHaveBeenCalledTimes(1);
// The second log item has the recipe
expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[1]);
// List Shared
const clickableList = screen.getByText('Weekly Groceries');
fireEvent.click(clickableList);
expect(onLogClickMock).toHaveBeenCalledTimes(2);
// The third log item has the list
expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[2]);
// Recipe Favorited
const clickableFav = screen.getByText('Best Pizza');
fireEvent.click(clickableFav);
expect(onLogClickMock).toHaveBeenCalledWith(mockLogs[4]);
});
expect(onLogClickMock).toHaveBeenCalledTimes(3);
});
it('should not render clickable styling if onLogClick is undefined', async () => {
mockedApiClient.fetchActivityLog.mockResolvedValue(new Response(JSON.stringify(mockLogs)));
render(<ActivityLog user={mockUser} />); // onLogClick is undefined
await waitFor(() => {
const recipeName = screen.getByText('Pasta Carbonara');
expect(recipeName).not.toHaveClass('cursor-pointer');
expect(recipeName).not.toHaveClass('text-blue-500');
const listName = screen.getByText('Weekly Groceries');
expect(listName).not.toHaveClass('cursor-pointer');
});
});
});

View File

@@ -1,6 +1,6 @@
// src/pages/admin/AdminPage.test.tsx
import React from 'react';
import { render, screen } from '@testing-library/react';
import { render, screen, within } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { AdminPage } from './AdminPage';
@@ -10,6 +10,15 @@ vi.mock('./components/SystemCheck', () => ({
SystemCheck: () => <div data-testid="system-check-mock">System Health Checks</div>,
}));
// Mock the icons to verify they are rendered correctly
vi.mock('../../components/icons/ShieldExclamationIcon', () => ({
ShieldExclamationIcon: (props: any) => <svg data-testid="shield-icon" {...props} />,
}));
vi.mock('../../components/icons/ChartBarIcon', () => ({
ChartBarIcon: (props: any) => <svg data-testid="chart-icon" {...props} />,
}));
// Mock the logger to prevent console output during tests
vi.mock('../../services/logger', () => ({
logger: {
@@ -36,16 +45,23 @@ describe('AdminPage', () => {
expect(screen.getByText('Tools and system health checks.')).toBeInTheDocument();
});
it('should render the management section heading', () => {
renderWithRouter();
expect(screen.getByRole('heading', { name: /management/i })).toBeInTheDocument();
});
it('should render navigation links to other admin sections', () => {
renderWithRouter();
const correctionsLink = screen.getByRole('link', { name: /review corrections/i });
expect(correctionsLink).toBeInTheDocument();
expect(correctionsLink).toHaveAttribute('href', '/admin/corrections');
expect(within(correctionsLink).getByTestId('shield-icon')).toBeInTheDocument();
const statsLink = screen.getByRole('link', { name: /view statistics/i });
expect(statsLink).toBeInTheDocument();
expect(statsLink).toHaveAttribute('href', '/admin/stats');
expect(within(statsLink).getByTestId('chart-icon')).toBeInTheDocument();
const backLink = screen.getByRole('link', { name: /back to main app/i });
expect(backLink).toBeInTheDocument();

View File

@@ -1,6 +1,6 @@
// src/pages/admin/AdminStatsPage.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { AdminStatsPage } from './AdminStatsPage';
@@ -24,27 +24,22 @@ describe('AdminStatsPage', () => {
vi.clearAllMocks();
});
it.todo('TODO: should render a loading spinner while fetching stats', () => {
// This test uses a manually-resolved promise pattern that is still causing test hangs and memory leaks.
// Disabling to get the pipeline passing.
});
/*
it('should render a loading spinner while fetching stats', async () => {
let resolvePromise: (value: Response) => void;
const mockPromise = new Promise<Response>(resolve => {
resolvePromise = resolve;
});
mockedApiClient.getApplicationStats.mockReturnValue(mockPromise);
// Cast to any to bypass strict type checking for the mock return value vs Promise
mockedApiClient.getApplicationStats.mockReturnValue(mockPromise as any);
renderWithRouter();
expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument();
await act(async () => {
resolvePromise(new Response(JSON.stringify({})));
await mockPromise;
resolvePromise!(new Response(JSON.stringify({ userCount: 0, flyerCount: 0, flyerItemCount: 0, storeCount: 0, pendingCorrectionCount: 0 })));
});
});
*/
it('should display stats cards when data is fetched successfully', async () => {
const mockStats: AppStats = {
@@ -86,4 +81,28 @@ describe('AdminStatsPage', () => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
it('should display a generic error message for unknown errors', async () => {
mockedApiClient.getApplicationStats.mockRejectedValue('Unknown error object');
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('An unknown error occurred.')).toBeInTheDocument();
});
});
it('should render a link back to the admin dashboard', async () => {
mockedApiClient.getApplicationStats.mockResolvedValue(new Response(JSON.stringify({
userCount: 0,
flyerCount: 0,
flyerItemCount: 0,
storeCount: 0,
pendingCorrectionCount: 0,
})));
renderWithRouter();
const link = await screen.findByRole('link', { name: /back to admin dashboard/i });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute('href', '/admin');
});
});

View File

@@ -1,6 +1,6 @@
// src/pages/admin/CorrectionsPage.test.tsx
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
import { CorrectionsPage } from './CorrectionsPage';
@@ -13,9 +13,17 @@ const mockedApiClient = vi.mocked(apiClient);
// Mock the child CorrectionRow component to isolate the test to the page itself
// The CorrectionRow component is now located in a sub-directory.
vi.mock('./components/CorrectionRow', () => ({
CorrectionRow: (props: { correction: SuggestedCorrection & { flyer_item: { item: string } } }) => (
CorrectionRow: (props: any) => (
<tr data-testid={`correction-row-${props.correction.suggested_correction_id}`}>
<td>{props.correction.flyer_item_name}</td>
<td>
<button
data-testid={`process-btn-${props.correction.suggested_correction_id}`}
onClick={() => props.onProcessed(props.correction.suggested_correction_id)}
>
Process
</button>
</td>
</tr>
),
}));
@@ -41,27 +49,25 @@ describe('CorrectionsPage', () => {
vi.clearAllMocks();
});
it.todo('TODO: should render a loading spinner while fetching data', () => {
// This test uses a manually-resolved promise pattern that is still causing test hangs and memory leaks.
// Disabling to get the pipeline passing.
});
/*
it('should render a loading spinner while fetching data', async () => {
let resolvePromise: (value: Response) => void;
const mockPromise = new Promise<Response>(resolve => {
resolvePromise = resolve;
});
mockedApiClient.getSuggestedCorrections.mockReturnValue(mockPromise);
// Cast to any to bypass strict type checking for the mock return value vs Promise
mockedApiClient.getSuggestedCorrections.mockReturnValue(mockPromise as any);
// Mock other calls to resolve immediately so Promise.all waits on the one we control
mockedApiClient.fetchMasterItems.mockResolvedValue(new Response(JSON.stringify([])));
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify([])));
renderWithRouter();
expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument();
await act(async () => {
resolvePromise(new Response(JSON.stringify([])));
await mockPromise;
resolvePromise!(new Response(JSON.stringify([])));
});
});
*/
it('should display corrections when data is fetched successfully', async () => {
mockedApiClient.getSuggestedCorrections.mockResolvedValue(new Response(JSON.stringify(mockCorrections)));
@@ -113,4 +119,49 @@ describe('CorrectionsPage', () => {
expect(screen.getByText(errorMessage)).toBeInTheDocument();
});
});
it('should handle unknown errors gracefully', async () => {
mockedApiClient.getSuggestedCorrections.mockRejectedValue('Unknown string error');
mockedApiClient.fetchMasterItems.mockResolvedValue(new Response(JSON.stringify(mockMasterItems)));
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories)));
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('An unknown error occurred while fetching corrections.')).toBeInTheDocument();
});
});
it('should refresh corrections when the refresh button is clicked', async () => {
mockedApiClient.getSuggestedCorrections.mockResolvedValue(new Response(JSON.stringify(mockCorrections)));
mockedApiClient.fetchMasterItems.mockResolvedValue(new Response(JSON.stringify(mockMasterItems)));
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories)));
renderWithRouter();
await waitFor(() => expect(screen.getByText('Bananas')).toBeInTheDocument());
// Clear mocks to track new calls
mockedApiClient.getSuggestedCorrections.mockClear();
// Click refresh
const refreshButton = screen.getByTitle('Refresh Corrections');
fireEvent.click(refreshButton);
expect(mockedApiClient.getSuggestedCorrections).toHaveBeenCalled();
});
it('should remove a correction from the list when processed', async () => {
mockedApiClient.getSuggestedCorrections.mockResolvedValue(new Response(JSON.stringify(mockCorrections)));
mockedApiClient.fetchMasterItems.mockResolvedValue(new Response(JSON.stringify(mockMasterItems)));
mockedApiClient.fetchCategories.mockResolvedValue(new Response(JSON.stringify(mockCategories)));
renderWithRouter();
await waitFor(() => expect(screen.getByTestId('correction-row-1')).toBeInTheDocument());
// Click the process button in the mock row for ID 1
fireEvent.click(screen.getByTestId('process-btn-1'));
// It should disappear
await waitFor(() => expect(screen.queryByTestId('correction-row-1')).not.toBeInTheDocument());
expect(screen.getByTestId('correction-row-2')).toBeInTheDocument();
});
});

View File

@@ -55,20 +55,23 @@ describe('AddressForm', () => {
expect(screen.getByLabelText(/country/i)).toHaveValue('Canada');
});
it('should call onAddressChange with the correct field and value on input change', () => {
it('should call onAddressChange with the correct field and value for all inputs', () => {
render(<AddressForm {...defaultProps} />);
const cityInput = screen.getByLabelText(/city/i);
fireEvent.change(cityInput, { target: { value: 'New City' } });
const inputs = [
{ label: /address line 1/i, name: 'address_line_1', value: '123 St' },
{ label: /address line 2/i, name: 'address_line_2', value: 'Apt 4' },
{ label: /city/i, name: 'city', value: 'Metropolis' },
{ label: /province \/ state/i, name: 'province_state', value: 'NY' },
{ label: /postal \/ zip code/i, name: 'postal_code', value: '10001' },
{ label: /country/i, name: 'country', value: 'USA' },
];
expect(mockOnAddressChange).toHaveBeenCalledTimes(1);
expect(mockOnAddressChange).toHaveBeenCalledWith('city', 'New City');
const postalInput = screen.getByLabelText(/postal \/ zip code/i);
fireEvent.change(postalInput, { target: { value: 'A1B 2C3' } });
expect(mockOnAddressChange).toHaveBeenCalledTimes(2);
expect(mockOnAddressChange).toHaveBeenCalledWith('postal_code', 'A1B 2C3');
inputs.forEach(({ label, name, value }) => {
const input = screen.getByLabelText(label);
fireEvent.change(input, { target: { value } });
expect(mockOnAddressChange).toHaveBeenCalledWith(name, value);
});
});
it('should call onGeocode when the "Re-Geocode" button is clicked', () => {
@@ -80,6 +83,12 @@ describe('AddressForm', () => {
expect(mockOnGeocode).toHaveBeenCalledTimes(1);
});
it('should show MapPinIcon when not geocoding', () => {
render(<AddressForm {...defaultProps} isGeocoding={false} />);
expect(screen.getByTestId('map-pin-icon')).toBeInTheDocument();
expect(screen.queryByTestId('loading-spinner')).not.toBeInTheDocument();
});
describe('when isGeocoding is true', () => {
it('should disable the button and show a loading spinner', () => {
render(<AddressForm {...defaultProps} isGeocoding={true} />);
@@ -91,6 +100,8 @@ describe('AddressForm', () => {
expect(geocodeButton.querySelector('[data-testid="loading-spinner"]')).toBeInTheDocument();
// A second spinner is shown next to the title
expect(screen.getAllByTestId('loading-spinner')).toHaveLength(2);
// MapPinIcon should be hidden
expect(screen.queryByTestId('map-pin-icon')).not.toBeInTheDocument();
});
});
});

View File

@@ -244,4 +244,65 @@ describe('AdminBrandManager', () => {
});
console.log('TEST END: should show an error toast if no file is selected');
});
it('should render an empty table if no brands are found', async () => {
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify([]), { status: 200 })
);
render(<AdminBrandManager />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: /brand management/i })).toBeInTheDocument();
// Only the header row should be present
expect(screen.getAllByRole('row')).toHaveLength(1);
});
});
it('should use status code in error message if response body is empty on upload failure', async () => {
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 })
);
mockedApiClient.uploadBrandLogo.mockImplementation(
async () => new Response(null, { status: 500, statusText: 'Internal Server Error' })
);
mockedToast.loading.mockReturnValue('toast-fallback');
render(<AdminBrandManager />);
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
const input = screen.getByLabelText('Upload logo for No Frills');
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
expect(mockedToast.error).toHaveBeenCalledWith('Upload failed: Upload failed with status 500', { id: 'toast-fallback' });
});
});
it('should only update the target brand logo and leave others unchanged', async () => {
mockedApiClient.fetchAllBrands.mockImplementation(
async () => new Response(JSON.stringify(mockBrands), { status: 200 })
);
mockedApiClient.uploadBrandLogo.mockImplementation(
async () => new Response(JSON.stringify({ logoUrl: 'new-logo.png' }), { status: 200 })
);
mockedToast.loading.mockReturnValue('toast-opt');
render(<AdminBrandManager />);
await waitFor(() => expect(screen.getByText('No Frills')).toBeInTheDocument());
// Brand 1: No Frills (initially null logo)
// Brand 2: Compliments (initially has logo)
const file = new File(['logo'], 'logo.png', { type: 'image/png' });
const input = screen.getByLabelText('Upload logo for No Frills'); // Brand 1
fireEvent.change(input, { target: { files: [file] } });
await waitFor(() => {
// Brand 1 should have new logo
expect(screen.getByAltText('No Frills logo')).toHaveAttribute('src', 'new-logo.png');
// Brand 2 should still have original logo
expect(screen.getByAltText('Compliments logo')).toHaveAttribute('src', 'http://example.com/compliments.png');
});
});
});

View File

@@ -4,14 +4,24 @@ 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('../../../components/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', () => {
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', () => {

View File

@@ -198,4 +198,51 @@ describe('AuthView', () => {
expect(window.location.href).toBe('/api/auth/github');
});
});
describe('UI Logic and Loading States', () => {
it('should toggle "Remember me" checkbox', () => {
render(<AuthView {...defaultProps} />);
const rememberMeCheckbox = screen.getByRole('checkbox', { name: /remember me/i });
expect(rememberMeCheckbox).not.toBeChecked();
fireEvent.click(rememberMeCheckbox);
expect(rememberMeCheckbox).toBeChecked();
fireEvent.click(rememberMeCheckbox);
expect(rememberMeCheckbox).not.toBeChecked();
});
it('should show loading state during login submission', async () => {
// Mock a promise that doesn't resolve immediately
(mockedApiClient.loginUser as Mock).mockReturnValue(new Promise(() => {}));
render(<AuthView {...defaultProps} />);
fireEvent.change(screen.getByLabelText(/email address/i), { target: { value: 'test@example.com' } });
fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'password' } });
fireEvent.submit(screen.getByTestId('auth-form'));
// Find the submit button. Since the text is replaced by a spinner, we find by type.
const submitButton = screen.getByTestId('auth-form').querySelector('button[type="submit"]');
expect(submitButton).toBeInTheDocument();
expect(submitButton).toBeDisabled();
// Verify 'Sign In' text is gone
expect(screen.queryByText('Sign In')).not.toBeInTheDocument();
});
it('should show loading state during password reset submission', async () => {
(mockedApiClient.requestPasswordReset as Mock).mockReturnValue(new Promise(() => {}));
render(<AuthView {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /forgot password\?/i }));
fireEvent.change(screen.getByLabelText(/email address/i), { target: { value: 'reset@example.com' } });
fireEvent.submit(screen.getByTestId('reset-password-form'));
const submitButton = screen.getByTestId('reset-password-form').querySelector('button[type="submit"]');
expect(submitButton).toBeInTheDocument();
expect(submitButton).toBeDisabled();
expect(screen.queryByText('Send Reset Link')).not.toBeInTheDocument();
});
});
});

View File

@@ -134,6 +134,14 @@ describe('CorrectionRow', () => {
expect(screen.getByText('Unknown Category (ID: 999)')).toBeInTheDocument();
});
it('should return raw value for WRONG_PRICE if value is not a number', () => {
renderInTable({
...defaultProps,
correction: { ...mockCorrection, correction_type: 'WRONG_PRICE', suggested_value: 'invalid' },
});
expect(screen.getByText('invalid')).toBeInTheDocument();
});
it('should return the raw value for other correction types', () => {
renderInTable({
...defaultProps,
@@ -188,6 +196,24 @@ describe('CorrectionRow', () => {
});
});
it('should handle non-Error objects thrown during confirmation', async () => {
// Mock rejection with a string (not an Error object) to test the fallback error message
mockedApiClient.approveCorrection.mockRejectedValue('Network string error');
renderInTable();
fireEvent.click(screen.getByTitle('Approve'));
// Wait for modal
await waitFor(() => expect(screen.getByTestId('confirmation-modal')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
await waitFor(() => {
// Expect the fallback message defined in the component
expect(screen.getByText('An unknown error occurred while trying to approve the correction.')).toBeInTheDocument();
});
});
it('should enter and exit editing mode', async () => {
renderInTable();
// Enter editing mode
@@ -240,6 +266,22 @@ describe('CorrectionRow', () => {
expect(screen.getByRole('spinbutton')).toBeInTheDocument();
});
it('should display a generic error if saving an edit fails with a non-Error object', async () => {
mockedApiClient.updateSuggestedCorrection.mockRejectedValue('String error');
renderInTable();
fireEvent.click(screen.getByTitle('Edit'));
const input = await screen.findByRole('spinbutton');
fireEvent.change(input, { target: { value: '300' } });
fireEvent.click(screen.getByTitle('Save'));
await waitFor(() => {
// Expect the fallback message defined in the component
expect(screen.getByText('Failed to save changes.')).toBeInTheDocument();
});
expect(screen.getByRole('spinbutton')).toBeInTheDocument();
});
describe('renderEditableField', () => {
it('should render a select for INCORRECT_ITEM_LINK', async () => {
renderInTable({
@@ -273,5 +315,18 @@ describe('CorrectionRow', () => {
// FIX: Use getByRole for consistency and robustness
expect(screen.getByRole('option', { name: 'Produce' })).toBeInTheDocument();
});
it('should render a text input for unknown correction types', async () => {
renderInTable({
...defaultProps,
correction: { ...mockCorrection, correction_type: 'OTHER_TYPE' as any, suggested_value: 'some text' },
});
fireEvent.click(screen.getByTitle('Edit'));
const input = await screen.findByRole('textbox'); // type="text" has role textbox
expect(input).toBeInTheDocument();
expect(input).toHaveValue('some text');
});
});
});

View File

@@ -71,4 +71,23 @@ describe('PasswordInput (in auth feature)', () => {
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

@@ -79,4 +79,31 @@ describe('PasswordStrengthIndicator', () => {
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

@@ -575,5 +575,72 @@ describe('ProfileManager', () => {
expect(mockOnProfileUpdate).toHaveBeenCalledWith(expect.objectContaining({ preferences: expect.objectContaining({ unitSystem: 'metric' }) }));
});
});
it('should only call updateProfile when only profile data has changed', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/full name/i)).toHaveValue(authenticatedProfile.full_name));
fireEvent.change(screen.getByLabelText(/full name/i), { target: { value: 'Only Name Changed' } });
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
await waitFor(() => {
expect(mockedApiClient.updateUserProfile).toHaveBeenCalled();
expect(mockedApiClient.updateUserAddress).not.toHaveBeenCalled();
expect(notifySuccess).toHaveBeenCalledWith('Profile updated successfully!');
});
});
it('should only call updateAddress when only address data has changed', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
fireEvent.change(screen.getByLabelText(/city/i), { target: { value: 'Only City Changed' } });
fireEvent.click(screen.getByRole('button', { name: /save profile/i }));
await waitFor(() => {
expect(mockedApiClient.updateUserAddress).toHaveBeenCalled();
expect(mockedApiClient.updateUserProfile).not.toHaveBeenCalled();
expect(notifySuccess).toHaveBeenCalledWith('Profile updated successfully!');
});
});
it('should handle manual geocode success via button click', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => expect(screen.getByLabelText(/city/i)).toHaveValue(mockAddress.city));
// Mock geocode response for the manual trigger
(mockedApiClient.geocodeAddress as Mock).mockResolvedValue({ ok: true, json: () => Promise.resolve({ lat: 50.0, lng: -80.0 }) });
fireEvent.click(screen.getByRole('button', { name: /re-geocode/i }));
await waitFor(() => {
expect(mockedApiClient.geocodeAddress).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith('Address re-geocoded successfully!');
});
});
it('should reset address form if profile has no address_id', async () => {
const profileNoAddress = { ...authenticatedProfile, address_id: null };
render(<ProfileManager {...defaultAuthenticatedProps} profile={profileNoAddress as any} />);
await waitFor(() => {
// Address fields should be empty
expect(screen.getByLabelText(/address line 1/i)).toHaveValue('');
// Should not attempt to fetch address
expect(mockedApiClient.getUserAddress).not.toHaveBeenCalled();
});
});
it('should log warning if address fetch returns null', async () => {
const loggerSpy = vi.spyOn(logger.logger, 'warn');
// Mock getUserAddress to return null
(mockedApiClient.getUserAddress as Mock).mockResolvedValue(null);
render(<ProfileManager {...defaultAuthenticatedProps} />);
await waitFor(() => {
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('Fetch returned null or undefined'));
});
});
});
});

View File

@@ -351,6 +351,17 @@ describe('SystemCheck', () => {
expect(vi.mocked(toast).error).toHaveBeenCalledWith('Queue is down');
});
});
it('should show an error toast if the API returns a non-OK response', async () => {
mockedApiClient.triggerFailingJob.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Server error' }), { status: 500 }));
render(<SystemCheck />);
const triggerButton = screen.getByRole('button', { name: /trigger failing job/i });
fireEvent.click(triggerButton);
await waitFor(() => {
expect(vi.mocked(toast).error).toHaveBeenCalledWith('Server error');
});
});
});
describe('GeocodeCacheManager', () => {
@@ -380,5 +391,58 @@ describe('SystemCheck', () => {
fireEvent.click(screen.getByRole('button', { name: /clear geocode cache/i }));
await waitFor(() => expect(vi.mocked(toast).error).toHaveBeenCalledWith('Redis is busy'));
});
it('should not call clearGeocodeCache if user cancels confirmation', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
render(<SystemCheck />);
await waitFor(() => expect(screen.getByText('Redis OK')).toBeInTheDocument());
const clearButton = screen.getByRole('button', { name: /clear geocode cache/i });
fireEvent.click(clearButton);
expect(mockedApiClient.clearGeocodeCache).not.toHaveBeenCalled();
});
it('should show an error toast if the API returns a non-OK response', async () => {
mockedApiClient.clearGeocodeCache.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Cache clear failed' }), { status: 500 }));
render(<SystemCheck />);
await waitFor(() => expect(screen.getByText('Redis OK')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /clear geocode cache/i }));
await waitFor(() => {
expect(vi.mocked(toast).error).toHaveBeenCalledWith('Cache clear failed');
});
});
it('should hide Redis controls if Redis check fails', async () => {
mockedApiClient.checkRedisHealth.mockResolvedValueOnce(new Response(JSON.stringify({ success: false, message: 'Redis down' })));
render(<SystemCheck />);
await waitFor(() => expect(screen.getByText('Redis down')).toBeInTheDocument());
expect(screen.queryByTitle('Redis cache is connected')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /clear geocode cache/i })).not.toBeInTheDocument();
});
});
describe('Additional Edge Cases', () => {
it('should fail backend check if response text is not "pong"', async () => {
mockedApiClient.pingBackend.mockResolvedValueOnce(new Response('unexpected response', { status: 200 }));
render(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('Backend server is not responding. Is it running?')).toBeInTheDocument();
});
});
it('should handle non-OK response for generic checks (e.g. Storage)', async () => {
mockedApiClient.checkStorage.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Permission denied' }), { status: 403 }));
render(<SystemCheck />);
await waitFor(() => {
expect(screen.getByText('Permission denied')).toBeInTheDocument();
});
});
});
});

View File

@@ -157,7 +157,7 @@ export const startVoiceSession = (callbacks: {
onmessage: (message: import('@google/genai').LiveServerMessage) => void;
onerror?: (error: ErrorEvent) => void;
onclose?: () => void;
}) => {
}) : void => {
logger.debug('Stub: startVoiceSession called.', { callbacks });
// In a real implementation, this would connect to a WebSocket endpoint on your server,
// which would then proxy the connection to the Google AI Live API.