All checks were successful
Deploy to Test Environment / deploy-to-test (push) Successful in 19m58s
471 lines
18 KiB
TypeScript
471 lines
18 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, type Mocked } 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';
|
|
|
|
// The apiClient, logger, notificationService, and aiApiClient are all mocked globally.
|
|
// We can get a typed reference to the notificationService for individual test overrides.
|
|
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: 'http://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(() => {}));
|
|
render(<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)),
|
|
);
|
|
render(<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)),
|
|
);
|
|
render(<UserProfilePage />);
|
|
|
|
await waitFor(() => {
|
|
// The component throws 'Failed to fetch user profile.' because it just checks `!profileRes.ok`
|
|
expect(screen.getByText('Error: Failed to fetch user profile.')).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 }),
|
|
);
|
|
render(<UserProfilePage />);
|
|
|
|
await waitFor(() => {
|
|
// The component throws 'Failed to fetch user achievements.'
|
|
expect(screen.getByText('Error: Failed to fetch user achievements.')).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'));
|
|
render(<UserProfilePage />);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText('Error: Achievements service down')).toBeInTheDocument();
|
|
});
|
|
});
|
|
|
|
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)),
|
|
);
|
|
render(<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)),
|
|
);
|
|
render(<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([])));
|
|
|
|
render(<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)),
|
|
);
|
|
|
|
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)),
|
|
);
|
|
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)),
|
|
);
|
|
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: '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 () => {
|
|
render(<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 }),
|
|
);
|
|
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: '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 }),
|
|
);
|
|
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: '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 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', () => {
|
|
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: 'http://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);
|
|
});
|
|
});
|
|
|
|
render(<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 () => {
|
|
render(<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 }),
|
|
);
|
|
render(<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 }),
|
|
);
|
|
render(<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 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.',
|
|
);
|
|
});
|
|
});
|
|
|
|
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' } },
|
|
),
|
|
);
|
|
|
|
render(<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();
|
|
});
|
|
});
|
|
});
|
|
});
|