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

1
backend/.gitignore vendored
View File

@@ -1,6 +1,5 @@
node_modules/
.env
.env.*
uploads/
*.log
.DS_Store

3
frontend/.gitignore vendored
View File

@@ -11,6 +11,9 @@
# production
/build
# Vite
.vite
# misc
.DS_Store
.env.local

View File

@@ -2,14 +2,14 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Village Share - Life is too expensive. Rent or borrow from your neighbors"
/>
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="manifest" href="/manifest.json" />
<title>Village Share</title>
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
@@ -23,15 +23,6 @@
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<script type="module" src="/src/index.tsx"></script>
</body>
</html>

View File

@@ -1,31 +0,0 @@
module.exports = {
preset: 'react-app',
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/index.tsx',
'!src/reportWebVitals.ts',
'!src/**/*.d.ts',
'!src/setupTests.ts',
'!src/test-polyfills.js',
'!src/mocks/**'
],
coverageReporters: ['text', 'lcov', 'html'],
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
'<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}'
],
transformIgnorePatterns: [
'/node_modules/(?!(axios|@stripe)/).*'
],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
},
testTimeout: 10000,
coverageThreshold: {
global: {
lines: 80,
statements: 80
}
}
};

18092
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
"name": "frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"dependencies": {
"@googlemaps/js-api-loader": "^1.16.10",
"@stripe/connect-js": "^3.3.31",
@@ -12,8 +13,7 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.126",
"@types/node": "^20.0.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-router-dom": "^5.3.3",
@@ -24,51 +24,32 @@
"react-datepicker": "^9.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^6.30.1",
"react-scripts": "^5.0.1",
"socket.io-client": "^4.8.1",
"stripe": "^18.4.0",
"typescript": "^4.9.5",
"web-vitals": "^2.1.4"
"typescript": "^4.9.5"
},
"scripts": {
"start:dev": "dotenv -e .env.dev react-scripts start",
"start:qa": "dotenv -e .env.qa react-scripts start",
"start:prod": "dotenv -e .env.prod react-scripts start",
"build:dev": "dotenv -e .env.dev react-scripts build",
"build:qa": "dotenv -e .env.qa react-scripts build",
"build:prod": "dotenv -e .env.prod react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject",
"test:coverage": "react-scripts test --coverage --watchAll=false --maxWorkers=4"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"start:dev": "vite --mode dev",
"start:qa": "vite --mode qa",
"start:prod": "vite --mode prod",
"build:dev": "vite build --mode dev",
"build:qa": "vite build --mode qa",
"build:prod": "vite build --mode prod",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"devDependencies": {
"@types/google.maps": "^3.58.1",
"@vitejs/plugin-react": "^4.5.0",
"@vitest/coverage-v8": "^4.0.17",
"cross-fetch": "^4.1.0",
"dotenv-cli": "^9.0.0",
"msw": "^2.11.2"
},
"overrides": {
"nth-check": "^2.1.1",
"postcss": "^8.4.31",
"svgo": "^3.0.0",
"webpack-dev-server": "^5.2.1"
"identity-obj-proxy": "^3.0.0",
"jsdom": "^27.4.0",
"msw": "^2.11.2",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^4.0.17"
}
}

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'),
vi.mock('../../contexts/AuthContext', async () => {
const actual = await vi.importActual('../../contexts/AuthContext');
return {
...actual,
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>;
};
});
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'),
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;
}

View File

@@ -1,26 +1,21 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"types": ["vite/client", "node", "vitest/globals", "google.maps"]
},
"include": [
"src"
]
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

44
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,44 @@
/// <reference types="vitest" />
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '');
return {
plugins: [react(), tsconfigPaths()],
server: {
port: 3000,
open: false,
proxy: {
'/api': { target: env.VITE_BASE_URL || 'http://localhost:5001', changeOrigin: true },
'/socket.io': { target: env.VITE_BASE_URL || 'http://localhost:5001', changeOrigin: true, ws: true },
},
},
build: { outDir: 'build', sourcemap: true },
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/setupTests.ts'],
include: ['src/**/*.{test,spec}.{js,jsx,ts,tsx}', 'src/**/__tests__/**/*.{js,jsx,ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov', 'html'],
include: ['src/**/*.{js,jsx,ts,tsx}'],
exclude: [
'src/index.tsx',
'src/**/*.d.ts',
'src/setupTests.ts',
'src/setupEnv.ts',
'src/test-polyfills.js',
'src/mocks/**',
'src/__mocks__/**',
],
thresholds: {
lines: 80,
statements: 80,
},
},
},
};
});