Files
flyer-crawler.projectium.com/src/pages/UserProfilePage.test.tsx
Torben Sorensen 503e7084da
All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 14m41s
Adopt TanStack Query fixes
2026-01-10 17:42:45 -08:00

528 lines
21 KiB
TypeScript

// src/pages/UserProfilePage.test.tsx
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import UserProfilePage from './UserProfilePage';
import * as apiClient from '../services/apiClient';
import { UserProfile, Achievement, UserAchievement } from '../types';
import {
createMockUserProfile,
createMockUserAchievement,
createMockUser,
} from '../tests/utils/mockFactories';
import { QueryWrapper } from '../tests/utils/renderWithProviders';
// Must explicitly call vi.mock() for apiClient
vi.mock('../services/apiClient');
const renderWithQuery = (ui: React.ReactElement) => render(ui, { wrapper: QueryWrapper });
const mockedNotificationService = vi.mocked(await import('../services/notificationService'));
vi.mock('../components/AchievementsList', () => ({
AchievementsList: ({ achievements }: { achievements: (UserAchievement & Achievement)[] }) => (
<div data-testid="achievements-list-mock">Achievements Count: {achievements.length}</div>
),
}));
const mockedApiClient = vi.mocked(apiClient);
// --- Mock Data ---
const mockProfile: UserProfile = createMockUserProfile({
user: createMockUser({ user_id: 'user-123', email: 'test@example.com' }),
full_name: 'Test User',
avatar_url: 'https://example.com/avatar.jpg',
points: 150,
role: 'user',
});
const mockAchievements: (UserAchievement & Achievement)[] = [
createMockUserAchievement({
achievement_id: 1,
user_id: 'user-123',
achieved_at: '2024-01-01T00:00:00Z',
name: 'First Steps',
description: 'Uploaded first flyer.',
icon: 'upload',
points_value: 10,
}),
];
describe('UserProfilePage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
// ... (Keep existing tests for loading message, error handling, rendering, etc.) ...
it('should display a loading message initially', () => {
mockedApiClient.getAuthenticatedUserProfile.mockReturnValue(new Promise(() => {}));
mockedApiClient.getUserAchievements.mockReturnValue(new Promise(() => {}));
renderWithQuery(<UserProfilePage />);
expect(screen.getByText('Loading profile...')).toBeInTheDocument();
});
it('should display an error message if fetching profile fails', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Network Error'));
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByText('Error: Network Error')).toBeInTheDocument();
});
});
it('should display an error message if fetching profile returns a non-ok response', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify({ message: 'Auth Failed' }), { status: 401 }),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
// The query hook parses the error message from the JSON body
expect(screen.getByText('Error: Auth Failed')).toBeInTheDocument();
});
});
it('should display an error message if fetching achievements returns a non-ok response', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify({ message: 'Server Busy' }), { status: 503 }),
);
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
// The query hook parses the error message from the JSON body
expect(screen.getByText('Error: Server Busy')).toBeInTheDocument();
});
});
it('should display an error message if fetching achievements fails', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
mockedApiClient.getUserAchievements.mockRejectedValue(new Error('Achievements service down'));
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByText('Error: Achievements service down')).toBeInTheDocument();
});
});
it('should handle unknown errors during fetch', async () => {
// Use an actual Error object since the hook extracts error.message
mockedApiClient.getAuthenticatedUserProfile.mockRejectedValue(new Error('Unknown error'));
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByText('Error: Unknown error')).toBeInTheDocument();
});
});
it('should handle null achievements data gracefully on fetch', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
// Mock a successful response but with a null body for achievements
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify(null)));
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
// The mock achievements list should show 0 achievements because the component
// should handle the null response and pass an empty array to the list.
expect(screen.getByTestId('achievements-list-mock')).toHaveTextContent(
'Achievements Count: 0',
);
});
});
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)),
);
renderWithQuery(<UserProfilePage />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
expect(screen.getByText('test@example.com')).toBeInTheDocument();
expect(screen.getByText('150 Points')).toBeInTheDocument();
expect(screen.getByAltText('User Avatar')).toHaveAttribute('src', mockProfile.avatar_url);
expect(screen.getByTestId('achievements-list-mock')).toHaveTextContent(
'Achievements Count: 1',
);
});
});
it('should render a fallback message if profile is null after loading', async () => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(null)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
renderWithQuery(<UserProfilePage />);
expect(await screen.findByText('Could not load user profile.')).toBeInTheDocument();
});
it('should display a fallback avatar if the user has no avatar_url', async () => {
// Create a mock profile with a null avatar_url and a specific name for the seed
const profileWithoutAvatar = { ...mockProfile, avatar_url: null, full_name: 'No Avatar User' };
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(profileWithoutAvatar)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(new Response(JSON.stringify([])));
renderWithQuery(<UserProfilePage />);
// Wait for the component to render with the fetched data
await waitFor(() => {
const avatarImage = screen.getByAltText('User Avatar');
// JSDOM might not URL-encode spaces in the src attribute in the same way a browser does.
// We adjust the expectation to match the literal string returned by getAttribute.
const expectedSrc = 'https://api.dicebear.com/8.x/initials/svg?seed=No Avatar User';
console.log('[TEST LOG] Actual Avatar Src:', avatarImage.getAttribute('src'));
expect(avatarImage).toHaveAttribute('src', expectedSrc);
});
});
it('should use email for avatar seed if full_name is missing', async () => {
const profileNoName = { ...mockProfile, full_name: null, avatar_url: null };
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(profileNoName)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
renderWithQuery(<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)),
);
renderWithQuery(<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)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
});
it('should allow editing and saving the user name', async () => {
const updatedProfile = { ...mockProfile, full_name: 'Updated Name' };
mockedApiClient.updateUserProfile.mockResolvedValue(
new Response(JSON.stringify(updatedProfile)),
);
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const nameInput = screen.getByRole('textbox');
fireEvent.change(nameInput, { target: { value: 'Updated Name' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(mockedApiClient.updateUserProfile).toHaveBeenCalledWith({
full_name: 'Updated Name',
});
expect(screen.getByRole('heading', { name: 'Updated Name' })).toBeInTheDocument();
});
});
it('should allow canceling the name edit', async () => {
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Test User' })).toBeInTheDocument();
});
it('should show an error if saving the name fails with a non-ok response', async () => {
mockedApiClient.updateUserProfile.mockResolvedValue(
new Response(JSON.stringify({ message: 'Validation failed' }), { status: 400 }),
);
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const nameInput = screen.getByRole('textbox');
fireEvent.change(nameInput, { target: { value: 'Invalid Name' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith('Validation failed');
});
});
it('should show a default error if saving the name fails with a non-ok response and no message', async () => {
mockedApiClient.updateUserProfile.mockResolvedValue(
new Response(JSON.stringify({}), { status: 400 }),
);
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const nameInput = screen.getByRole('textbox');
fireEvent.change(nameInput, { target: { value: 'Invalid Name' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
// This covers the `|| 'Failed to update name.'` part of the error throw
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Failed to update name.',
);
});
});
it('should handle non-ok response with null body when saving name', async () => {
// This tests the case where the server returns an error status but an empty/null body.
mockedApiClient.updateUserProfile.mockResolvedValue(new Response(null, { status: 500 }));
renderWithQuery(<UserProfilePage />);
await screen.findByText('Test User');
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'New Name' } });
fireEvent.click(screen.getByRole('button', { name: /save/i }));
await waitFor(() => {
// The component should fall back to the default error message.
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Failed to update name.',
);
});
});
it('should handle unknown errors when saving name', async () => {
mockedApiClient.updateUserProfile.mockRejectedValue('Unknown update error');
renderWithQuery(<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', () => {
beforeEach(() => {
mockedApiClient.getAuthenticatedUserProfile.mockResolvedValue(
new Response(JSON.stringify(mockProfile)),
);
mockedApiClient.getUserAchievements.mockResolvedValue(
new Response(JSON.stringify(mockAchievements)),
);
});
it('should upload a new avatar and update the image source', async () => {
const updatedProfile = { ...mockProfile, avatar_url: 'https://example.com/new-avatar.png' };
// Log when the mock is called
mockedApiClient.uploadAvatar.mockImplementation((file) => {
console.log('[TEST LOG] uploadAvatar mock called with:', file.name);
// Add a slight delay to ensure "isUploading" state can be observed
return new Promise((resolve) => {
setTimeout(() => {
console.log('[TEST LOG] uploadAvatar mock resolving...');
resolve(new Response(JSON.stringify(updatedProfile)));
}, 100);
});
});
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
// Mock the hidden file input
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'chucknorris.png', { type: 'image/png' });
console.log('[TEST LOG] Firing file change event...');
fireEvent.change(fileInput, { target: { files: [file] } });
// DEBUG: Print current DOM state if spinner is not found immediately
// const spinner = screen.queryByTestId('avatar-upload-spinner');
// if (!spinner) {
// console.log('[TEST LOG] Spinner NOT found immediately after event.');
// // screen.debug(); // Uncomment to see DOM
// } else {
// console.log('[TEST LOG] Spinner FOUND immediately.');
// }
// Wait for the spinner to appear
console.log('[TEST LOG] Waiting for spinner...');
await screen.findByTestId('avatar-upload-spinner');
console.log('[TEST LOG] Spinner found.');
// Wait for the upload to complete and the UI to update.
await waitFor(() => {
expect(mockedApiClient.uploadAvatar).toHaveBeenCalledWith(file);
expect(screen.getByAltText('User Avatar')).toHaveAttribute(
'src',
updatedProfile.avatar_url,
);
expect(screen.queryByTestId('avatar-upload-spinner')).not.toBeInTheDocument();
});
});
it('should not attempt to upload if no file is selected', async () => {
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
// Simulate user canceling the file dialog
fireEvent.change(fileInput, { target: { files: null } });
// Assert that no API call was made
expect(mockedApiClient.uploadAvatar).not.toHaveBeenCalled();
});
it('should show an error if avatar upload returns a non-ok response', async () => {
mockedApiClient.uploadAvatar.mockResolvedValue(
new Response(JSON.stringify({ message: 'File too large' }), { status: 413 }),
);
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'large.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith('File too large');
});
});
it('should show a default error if avatar upload returns a non-ok response and no message', async () => {
mockedApiClient.uploadAvatar.mockResolvedValue(
new Response(JSON.stringify({}), { status: 413 }),
);
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'large.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
// This covers the `|| 'Failed to upload avatar.'` part of the error throw
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Failed to upload avatar.',
);
});
});
it('should handle non-ok response with null body when uploading avatar', async () => {
mockedApiClient.uploadAvatar.mockResolvedValue(new Response(null, { status: 500 }));
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
const file = new File(['(⌐□_□)'], 'chucknorris.png', { type: 'image/png' });
fireEvent.change(fileInput, { target: { files: [file] } });
await waitFor(() => {
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Failed to upload avatar.',
);
});
});
it('should handle unknown errors when uploading avatar', async () => {
mockedApiClient.uploadAvatar.mockRejectedValue('Unknown upload error');
renderWithQuery(<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.',
);
});
});
it('should show an error if a non-image file is selected for upload', async () => {
// Mock the API client to return a non-OK response, simulating server-side validation failure
mockedApiClient.uploadAvatar.mockResolvedValue(
new Response(
JSON.stringify({
message: 'Invalid file type. Only images (png, jpeg, gif) are allowed.',
}),
{ status: 400, headers: { 'Content-Type': 'application/json' } },
),
);
renderWithQuery(<UserProfilePage />);
await screen.findByAltText('User Avatar');
const fileInput = screen.getByTestId('avatar-file-input');
// Create a mock file that is NOT an image (e.g., a PDF)
const nonImageFile = new File(['some text content'], 'document.pdf', {
type: 'application/pdf',
});
fireEvent.change(fileInput, { target: { files: [nonImageFile] } });
await waitFor(() => {
expect(mockedApiClient.uploadAvatar).toHaveBeenCalledWith(nonImageFile);
expect(mockedNotificationService.notifyError).toHaveBeenCalledWith(
'Invalid file type. Only images (png, jpeg, gif) are allowed.',
);
expect(screen.queryByTestId('avatar-upload-spinner')).not.toBeInTheDocument();
});
});
});
});