Compare commits

...

3 Commits

Author SHA1 Message Date
Gitea Actions
e3c876c7be ci: Bump version to 0.7.24 [skip ci] 2026-01-02 23:23:21 +05:00
32dcf3b89e unit test auto-provider refactor
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 21m2s
2026-01-02 10:22:27 -08:00
7066b937f6 unit test auto-provider refactor 2026-01-02 10:17:01 -08:00
11 changed files with 124 additions and 50 deletions

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "flyer-crawler",
"version": "0.7.23",
"version": "0.7.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "flyer-crawler",
"version": "0.7.23",
"version": "0.7.24",
"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.7.23",
"version": "0.7.24",
"type": "module",
"scripts": {
"dev": "concurrently \"npm:start:dev\" \"vite\"",

View File

@@ -1,10 +1,12 @@
// src/components/RecipeSuggester.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RecipeSuggester } from './RecipeSuggester';
import { suggestRecipe } from '../services/apiClient';
import { logger } from '../services/logger.client';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
import '@testing-library/jest-dom';
// Mock the API client
vi.mock('../services/apiClient', () => ({
@@ -26,7 +28,7 @@ describe('RecipeSuggester Component', () => {
it('renders correctly with initial state', () => {
console.log('TEST: Verifying initial render state');
render(<RecipeSuggester />);
renderWithProviders(<RecipeSuggester />);
expect(screen.getByText('Get a Recipe Suggestion')).toBeInTheDocument();
expect(screen.getByLabelText(/Ingredients:/i)).toBeInTheDocument();
@@ -37,7 +39,7 @@ describe('RecipeSuggester Component', () => {
it('shows validation error if no ingredients are entered', async () => {
console.log('TEST: Verifying validation for empty input');
const user = userEvent.setup();
render(<RecipeSuggester />);
renderWithProviders(<RecipeSuggester />);
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
await user.click(button);
@@ -50,17 +52,18 @@ describe('RecipeSuggester Component', () => {
it('calls suggestRecipe and displays suggestion on success', async () => {
console.log('TEST: Verifying successful recipe suggestion flow');
const user = userEvent.setup();
render(<RecipeSuggester />);
renderWithProviders(<RecipeSuggester />);
const input = screen.getByLabelText(/Ingredients:/i);
await user.type(input, 'chicken, rice');
// Mock successful API response
const mockSuggestion = 'Here is a nice Chicken and Rice recipe...';
vi.mocked(suggestRecipe).mockResolvedValue({
ok: true,
json: async () => ({ suggestion: mockSuggestion }),
} as Response);
// Add a delay to ensure the loading state is visible during the test
vi.mocked(suggestRecipe).mockImplementation(async () => {
await new Promise((resolve) => setTimeout(resolve, 50));
return { ok: true, json: async () => ({ suggestion: mockSuggestion }) } as Response;
});
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });
await user.click(button);
@@ -80,7 +83,7 @@ describe('RecipeSuggester Component', () => {
it('handles API errors (non-200 response) gracefully', async () => {
console.log('TEST: Verifying API error handling (400/500 responses)');
const user = userEvent.setup();
render(<RecipeSuggester />);
renderWithProviders(<RecipeSuggester />);
const input = screen.getByLabelText(/Ingredients:/i);
await user.type(input, 'rocks');
@@ -107,7 +110,7 @@ describe('RecipeSuggester Component', () => {
it('handles network exceptions and logs them', async () => {
console.log('TEST: Verifying network exception handling');
const user = userEvent.setup();
render(<RecipeSuggester />);
renderWithProviders(<RecipeSuggester />);
const input = screen.getByLabelText(/Ingredients:/i);
await user.type(input, 'beef');
@@ -133,7 +136,7 @@ describe('RecipeSuggester Component', () => {
it('clears previous errors when submitting again', async () => {
console.log('TEST: Verifying error clearing on re-submit');
const user = userEvent.setup();
render(<RecipeSuggester />);
renderWithProviders(<RecipeSuggester />);
// Trigger validation error first
const button = screen.getByRole('button', { name: /Suggest a Recipe/i });

View File

@@ -0,0 +1,34 @@
// src/components/StatCard.test.tsx
import React from 'react';
import { screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { StatCard } from './StatCard';
import { renderWithProviders } from '../tests/utils/renderWithProviders';
import '@testing-library/jest-dom';
describe('StatCard', () => {
it('renders title and value correctly', () => {
renderWithProviders(
<StatCard
title="Total Users"
value="1,234"
icon={<div data-testid="mock-icon">Icon</div>}
/>,
);
expect(screen.getByText('Total Users')).toBeInTheDocument();
expect(screen.getByText('1,234')).toBeInTheDocument();
});
it('renders the icon', () => {
renderWithProviders(
<StatCard
title="Total Users"
value="1,234"
icon={<div data-testid="mock-icon">Icon</div>}
/>,
);
expect(screen.getByTestId('mock-icon')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,32 @@
// src/components/StatCard.tsx
import React, { ReactNode } from 'react';
interface StatCardProps {
title: string;
value: string;
icon: ReactNode;
}
export const StatCard: React.FC<StatCardProps> = ({ title, value, icon }) => {
return (
<div className="bg-white dark:bg-gray-800 overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="flex items-center justify-center h-12 w-12 rounded-md bg-blue-500 text-white">
{icon}
</div>
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 dark:text-gray-400 truncate">{title}</dt>
<dd>
<div className="text-lg font-medium text-gray-900 dark:text-white">{value}</div>
</dd>
</dl>
</div>
</div>
</div>
</div>
);
};

View File

@@ -7,13 +7,13 @@ import { AdminStatsPage } from './AdminStatsPage';
import * as apiClient from '../../services/apiClient';
import type { AppStats } from '../../services/apiClient';
import { createMockAppStats } from '../../tests/utils/mockFactories';
import { StatCard } from './components/StatCard';
import { StatCard } from '../../components/StatCard';
// The apiClient and logger are now mocked globally via src/tests/setup/tests-setup-unit.ts.
const mockedApiClient = vi.mocked(apiClient);
// Mock the child StatCard component to use the shared mock and allow spying
vi.mock('./components/StatCard', async () => {
vi.mock('../../components/StatCard', async () => {
const { MockStatCard } = await import('../../tests/utils/componentMocks');
return { StatCard: vi.fn(MockStatCard) };
});

View File

@@ -10,7 +10,7 @@ import { DocumentDuplicateIcon } from '../../components/icons/DocumentDuplicateI
import { BuildingStorefrontIcon } from '../../components/icons/BuildingStorefrontIcon';
import { BellAlertIcon } from '../../components/icons/BellAlertIcon';
import { BookOpenIcon } from '../../components/icons/BookOpenIcon';
import { StatCard } from './components/StatCard';
import { StatCard } from '../../components/StatCard';
export const AdminStatsPage: React.FC = () => {
const [stats, setStats] = useState<AppStats | null>(null);

View File

@@ -881,17 +881,6 @@ describe('ProfileManager', () => {
// Should not attempt to fetch address
expect(mockedApiClient.getUserAddress).not.toHaveBeenCalled();
});
it('should call onSignOut when clicking the sign out button', async () => {
render(<ProfileManager {...defaultAuthenticatedProps} />);
// Get the sign out button via its text
const signOutButton = screen.getByRole('button', { name: /sign out/i });
fireEvent.click(signOutButton);
await waitFor(() => {
expect(mockOnSignOut).toHaveBeenCalled();
});
});
it('should not render auth views when the user is already authenticated', () => {
@@ -900,9 +889,6 @@ describe('ProfileManager', () => {
expect(screen.queryByText('Create an Account')).not.toBeInTheDocument();
});
});
it('should log warning if address fetch returns null', async () => {
console.log('[TEST DEBUG] Running: should log warning if address fetch returns null');
const loggerSpy = vi.spyOn(logger.logger, 'warn');
@@ -927,11 +913,11 @@ describe('ProfileManager', () => {
});
it('should handle updating the user profile and address with empty strings', async () => {
mockedApiClient.updateUserProfile.mockResolvedValue(
new Response(JSON.stringify(authenticatedProfile), { status: 200 }),
mockedApiClient.updateUserProfile.mockImplementation(async (data) =>
new Response(JSON.stringify({ ...authenticatedProfile, ...data })),
);
mockedApiClient.updateUserAddress.mockResolvedValue(
new Response(JSON.stringify(mockAddress), { status: 200 }),
mockedApiClient.updateUserAddress.mockImplementation(async (data) =>
new Response(JSON.stringify({ ...mockAddress, ...data })),
);
render(<ProfileManager {...defaultAuthenticatedProps} />);
@@ -957,7 +943,7 @@ describe('ProfileManager', () => {
expect.objectContaining({ signal: expect.anything() }),
);
expect(mockOnProfileUpdate).toHaveBeenCalledWith(
expect.objectContaining({ full_name: '' }),
expect.objectContaining({ full_name: '' })
);
expect(notifySuccess).toHaveBeenCalledWith('Profile updated successfully!');
});
@@ -990,7 +976,7 @@ describe('ProfileManager', () => {
fireEvent.click(screen.getByRole('button', { name: /re-geocode/i }));
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Geocoding failed');
expect(notifyError).toHaveBeenCalledWith('Geocoding failed');
});
});
@@ -1011,7 +997,7 @@ describe('ProfileManager', () => {
await vi.runAllTimersAsync();
});
expect(toast.error).toHaveBeenCalledWith('Auto-geocode error');
expect(notifyError).toHaveBeenCalledWith('Auto-geocode error');
});
it('should handle permission denied error during geocoding', async () => {
@@ -1023,7 +1009,7 @@ describe('ProfileManager', () => {
fireEvent.click(screen.getByRole('button', { name: /re-geocode/i }));
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith('Permission denied');
expect(notifyError).toHaveBeenCalledWith('Permission denied');
});
});
});

View File

@@ -11,6 +11,12 @@ import { renderWithProviders } from '../../../tests/utils/renderWithProviders';
// Mock the entire apiClient module to ensure all exports are defined.
// This is the primary fix for the error: [vitest] No "..." export is defined on the mock.
vi.mock('../../../services/apiClient', () => ({
// Mocks for providers used by renderWithProviders
fetchFlyers: vi.fn(),
fetchMasterItems: vi.fn(),
fetchWatchedItems: vi.fn(),
fetchShoppingLists: vi.fn(),
getAuthenticatedUserProfile: vi.fn(),
pingBackend: vi.fn(),
checkStorage: vi.fn(),
checkDbPoolHealth: vi.fn(),
@@ -21,7 +27,6 @@ vi.mock('../../../services/apiClient', () => ({
triggerFailingJob: vi.fn(),
clearGeocodeCache: vi.fn(),
}));
// Get a type-safe mocked version of the apiClient module.
const mockedApiClient = vi.mocked(apiClient);

View File

@@ -1,5 +1,5 @@
// src/providers/AuthProvider.test.tsx
import React, { useContext } from 'react';
import React, { useContext, useState } from 'react';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import { AuthProvider } from './AuthProvider';
@@ -30,16 +30,27 @@ const mockProfile = createMockUserProfile({
// A simple consumer component to access and display context values
const TestConsumer = () => {
const context = useContext(AuthContext);
const [error, setError] = useState<string | null>(null);
if (!context) {
return <div>No Context</div>;
}
const handleLoginWithoutProfile = async () => {
try {
await context.login('test-token-no-profile');
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
return (
<div>
<div data-testid="auth-status">{context.authStatus}</div>
<div data-testid="user-email">{context.userProfile?.user.email ?? 'No User'}</div>
<div data-testid="is-loading">{context.isLoading.toString()}</div>
{error && <div data-testid="error-display">{error}</div>}
<button onClick={() => context.login('test-token', mockProfile)}>Login with Profile</button>
<button onClick={() => context.login('test-token-no-profile')}>Login without Profile</button>
<button onClick={handleLoginWithoutProfile}>Login without Profile</button>
<button onClick={context.logout}>Logout</button>
<button onClick={() => context.updateProfile({ full_name: 'Updated Name' })}>
Update Profile
@@ -65,8 +76,9 @@ describe('AuthProvider', () => {
mockedTokenStorage.getToken.mockReturnValue(null);
renderWithProvider();
expect(screen.getByTestId('auth-status')).toHaveTextContent('Determining...');
expect(screen.getByTestId('is-loading')).toHaveTextContent('true');
// The transition happens synchronously in the effect when no token is present,
// so 'Determining...' might be skipped or flashed too quickly for the test runner.
// We check that it settles correctly.
await waitFor(() => {
expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT');
@@ -179,15 +191,16 @@ describe('AuthProvider', () => {
const loginButton = screen.getByRole('button', { name: 'Login without Profile' });
// The login function throws an error, so we wrap it to assert the throw
await expect(
act(async () => {
fireEvent.click(loginButton);
}),
).rejects.toThrow('Login succeeded, but failed to fetch your data: API is down');
// Click the button that triggers the failing login
fireEvent.click(loginButton);
// After the error is thrown, the state should be rolled back
await waitFor(() => {
// The error is now caught and displayed by the TestConsumer
expect(screen.getByTestId('error-display')).toHaveTextContent(
'Login succeeded, but failed to fetch your data: API is down',
);
expect(mockedTokenStorage.setToken).toHaveBeenCalledWith('test-token-no-profile');
expect(mockedTokenStorage.removeToken).toHaveBeenCalled();
expect(screen.getByTestId('auth-status')).toHaveTextContent('SIGNED_OUT');

View File

@@ -243,6 +243,7 @@ describe('AI Service (Server)', () => {
vi.unstubAllEnvs();
process.env = { ...originalEnv, GEMINI_API_KEY: 'test-key' };
vi.resetModules(); // Re-import to use the new env var and re-instantiate the service
mockGenerateContent.mockReset();
});
afterEach(() => {