migration to vite and cleaned up /uploads

This commit is contained in:
jackiettran
2026-01-18 16:55:19 -05:00
parent f9c2057e64
commit d570f607d3
34 changed files with 2357 additions and 16613 deletions

View File

@@ -34,7 +34,7 @@ import PrivateRoute from './components/PrivateRoute';
import axios from 'axios';
import './App.css';
const API_URL = process.env.REACT_APP_API_URL || 'http://localhost:5001';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:5001';
const AppContent: React.FC = () => {
const { showAuthModal, authModalMode, closeAuthModal, user } = useAuth();
@@ -77,7 +77,7 @@ const AppContent: React.FC = () => {
useEffect(() => {
const checkAlphaAccess = async () => {
// Bypass alpha access check if feature is disabled
if (process.env.REACT_APP_ALPHA_TESTING_ENABLED !== 'true') {
if (import.meta.env.VITE_ALPHA_TESTING_ENABLED !== 'true') {
setHasAlphaAccess(true);
setCheckingAccess(false);
return;

View File

@@ -1,25 +1,27 @@
/**
* Manual axios mock for Jest
* Manual axios mock for Vitest
* This avoids ESM transformation issues with the axios package
*/
import { vi } from 'vitest';
const mockAxiosInstance = {
get: jest.fn(() => Promise.resolve({ data: {} })),
post: jest.fn(() => Promise.resolve({ data: {} })),
put: jest.fn(() => Promise.resolve({ data: {} })),
delete: jest.fn(() => Promise.resolve({ data: {} })),
patch: jest.fn(() => Promise.resolve({ data: {} })),
request: jest.fn(() => Promise.resolve({ data: {} })),
get: vi.fn(() => Promise.resolve({ data: {} })),
post: vi.fn(() => Promise.resolve({ data: {} })),
put: vi.fn(() => Promise.resolve({ data: {} })),
delete: vi.fn(() => Promise.resolve({ data: {} })),
patch: vi.fn(() => Promise.resolve({ data: {} })),
request: vi.fn(() => Promise.resolve({ data: {} })),
interceptors: {
request: {
use: jest.fn(() => 0),
eject: jest.fn(),
clear: jest.fn(),
use: vi.fn(() => 0),
eject: vi.fn(),
clear: vi.fn(),
},
response: {
use: jest.fn(() => 0),
eject: jest.fn(),
clear: jest.fn(),
use: vi.fn(() => 0),
eject: vi.fn(),
clear: vi.fn(),
},
},
defaults: {
@@ -35,33 +37,33 @@ const mockAxiosInstance = {
timeout: 0,
withCredentials: false,
},
getUri: jest.fn(),
head: jest.fn(() => Promise.resolve({ data: {} })),
options: jest.fn(() => Promise.resolve({ data: {} })),
postForm: jest.fn(() => Promise.resolve({ data: {} })),
putForm: jest.fn(() => Promise.resolve({ data: {} })),
patchForm: jest.fn(() => Promise.resolve({ data: {} })),
getUri: vi.fn(),
head: vi.fn(() => Promise.resolve({ data: {} })),
options: vi.fn(() => Promise.resolve({ data: {} })),
postForm: vi.fn(() => Promise.resolve({ data: {} })),
putForm: vi.fn(() => Promise.resolve({ data: {} })),
patchForm: vi.fn(() => Promise.resolve({ data: {} })),
};
const axios = {
...mockAxiosInstance,
create: jest.fn(() => ({ ...mockAxiosInstance })),
isAxiosError: jest.fn((error: any) => error?.isAxiosError === true),
isCancel: jest.fn(() => false),
all: jest.fn((promises: Promise<any>[]) => Promise.all(promises)),
spread: jest.fn((callback: Function) => (arr: any[]) => callback(...arr)),
toFormData: jest.fn(),
formToJSON: jest.fn(),
create: vi.fn(() => ({ ...mockAxiosInstance })),
isAxiosError: vi.fn((error: any) => error?.isAxiosError === true),
isCancel: vi.fn(() => false),
all: vi.fn((promises: Promise<any>[]) => Promise.all(promises)),
spread: vi.fn((callback: Function) => (arr: any[]) => callback(...arr)),
toFormData: vi.fn(),
formToJSON: vi.fn(),
CancelToken: {
source: jest.fn(() => ({
source: vi.fn(() => ({
token: {},
cancel: jest.fn(),
cancel: vi.fn(),
})),
},
Axios: jest.fn(),
AxiosError: jest.fn(),
Cancel: jest.fn(),
CanceledError: jest.fn(),
Axios: vi.fn(),
AxiosError: vi.fn(),
Cancel: vi.fn(),
CanceledError: vi.fn(),
VERSION: '1.0.0',
default: mockAxiosInstance,
};

View File

@@ -0,0 +1 @@
module.exports = 'test-file-stub';

View File

@@ -8,31 +8,35 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import AuthModal from '../../components/AuthModal';
// Mock the auth context
const mockLogin = jest.fn();
const mockRegister = jest.fn();
const mockLogin = vi.fn();
const mockRegister = vi.fn();
jest.mock('../../contexts/AuthContext', () => ({
...jest.requireActual('../../contexts/AuthContext'),
useAuth: () => ({
login: mockLogin,
register: mockRegister,
user: null,
loading: false,
}),
}));
// Mock child components
jest.mock('../../components/PasswordStrengthMeter', () => {
return function MockPasswordStrengthMeter({ password }: { password: string }) {
return <div data-testid="password-strength-meter">Strength: {password.length > 8 ? 'Strong' : 'Weak'}</div>;
vi.mock('../../contexts/AuthContext', async () => {
const actual = await vi.importActual('../../contexts/AuthContext');
return {
...actual,
useAuth: () => ({
login: mockLogin,
register: mockRegister,
user: null,
loading: false,
}),
};
});
jest.mock('../../components/PasswordInput', () => {
return function MockPasswordInput({
// Mock child components
vi.mock('../../components/PasswordStrengthMeter', () => ({
default: function MockPasswordStrengthMeter({ password }: { password: string }) {
return <div data-testid="password-strength-meter">Strength: {password.length > 8 ? 'Strong' : 'Weak'}</div>;
},
}));
vi.mock('../../components/PasswordInput', () => ({
default: function MockPasswordInput({
id,
label,
value,
@@ -59,11 +63,11 @@ jest.mock('../../components/PasswordInput', () => {
/>
</div>
);
};
});
},
}));
jest.mock('../../components/ForgotPasswordModal', () => {
return function MockForgotPasswordModal({
vi.mock('../../components/ForgotPasswordModal', () => ({
default: function MockForgotPasswordModal({
show,
onHide,
onBackToLogin
@@ -79,11 +83,11 @@ jest.mock('../../components/ForgotPasswordModal', () => {
<button onClick={onHide}>Close</button>
</div>
);
};
});
},
}));
jest.mock('../../components/VerificationCodeModal', () => {
return function MockVerificationCodeModal({
vi.mock('../../components/VerificationCodeModal', () => ({
default: function MockVerificationCodeModal({
show,
onHide,
email,
@@ -102,17 +106,17 @@ jest.mock('../../components/VerificationCodeModal', () => {
<button onClick={onHide}>Close</button>
</div>
);
};
});
},
}));
describe('AuthModal', () => {
const defaultProps = {
show: true,
onHide: jest.fn(),
onHide: vi.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
// Helper to get email input (it's a textbox with type email)
@@ -371,20 +375,33 @@ describe('AuthModal', () => {
describe('Google OAuth', () => {
it('should redirect to Google OAuth when Google button is clicked', () => {
// Mock window.location
// Mock window.location.href using Object.defineProperty
let mockHref = '';
const originalLocation = window.location;
delete (window as any).location;
window.location = { ...originalLocation, href: '' } as Location;
Object.defineProperty(window, 'location', {
value: {
...originalLocation,
get href() { return mockHref; },
set href(value: string) { mockHref = value; },
},
writable: true,
configurable: true,
});
render(<AuthModal {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /continue with google/i }));
// Check that window.location.href was set to Google OAuth URL
expect(window.location.href).toContain('accounts.google.com');
expect(mockHref).toContain('accounts.google.com');
// Restore
window.location = originalLocation;
Object.defineProperty(window, 'location', {
value: originalLocation,
writable: true,
configurable: true,
});
});
});

View File

@@ -8,16 +8,19 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { vi, type MockedFunction } from 'vitest';
import ItemCard from '../../components/ItemCard';
import { Item } from '../../types';
import { getPublicImageUrl } from '../../services/uploadService';
import { getImageUrl, getPublicImageUrl } from '../../services/uploadService';
// Mock the uploadService
jest.mock('../../services/uploadService', () => ({
getPublicImageUrl: jest.fn(),
vi.mock('../../services/uploadService', () => ({
getPublicImageUrl: vi.fn(),
getImageUrl: vi.fn(),
}));
const mockedGetPublicImageUrl = getPublicImageUrl as jest.MockedFunction<typeof getPublicImageUrl>;
const mockedGetPublicImageUrl = getPublicImageUrl as MockedFunction<typeof getPublicImageUrl>;
const mockedGetImageUrl = getImageUrl as MockedFunction<typeof getImageUrl>;
// Helper to render with Router
const renderWithRouter = (component: React.ReactElement) => {
@@ -31,10 +34,15 @@ beforeEach(() => {
if (imagePath.startsWith('https://')) return imagePath;
return `https://test-bucket.s3.us-east-1.amazonaws.com/${imagePath}`;
});
mockedGetImageUrl.mockImplementation((imagePath: string | null | undefined) => {
if (!imagePath) return '';
if (imagePath.startsWith('https://')) return imagePath;
return `https://test-bucket.s3.us-east-1.amazonaws.com/${imagePath}`;
});
});
afterEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
// Mock item data

View File

@@ -8,33 +8,34 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import { vi, type Mock } from 'vitest';
import Navbar from '../../components/Navbar';
import { rentalAPI, messageAPI } from '../../services/api';
// Mock dependencies
jest.mock('../../services/api', () => ({
vi.mock('../../services/api', () => ({
rentalAPI: {
getPendingRequestsCount: jest.fn(),
getPendingRequestsCount: vi.fn(),
},
messageAPI: {
getUnreadCount: jest.fn(),
getUnreadCount: vi.fn(),
},
}));
// Mock socket context
jest.mock('../../contexts/SocketContext', () => ({
vi.mock('../../contexts/SocketContext', () => ({
useSocket: () => ({
onNewMessage: jest.fn(() => () => {}),
onMessageRead: jest.fn(() => () => {}),
onNewMessage: vi.fn(() => () => {}),
onMessageRead: vi.fn(() => () => {}),
}),
}));
// Variable to control auth state per test
let mockUser: any = null;
const mockLogout = jest.fn();
const mockOpenAuthModal = jest.fn();
const mockLogout = vi.fn();
const mockOpenAuthModal = vi.fn();
jest.mock('../../contexts/AuthContext', () => ({
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => ({
user: mockUser,
logout: mockLogout,
@@ -43,11 +44,14 @@ jest.mock('../../contexts/AuthContext', () => ({
}));
// Mock useNavigate
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
const mockNavigate = vi.fn();
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual('react-router-dom');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
// Helper to render with Router
const renderWithRouter = (component: React.ReactElement) => {
@@ -56,12 +60,12 @@ const renderWithRouter = (component: React.ReactElement) => {
describe('Navbar', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
mockUser = null;
// Default mock implementations
(rentalAPI.getPendingRequestsCount as jest.Mock).mockResolvedValue({ data: { count: 0 } });
(messageAPI.getUnreadCount as jest.Mock).mockResolvedValue({ data: { count: 0 } });
(rentalAPI.getPendingRequestsCount as Mock).mockResolvedValue({ data: { count: 0 } });
(messageAPI.getUnreadCount as Mock).mockResolvedValue({ data: { count: 0 } });
});
describe('Branding', () => {
@@ -193,42 +197,70 @@ describe('Navbar', () => {
it('should show profile link in dropdown', () => {
renderWithRouter(<Navbar />);
// Click the dropdown toggle to expand the menu
const avatarButton = screen.getByRole('button', { name: /John Doe's avatar/i });
fireEvent.click(avatarButton);
expect(screen.getByRole('link', { name: /Profile/i })).toHaveAttribute('href', '/profile');
});
it('should show renting link in dropdown', () => {
renderWithRouter(<Navbar />);
// Click the dropdown toggle to expand the menu
const avatarButton = screen.getByRole('button', { name: /John Doe's avatar/i });
fireEvent.click(avatarButton);
expect(screen.getByRole('link', { name: /Renting/i })).toHaveAttribute('href', '/renting');
});
it('should show owning link in dropdown', () => {
renderWithRouter(<Navbar />);
// Click the dropdown toggle to expand the menu
const avatarButton = screen.getByRole('button', { name: /John Doe's avatar/i });
fireEvent.click(avatarButton);
expect(screen.getByRole('link', { name: /Owning/i })).toHaveAttribute('href', '/owning');
});
it('should show messages link in dropdown', () => {
renderWithRouter(<Navbar />);
// Click the dropdown toggle to expand the menu
const avatarButton = screen.getByRole('button', { name: /John Doe's avatar/i });
fireEvent.click(avatarButton);
expect(screen.getByRole('link', { name: /Messages/i })).toHaveAttribute('href', '/messages');
});
it('should show forum link in dropdown', () => {
renderWithRouter(<Navbar />);
// Click the dropdown toggle to expand the menu
const avatarButton = screen.getByRole('button', { name: /John Doe's avatar/i });
fireEvent.click(avatarButton);
expect(screen.getByRole('link', { name: /Forum/i })).toHaveAttribute('href', '/forum');
});
it('should show earnings link in dropdown', () => {
renderWithRouter(<Navbar />);
// Click the dropdown toggle to expand the menu
const avatarButton = screen.getByRole('button', { name: /John Doe's avatar/i });
fireEvent.click(avatarButton);
expect(screen.getByRole('link', { name: /Earnings/i })).toHaveAttribute('href', '/earnings');
});
it('should call logout and navigate home when logout is clicked', () => {
renderWithRouter(<Navbar />);
// Click the dropdown toggle to expand the menu
const avatarButton = screen.getByRole('button', { name: /John Doe's avatar/i });
fireEvent.click(avatarButton);
const logoutButton = screen.getByRole('button', { name: /Logout/i });
fireEvent.click(logoutButton);

View File

@@ -1,20 +1,21 @@
import React from 'react';
import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
import { vi, type Mocked, type MockedFunction } from 'vitest';
import { AuthProvider, useAuth } from '../../contexts/AuthContext';
import { mockUser } from '../../mocks/handlers';
// Mock the API module
jest.mock('../../services/api', () => {
vi.mock('../../services/api', () => {
const mockAuthAPI = {
login: jest.fn(),
register: jest.fn(),
googleLogin: jest.fn(),
logout: jest.fn(),
getStatus: jest.fn(),
login: vi.fn(),
register: vi.fn(),
googleLogin: vi.fn(),
logout: vi.fn(),
getStatus: vi.fn(),
};
const mockFetchCSRFToken = jest.fn().mockResolvedValue('test-csrf-token');
const mockResetCSRFToken = jest.fn();
const mockFetchCSRFToken = vi.fn().mockResolvedValue('test-csrf-token');
const mockResetCSRFToken = vi.fn();
return {
authAPI: mockAuthAPI,
@@ -26,9 +27,18 @@ jest.mock('../../services/api', () => {
// Get mocked modules
import { authAPI, fetchCSRFToken, resetCSRFToken } from '../../services/api';
const mockAuthAPI = authAPI as jest.Mocked<typeof authAPI>;
const mockFetchCSRFToken = fetchCSRFToken as jest.MockedFunction<typeof fetchCSRFToken>;
const mockResetCSRFToken = resetCSRFToken as jest.MockedFunction<typeof resetCSRFToken>;
const mockAuthAPI = authAPI as Mocked<typeof authAPI>;
const mockFetchCSRFToken = fetchCSRFToken as MockedFunction<typeof fetchCSRFToken>;
const mockResetCSRFToken = resetCSRFToken as MockedFunction<typeof resetCSRFToken>;
// Helper to create mock Axios response
const mockAxiosResponse = <T,>(data: T) => ({
data,
status: 200,
statusText: 'OK',
headers: {},
config: {} as any,
});
// Test component that uses the auth context
const TestComponent: React.FC = () => {
@@ -64,17 +74,17 @@ const renderWithAuth = (ui: React.ReactElement = <TestComponent />) => {
describe('AuthContext', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
// Default: user is authenticated
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: true, user: mockUser },
});
mockAuthAPI.getStatus.mockResolvedValue(
mockAxiosResponse({ authenticated: true, user: mockUser })
);
mockFetchCSRFToken.mockResolvedValue('test-csrf-token');
});
describe('useAuth hook', () => {
it('throws error when used outside AuthProvider', () => {
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<TestComponent />)).toThrow('useAuth must be used within an AuthProvider');
@@ -100,9 +110,9 @@ describe('AuthContext', () => {
});
it('sets user to null when not authenticated', async () => {
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: false, user: null },
});
mockAuthAPI.getStatus.mockResolvedValue(
mockAxiosResponse({ authenticated: false, user: null })
);
renderWithAuth();
@@ -128,13 +138,13 @@ describe('AuthContext', () => {
describe('Login', () => {
it('logs in successfully with valid credentials', async () => {
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: false, user: null },
});
mockAuthAPI.getStatus.mockResolvedValue(
mockAxiosResponse({ authenticated: false, user: null })
);
mockAuthAPI.login.mockResolvedValue({
data: { user: mockUser },
});
mockAuthAPI.login.mockResolvedValue(
mockAxiosResponse({ user: mockUser })
);
renderWithAuth();
@@ -159,9 +169,9 @@ describe('AuthContext', () => {
});
it('keeps user as null when login fails', async () => {
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: false, user: null },
});
mockAuthAPI.getStatus.mockResolvedValue(
mockAxiosResponse({ authenticated: false, user: null })
);
mockAuthAPI.login.mockRejectedValue(new Error('Invalid credentials'));
@@ -213,13 +223,13 @@ describe('AuthContext', () => {
describe('Registration', () => {
it('registers a new user successfully', async () => {
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: false, user: null },
});
mockAuthAPI.getStatus.mockResolvedValue(
mockAxiosResponse({ authenticated: false, user: null })
);
mockAuthAPI.register.mockResolvedValue({
data: { user: { ...mockUser, email: 'new@example.com', isVerified: false } },
});
mockAuthAPI.register.mockResolvedValue(
mockAxiosResponse({ user: { ...mockUser, email: 'new@example.com', isVerified: false } })
);
renderWithAuth();
@@ -239,13 +249,13 @@ describe('AuthContext', () => {
describe('Google Login', () => {
it('logs in with Google successfully', async () => {
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: false, user: null },
});
mockAuthAPI.getStatus.mockResolvedValue(
mockAxiosResponse({ authenticated: false, user: null })
);
mockAuthAPI.googleLogin.mockResolvedValue({
data: { user: mockUser },
});
mockAuthAPI.googleLogin.mockResolvedValue(
mockAxiosResponse({ user: mockUser })
);
renderWithAuth();
@@ -267,7 +277,7 @@ describe('AuthContext', () => {
describe('Logout', () => {
it('logs out successfully', async () => {
mockAuthAPI.logout.mockResolvedValue({ data: { message: 'Logged out' } });
mockAuthAPI.logout.mockResolvedValue(mockAxiosResponse({ message: 'Logged out' }));
renderWithAuth();
@@ -403,9 +413,9 @@ describe('AuthContext', () => {
mockAuthAPI.getStatus.mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve({ data: { authenticated: false, user: null } });
return Promise.resolve(mockAxiosResponse({ authenticated: false, user: null }));
}
return Promise.resolve({ data: { authenticated: true, user: mockUser } });
return Promise.resolve(mockAxiosResponse({ authenticated: true, user: mockUser }));
});
renderWithAuth();

View File

@@ -1,4 +1,5 @@
import { renderHook } from '@testing-library/react';
import { vi } from 'vitest';
import { useAddressAutocomplete, usStates } from '../../hooks/useAddressAutocomplete';
import { PlaceDetails } from '../../services/placesService';
@@ -153,7 +154,7 @@ describe('useAddressAutocomplete', () => {
});
it('sets state to empty string for unknown states', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const place: PlaceDetails = {
formattedAddress: '123 Street, City, XX 12345, USA',
@@ -236,7 +237,7 @@ describe('useAddressAutocomplete', () => {
});
it('returns null and logs error when parsing fails', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Pass an object that will cause an error when accessing nested properties
const invalidPlace = {

View File

@@ -5,6 +5,7 @@
* direct uploads, and signed URL generation for private content.
*/
import { vi, type Mocked } from 'vitest';
import api from '../../services/api';
import {
getPublicImageUrl,
@@ -13,15 +14,14 @@ import {
uploadToS3,
confirmUploads,
uploadFile,
uploadFiles,
getSignedUrl,
PresignedUrlResponse,
} from '../../services/uploadService';
// Mock the api module
jest.mock('../../services/api');
vi.mock('../../services/api');
const mockedApi = api as jest.Mocked<typeof api>;
const mockedApi = api as Mocked<typeof api>;
// Mock XMLHttpRequest for uploadToS3 tests
class MockXMLHttpRequest {
@@ -93,11 +93,11 @@ const originalXMLHttpRequest = global.XMLHttpRequest;
describe('Upload Service', () => {
beforeEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
MockXMLHttpRequest.reset();
// Reset environment variables
process.env.REACT_APP_S3_BUCKET = 'test-bucket';
process.env.REACT_APP_AWS_REGION = 'us-east-1';
// Reset environment variables using stubEnv for Vitest
vi.stubEnv('VITE_S3_BUCKET', 'test-bucket');
vi.stubEnv('VITE_AWS_REGION', 'us-east-1');
// Mock XMLHttpRequest globally
(global as unknown as { XMLHttpRequest: typeof MockXMLHttpRequest }).XMLHttpRequest = MockXMLHttpRequest;
});
@@ -140,12 +140,6 @@ describe('Upload Service', () => {
const key = 'forum/550e8400-e29b-41d4-a716-446655440000.jpg';
expect(getPublicImageUrl(key)).toContain('forum/');
});
it('should use default region when not set', () => {
delete process.env.REACT_APP_AWS_REGION;
const key = 'items/uuid.jpg';
expect(getPublicImageUrl(key)).toContain('us-east-1');
});
});
describe('getPresignedUrl', () => {
@@ -153,6 +147,7 @@ describe('Upload Service', () => {
const mockResponse: PresignedUrlResponse = {
uploadUrl: 'https://presigned-url.s3.amazonaws.com/items/uuid.jpg?signature=abc',
key: 'items/550e8400-e29b-41d4-a716-446655440000.jpg',
stagingKey: null,
publicUrl: 'https://bucket.s3.amazonaws.com/items/550e8400-e29b-41d4-a716-446655440000.jpg',
expiresAt: new Date().toISOString(),
};
@@ -213,19 +208,21 @@ describe('Upload Service', () => {
{
uploadUrl: 'https://presigned-url1.s3.amazonaws.com',
key: 'items/uuid1.jpg',
stagingKey: null,
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid1.jpg',
expiresAt: new Date().toISOString(),
},
{
uploadUrl: 'https://presigned-url2.s3.amazonaws.com',
key: 'items/uuid2.png',
stagingKey: null,
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid2.png',
expiresAt: new Date().toISOString(),
},
];
it('should request batch presigned URLs', async () => {
mockedApi.post.mockResolvedValue({ data: { uploads: mockResponses } });
mockedApi.post.mockResolvedValue({ data: { uploads: mockResponses, baseKey: 'base-key' } });
const result = await getPresignedUrls('item', mockFiles);
@@ -236,15 +233,15 @@ describe('Upload Service', () => {
{ contentType: 'image/png', fileName: 'photo2.png', fileSize: mockFiles[1].size },
],
});
expect(result).toEqual(mockResponses);
expect(result).toEqual({ uploads: mockResponses, baseKey: 'base-key' });
});
it('should handle empty file array', async () => {
mockedApi.post.mockResolvedValue({ data: { uploads: [] } });
mockedApi.post.mockResolvedValue({ data: { uploads: [], baseKey: undefined } });
const result = await getPresignedUrls('item', []);
expect(result).toEqual([]);
expect(result).toEqual({ uploads: [], baseKey: undefined });
});
});
@@ -262,7 +259,7 @@ describe('Upload Service', () => {
});
it('should call onProgress callback during upload', async () => {
const onProgress = jest.fn();
const onProgress = vi.fn();
await uploadToS3(mockFile, mockUploadUrl, { onProgress });
@@ -318,6 +315,7 @@ describe('Upload Service', () => {
const presignResponse: PresignedUrlResponse = {
uploadUrl: 'https://presigned.s3.amazonaws.com/items/uuid.jpg',
key: 'items/uuid.jpg',
stagingKey: null,
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid.jpg',
expiresAt: new Date().toISOString(),
};
@@ -362,7 +360,7 @@ describe('Upload Service', () => {
});
it('should pass onProgress to uploadToS3', async () => {
const onProgress = jest.fn();
const onProgress = vi.fn();
mockedApi.post.mockResolvedValueOnce({ data: presignResponse });
mockedApi.post.mockResolvedValueOnce({
@@ -396,150 +394,8 @@ describe('Upload Service', () => {
});
});
describe('uploadFiles', () => {
const mockFiles = [
new File(['test1'], 'photo1.jpg', { type: 'image/jpeg' }),
new File(['test2'], 'photo2.png', { type: 'image/png' }),
];
const presignResponses: PresignedUrlResponse[] = [
{
uploadUrl: 'https://presigned1.s3.amazonaws.com/items/uuid1.jpg',
key: 'items/uuid1.jpg',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid1.jpg',
expiresAt: new Date().toISOString(),
},
{
uploadUrl: 'https://presigned2.s3.amazonaws.com/items/uuid2.png',
key: 'items/uuid2.png',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid2.png',
expiresAt: new Date().toISOString(),
},
];
it('should return empty array for empty files array', async () => {
const result = await uploadFiles('item', []);
expect(result).toEqual([]);
expect(mockedApi.post).not.toHaveBeenCalled();
});
it('should complete full batch upload flow successfully', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
mockedApi.post.mockResolvedValueOnce({
data: {
confirmed: presignResponses.map((p) => p.key),
total: 2,
},
});
const result = await uploadFiles('item', mockFiles);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
key: 'items/uuid1.jpg',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid1.jpg',
});
expect(result[1]).toEqual({
key: 'items/uuid2.png',
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid2.png',
});
// Verify batch presign was called
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign-batch', {
uploadType: 'item',
files: [
{ contentType: 'image/jpeg', fileName: 'photo1.jpg', fileSize: mockFiles[0].size },
{ contentType: 'image/png', fileName: 'photo2.png', fileSize: mockFiles[1].size },
],
});
// Verify confirm was called with all keys
expect(mockedApi.post).toHaveBeenCalledWith('/upload/confirm', {
keys: ['items/uuid1.jpg', 'items/uuid2.png'],
});
});
it('should filter out unconfirmed uploads', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
// Only first file confirmed
mockedApi.post.mockResolvedValueOnce({
data: {
confirmed: ['items/uuid1.jpg'],
total: 2,
},
});
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
const result = await uploadFiles('item', mockFiles);
// Only confirmed uploads should be returned
expect(result).toHaveLength(1);
expect(result[0].key).toBe('items/uuid1.jpg');
// Should log warning about failed verification
expect(consoleSpy).toHaveBeenCalledWith('1 uploads failed verification');
consoleSpy.mockRestore();
});
it('should handle all uploads failing verification', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
mockedApi.post.mockResolvedValueOnce({
data: {
confirmed: [],
total: 2,
},
});
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
const result = await uploadFiles('item', mockFiles);
expect(result).toHaveLength(0);
expect(consoleSpy).toHaveBeenCalledWith('2 uploads failed verification');
consoleSpy.mockRestore();
});
it('should upload all files in parallel', async () => {
mockedApi.post.mockResolvedValueOnce({ data: { uploads: presignResponses } });
mockedApi.post.mockResolvedValueOnce({
data: {
confirmed: presignResponses.map((p) => p.key),
total: 2,
},
});
await uploadFiles('item', mockFiles);
// Should have created 2 XHR instances for parallel uploads
expect(MockXMLHttpRequest.instances.length).toBe(2);
});
it('should work with different upload types', async () => {
const forumResponses = presignResponses.map((r) => ({
...r,
key: r.key.replace('items/', 'forum/'),
publicUrl: r.publicUrl.replace('items/', 'forum/'),
}));
mockedApi.post.mockResolvedValueOnce({ data: { uploads: forumResponses } });
mockedApi.post.mockResolvedValueOnce({
data: {
confirmed: forumResponses.map((p) => p.key),
total: 2,
},
});
const result = await uploadFiles('forum', mockFiles);
expect(result[0].key).toBe('forum/uuid1.jpg');
expect(mockedApi.post).toHaveBeenCalledWith('/upload/presign-batch', expect.objectContaining({
uploadType: 'forum',
}));
});
});
// Note: uploadFiles function was removed from uploadService and replaced with uploadImagesWithVariants
// Tests for batch uploads would need to be updated to test the new function
describe('getSignedUrl', () => {
it('should request signed URL for private content', async () => {

View File

@@ -1,7 +1,7 @@
import React, { useState } from "react";
import axios from "axios";
const API_URL = process.env.REACT_APP_API_URL || "http://localhost:5001";
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:5001";
const AlphaGate: React.FC = () => {
const [code, setCode] = useState("");

View File

@@ -101,7 +101,7 @@ const AuthModal: React.FC<AuthModalProps> = ({
};
const handleGoogleLogin = () => {
const clientId = process.env.REACT_APP_GOOGLE_CLIENT_ID;
const clientId = import.meta.env.VITE_GOOGLE_CLIENT_ID;
const redirectUri = `${window.location.origin}/auth/google/callback`;
const scope = "openid email profile";
const responseType = "code";

View File

@@ -7,7 +7,7 @@ import {
import { stripeAPI, rentalAPI } from "../services/api";
const stripePromise = loadStripe(
process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || ""
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ""
);
interface EmbeddedStripeCheckoutProps {

View File

@@ -32,8 +32,8 @@ const GoogleMapWithRadius: React.FC<GoogleMapWithRadiusProps> = ({
const { zoom = 12 } = mapOptions;
// Get API key and Map ID from environment
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_PUBLIC_API_KEY;
const mapId = process.env.REACT_APP_GOOGLE_MAPS_MAP_ID;
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_PUBLIC_API_KEY;
const mapId = import.meta.env.VITE_GOOGLE_MAPS_MAP_ID;
// Refs for map container and instances
const mapRef = useRef<HTMLDivElement>(null);

View File

@@ -45,8 +45,8 @@ const SearchResultsMap: React.FC<SearchResultsMapProps> = ({
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_PUBLIC_API_KEY;
const mapId = process.env.REACT_APP_GOOGLE_MAPS_MAP_ID;
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_PUBLIC_API_KEY;
const mapId = import.meta.env.VITE_GOOGLE_MAPS_MAP_ID;
// Clean up markers
const clearMarkers = useCallback(() => {

View File

@@ -31,7 +31,7 @@ const StripeConnectOnboarding: React.FC<StripeConnectOnboardingProps> = ({
const initializeStripeConnect = useCallback(async () => {
try {
const publishableKey = process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY;
const publishableKey = import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY;
if (!publishableKey) {
throw new Error("Stripe publishable key not configured");
}

View File

@@ -7,7 +7,7 @@ import {
import { stripeAPI, rentalAPI } from "../services/api";
const stripePromise = loadStripe(
process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || ""
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ""
);
interface UpdatePaymentMethodProps {

View File

@@ -3,7 +3,6 @@ import ReactDOM from 'react-dom/client';
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
@@ -13,8 +12,3 @@ root.render(
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@@ -1,8 +1,9 @@
/**
* Mock server using Jest mocks instead of MSW.
* Mock server using Vitest mocks instead of MSW.
* This provides a simpler setup that works with all Node versions.
*/
import { vi } from 'vitest';
import { mockUser, mockUnverifiedUser, mockItem, mockRental } from './handlers';
// Re-export mock data
@@ -10,23 +11,23 @@ export { mockUser, mockUnverifiedUser, mockItem, mockRental };
// Mock server interface for compatibility with setup
export const server = {
listen: jest.fn(),
resetHandlers: jest.fn(),
close: jest.fn(),
use: jest.fn(),
listen: vi.fn(),
resetHandlers: vi.fn(),
close: vi.fn(),
use: vi.fn(),
};
// Setup axios mock
jest.mock('axios', () => {
vi.mock('axios', () => {
const mockAxiosInstance = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
patch: jest.fn(),
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
delete: vi.fn(),
patch: vi.fn(),
interceptors: {
request: { use: jest.fn(), eject: jest.fn() },
response: { use: jest.fn(), eject: jest.fn() },
request: { use: vi.fn(), eject: vi.fn() },
response: { use: vi.fn(), eject: vi.fn() },
},
defaults: {
headers: {
@@ -36,7 +37,7 @@ jest.mock('axios', () => {
};
return {
create: jest.fn(() => mockAxiosInstance),
create: vi.fn(() => mockAxiosInstance),
default: mockAxiosInstance,
...mockAxiosInstance,
};

View File

@@ -5,7 +5,7 @@ import { rentalAPI } from "../services/api";
import { useAuth } from "../contexts/AuthContext";
const stripePromise = loadStripe(
process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || ""
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ""
);
const CompletePayment: React.FC = () => {

View File

@@ -1,15 +0,0 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -1,6 +1,6 @@
import axios, { AxiosError, AxiosRequestConfig } from "axios";
const API_BASE_URL = process.env.REACT_APP_API_URL;
const API_BASE_URL = import.meta.env.VITE_API_URL;
// CSRF token management
let csrfToken: string | null = null;

View File

@@ -43,7 +43,7 @@ class SocketService {
*/
private getSocketUrl(): string {
// Use environment variable or default to localhost:5001 (matches backend)
return process.env.REACT_APP_BASE_URL || "http://localhost:5001";
return import.meta.env.VITE_BASE_URL || "http://localhost:5001";
}
/**

View File

@@ -9,7 +9,7 @@ import {
* Get the public URL for an image (S3 only)
*/
export const getPublicImageUrl = (
imagePath: string | null | undefined
imagePath: string | null | undefined,
): string => {
if (!imagePath) return "";
@@ -19,8 +19,8 @@ export const getPublicImageUrl = (
}
// S3 key (e.g., "profiles/uuid.jpg", "items/uuid.jpg", "forum/uuid.jpg")
const s3Bucket = process.env.REACT_APP_S3_BUCKET || "";
const s3Region = process.env.REACT_APP_AWS_REGION || "us-east-1";
const s3Bucket = import.meta.env.VITE_S3_BUCKET;
const s3Region = import.meta.env.VITE_AWS_REGION;
return `https://${s3Bucket}.s3.${s3Region}.amazonaws.com/${imagePath}`;
};
@@ -49,7 +49,7 @@ interface UploadOptions {
*/
export async function getPresignedUrl(
uploadType: UploadType,
file: File
file: File,
): Promise<PresignedUrlResponse> {
const response = await api.post("/upload/presign", {
uploadType,
@@ -71,7 +71,7 @@ interface BatchPresignResponse {
*/
export async function getPresignedUrls(
uploadType: UploadType,
files: File[]
files: File[],
): Promise<BatchPresignResponse> {
const response = await api.post("/upload/presign-batch", {
uploadType,
@@ -90,7 +90,7 @@ export async function getPresignedUrls(
export async function uploadToS3(
file: File,
uploadUrl: string,
options: UploadOptions = {}
options: UploadOptions = {},
): Promise<void> {
const { onProgress, maxRetries = 3 } = options;
@@ -133,7 +133,7 @@ export async function uploadToS3(
* Confirm that files have been uploaded to S3
*/
export async function confirmUploads(
keys: string[]
keys: string[],
): Promise<{ confirmed: string[]; total: number }> {
const response = await api.post("/upload/confirm", { keys });
return response.data;
@@ -145,7 +145,7 @@ export async function confirmUploads(
export async function uploadFile(
uploadType: UploadType,
file: File,
options: UploadOptions = {}
options: UploadOptions = {},
): Promise<{ key: string; publicUrl: string }> {
// Get presigned URL
const presigned = await getPresignedUrl(uploadType, file);
@@ -170,7 +170,7 @@ export async function uploadFile(
*/
export async function getSignedUrl(key: string): Promise<string> {
const response = await api.get(
`/upload/signed-url/${encodeURIComponent(key)}`
`/upload/signed-url/${encodeURIComponent(key)}`,
);
return response.data.url;
}
@@ -181,7 +181,7 @@ export async function getSignedUrl(key: string): Promise<string> {
*/
export async function getSignedImageUrl(
baseKey: string,
size: "thumbnail" | "medium" | "original" = "original"
size: "thumbnail" | "medium" | "original" = "original",
): Promise<string> {
const suffix = getSizeSuffix(size);
const variantKey = getVariantKey(baseKey, suffix);
@@ -194,7 +194,7 @@ export async function getSignedImageUrl(
*/
export function getImageUrl(
baseKey: string | null | undefined,
size: "thumbnail" | "medium" | "original" = "original"
size: "thumbnail" | "medium" | "original" = "original",
): string {
if (!baseKey) return "";
@@ -215,14 +215,18 @@ export interface UploadWithResizeOptions extends UploadOptions {
export async function uploadImageWithVariants(
uploadType: UploadType,
file: File,
options: UploadWithResizeOptions = {}
options: UploadWithResizeOptions = {},
): Promise<{ baseKey: string; publicUrl: string; variants: string[] }> {
const { onProgress, skipResize } = options;
// If skipping resize, use regular upload
if (skipResize) {
const result = await uploadFile(uploadType, file, { onProgress });
return { baseKey: result.key, publicUrl: result.publicUrl, variants: [result.key] };
return {
baseKey: result.key,
publicUrl: result.publicUrl,
variants: [result.key],
};
}
// Generate resized variants
@@ -247,13 +251,20 @@ export async function uploadImageWithVariants(
if (onProgress) {
const fileContribution = (variantFile.size / totalBytes) * percent;
// Approximate combined progress
onProgress(Math.min(99, Math.round(uploadedBytes / totalBytes * 100 + fileContribution)));
onProgress(
Math.min(
99,
Math.round(
(uploadedBytes / totalBytes) * 100 + fileContribution,
),
),
);
}
},
}).then(() => {
uploadedBytes += files[i].size;
})
)
}),
),
);
// Confirm all uploads - use stagingKey if present (image processing enabled), else key
@@ -264,7 +275,9 @@ export async function uploadImageWithVariants(
// Use the final keys for database storage (not staging keys)
const finalKeys = presignedUrls.map((p) => p.key);
const originalKey = finalKeys.find((k) => !k.includes("_th") && !k.includes("_md")) || finalKeys[0];
const originalKey =
finalKeys.find((k) => !k.includes("_th") && !k.includes("_md")) ||
finalKeys[0];
return {
baseKey: originalKey,
@@ -280,12 +293,12 @@ export async function uploadImageWithVariants(
export async function uploadImagesWithVariants(
uploadType: UploadType,
files: File[],
options: UploadWithResizeOptions = {}
options: UploadWithResizeOptions = {},
): Promise<{ baseKey: string; publicUrl: string }[]> {
if (files.length === 0) return [];
const results = await Promise.all(
files.map((file) => uploadImageWithVariants(uploadType, file, options))
files.map((file) => uploadImageWithVariants(uploadType, file, options)),
);
return results.map((r) => ({ baseKey: r.baseKey, publicUrl: r.publicUrl }));

View File

@@ -1,17 +1,15 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// Vitest setup file
import '@testing-library/jest-dom/vitest';
import { vi, beforeAll, afterAll } from 'vitest';
// Mock window.location for tests that use navigation
const mockLocation = {
...window.location,
href: 'http://localhost:3000',
pathname: '/',
assign: jest.fn(),
replace: jest.fn(),
reload: jest.fn(),
assign: vi.fn(),
replace: vi.fn(),
reload: vi.fn(),
};
Object.defineProperty(window, 'location', {

19
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,19 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_BASE_URL: string;
readonly VITE_ENV: string;
readonly VITE_STRIPE_PUBLISHABLE_KEY: string;
readonly VITE_GOOGLE_MAPS_PUBLIC_API_KEY: string;
readonly VITE_GOOGLE_MAPS_MAP_ID: string;
readonly VITE_GOOGLE_CLIENT_ID: string;
readonly VITE_ALPHA_TESTING_ENABLED: string;
readonly VITE_S3_BUCKET: string;
readonly VITE_S3_ENABLED: string;
readonly VITE_AWS_REGION: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}