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/ node_modules/
.env .env
.env.* .env.*
uploads/
*.log *.log
.DS_Store .DS_Store

3
frontend/.gitignore vendored
View File

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

View File

@@ -2,14 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <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="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#000000" />
<meta <meta
name="description" name="description"
content="Village Share - Life is too expensive. Rent or borrow from your neighbors" 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> <title>Village Share</title>
<link <link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
@@ -23,15 +23,6 @@
<body> <body>
<noscript>You need to enable JavaScript to run this app.</noscript> <noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div> <div id="root"></div>
<!-- <script type="module" src="/src/index.tsx"></script>
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`.
-->
</body> </body>
</html> </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", "name": "frontend",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"type": "module",
"dependencies": { "dependencies": {
"@googlemaps/js-api-loader": "^1.16.10", "@googlemaps/js-api-loader": "^1.16.10",
"@stripe/connect-js": "^3.3.31", "@stripe/connect-js": "^3.3.31",
@@ -12,8 +13,7 @@
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2", "@types/node": "^20.0.0",
"@types/node": "^16.18.126",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
@@ -24,51 +24,32 @@
"react-datepicker": "^9.1.0", "react-datepicker": "^9.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^6.30.1", "react-router-dom": "^6.30.1",
"react-scripts": "^5.0.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"stripe": "^18.4.0", "stripe": "^18.4.0",
"typescript": "^4.9.5", "typescript": "^4.9.5"
"web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
"start:dev": "dotenv -e .env.dev react-scripts start", "start:dev": "vite --mode dev",
"start:qa": "dotenv -e .env.qa react-scripts start", "start:qa": "vite --mode qa",
"start:prod": "dotenv -e .env.prod react-scripts start", "start:prod": "vite --mode prod",
"build:dev": "dotenv -e .env.dev react-scripts build", "build:dev": "vite build --mode dev",
"build:qa": "dotenv -e .env.qa react-scripts build", "build:qa": "vite build --mode qa",
"build:prod": "dotenv -e .env.prod react-scripts build", "build:prod": "vite build --mode prod",
"test": "react-scripts test", "preview": "vite preview",
"eject": "react-scripts eject", "test": "vitest run",
"test:coverage": "react-scripts test --coverage --watchAll=false --maxWorkers=4" "test:watch": "vitest",
}, "test:coverage": "vitest run --coverage"
"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"
]
}, },
"devDependencies": { "devDependencies": {
"@types/google.maps": "^3.58.1", "@types/google.maps": "^3.58.1",
"@vitejs/plugin-react": "^4.5.0",
"@vitest/coverage-v8": "^4.0.17",
"cross-fetch": "^4.1.0", "cross-fetch": "^4.1.0",
"dotenv-cli": "^9.0.0", "identity-obj-proxy": "^3.0.0",
"msw": "^2.11.2" "jsdom": "^27.4.0",
}, "msw": "^2.11.2",
"overrides": { "vite": "^7.3.1",
"nth-check": "^2.1.1", "vite-tsconfig-paths": "^5.1.4",
"postcss": "^8.4.31", "vitest": "^4.0.17"
"svgo": "^3.0.0",
"webpack-dev-server": "^5.2.1"
} }
} }

View File

@@ -34,7 +34,7 @@ import PrivateRoute from './components/PrivateRoute';
import axios from 'axios'; import axios from 'axios';
import './App.css'; 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 AppContent: React.FC = () => {
const { showAuthModal, authModalMode, closeAuthModal, user } = useAuth(); const { showAuthModal, authModalMode, closeAuthModal, user } = useAuth();
@@ -77,7 +77,7 @@ const AppContent: React.FC = () => {
useEffect(() => { useEffect(() => {
const checkAlphaAccess = async () => { const checkAlphaAccess = async () => {
// Bypass alpha access check if feature is disabled // 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); setHasAlphaAccess(true);
setCheckingAccess(false); setCheckingAccess(false);
return; 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 * This avoids ESM transformation issues with the axios package
*/ */
import { vi } from 'vitest';
const mockAxiosInstance = { const mockAxiosInstance = {
get: jest.fn(() => Promise.resolve({ data: {} })), get: vi.fn(() => Promise.resolve({ data: {} })),
post: jest.fn(() => Promise.resolve({ data: {} })), post: vi.fn(() => Promise.resolve({ data: {} })),
put: jest.fn(() => Promise.resolve({ data: {} })), put: vi.fn(() => Promise.resolve({ data: {} })),
delete: jest.fn(() => Promise.resolve({ data: {} })), delete: vi.fn(() => Promise.resolve({ data: {} })),
patch: jest.fn(() => Promise.resolve({ data: {} })), patch: vi.fn(() => Promise.resolve({ data: {} })),
request: jest.fn(() => Promise.resolve({ data: {} })), request: vi.fn(() => Promise.resolve({ data: {} })),
interceptors: { interceptors: {
request: { request: {
use: jest.fn(() => 0), use: vi.fn(() => 0),
eject: jest.fn(), eject: vi.fn(),
clear: jest.fn(), clear: vi.fn(),
}, },
response: { response: {
use: jest.fn(() => 0), use: vi.fn(() => 0),
eject: jest.fn(), eject: vi.fn(),
clear: jest.fn(), clear: vi.fn(),
}, },
}, },
defaults: { defaults: {
@@ -35,33 +37,33 @@ const mockAxiosInstance = {
timeout: 0, timeout: 0,
withCredentials: false, withCredentials: false,
}, },
getUri: jest.fn(), getUri: vi.fn(),
head: jest.fn(() => Promise.resolve({ data: {} })), head: vi.fn(() => Promise.resolve({ data: {} })),
options: jest.fn(() => Promise.resolve({ data: {} })), options: vi.fn(() => Promise.resolve({ data: {} })),
postForm: jest.fn(() => Promise.resolve({ data: {} })), postForm: vi.fn(() => Promise.resolve({ data: {} })),
putForm: jest.fn(() => Promise.resolve({ data: {} })), putForm: vi.fn(() => Promise.resolve({ data: {} })),
patchForm: jest.fn(() => Promise.resolve({ data: {} })), patchForm: vi.fn(() => Promise.resolve({ data: {} })),
}; };
const axios = { const axios = {
...mockAxiosInstance, ...mockAxiosInstance,
create: jest.fn(() => ({ ...mockAxiosInstance })), create: vi.fn(() => ({ ...mockAxiosInstance })),
isAxiosError: jest.fn((error: any) => error?.isAxiosError === true), isAxiosError: vi.fn((error: any) => error?.isAxiosError === true),
isCancel: jest.fn(() => false), isCancel: vi.fn(() => false),
all: jest.fn((promises: Promise<any>[]) => Promise.all(promises)), all: vi.fn((promises: Promise<any>[]) => Promise.all(promises)),
spread: jest.fn((callback: Function) => (arr: any[]) => callback(...arr)), spread: vi.fn((callback: Function) => (arr: any[]) => callback(...arr)),
toFormData: jest.fn(), toFormData: vi.fn(),
formToJSON: jest.fn(), formToJSON: vi.fn(),
CancelToken: { CancelToken: {
source: jest.fn(() => ({ source: vi.fn(() => ({
token: {}, token: {},
cancel: jest.fn(), cancel: vi.fn(),
})), })),
}, },
Axios: jest.fn(), Axios: vi.fn(),
AxiosError: jest.fn(), AxiosError: vi.fn(),
Cancel: jest.fn(), Cancel: vi.fn(),
CanceledError: jest.fn(), CanceledError: vi.fn(),
VERSION: '1.0.0', VERSION: '1.0.0',
default: mockAxiosInstance, default: mockAxiosInstance,
}; };

View File

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

View File

@@ -8,31 +8,35 @@
import React from 'react'; import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import AuthModal from '../../components/AuthModal'; import AuthModal from '../../components/AuthModal';
// Mock the auth context // Mock the auth context
const mockLogin = jest.fn(); const mockLogin = vi.fn();
const mockRegister = jest.fn(); const mockRegister = vi.fn();
jest.mock('../../contexts/AuthContext', () => ({ vi.mock('../../contexts/AuthContext', async () => {
...jest.requireActual('../../contexts/AuthContext'), const actual = await vi.importActual('../../contexts/AuthContext');
return {
...actual,
useAuth: () => ({ useAuth: () => ({
login: mockLogin, login: mockLogin,
register: mockRegister, register: mockRegister,
user: null, user: null,
loading: false, 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', () => { // Mock child components
return function MockPasswordInput({ 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, id,
label, label,
value, value,
@@ -59,11 +63,11 @@ jest.mock('../../components/PasswordInput', () => {
/> />
</div> </div>
); );
}; },
}); }));
jest.mock('../../components/ForgotPasswordModal', () => { vi.mock('../../components/ForgotPasswordModal', () => ({
return function MockForgotPasswordModal({ default: function MockForgotPasswordModal({
show, show,
onHide, onHide,
onBackToLogin onBackToLogin
@@ -79,11 +83,11 @@ jest.mock('../../components/ForgotPasswordModal', () => {
<button onClick={onHide}>Close</button> <button onClick={onHide}>Close</button>
</div> </div>
); );
}; },
}); }));
jest.mock('../../components/VerificationCodeModal', () => { vi.mock('../../components/VerificationCodeModal', () => ({
return function MockVerificationCodeModal({ default: function MockVerificationCodeModal({
show, show,
onHide, onHide,
email, email,
@@ -102,17 +106,17 @@ jest.mock('../../components/VerificationCodeModal', () => {
<button onClick={onHide}>Close</button> <button onClick={onHide}>Close</button>
</div> </div>
); );
}; },
}); }));
describe('AuthModal', () => { describe('AuthModal', () => {
const defaultProps = { const defaultProps = {
show: true, show: true,
onHide: jest.fn(), onHide: vi.fn(),
}; };
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); vi.clearAllMocks();
}); });
// Helper to get email input (it's a textbox with type email) // Helper to get email input (it's a textbox with type email)
@@ -371,20 +375,33 @@ describe('AuthModal', () => {
describe('Google OAuth', () => { describe('Google OAuth', () => {
it('should redirect to Google OAuth when Google button is clicked', () => { 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; 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} />); render(<AuthModal {...defaultProps} />);
fireEvent.click(screen.getByRole('button', { name: /continue with google/i })); fireEvent.click(screen.getByRole('button', { name: /continue with google/i }));
// Check that window.location.href was set to Google OAuth URL // 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 // 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 React from 'react';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { vi, type MockedFunction } from 'vitest';
import ItemCard from '../../components/ItemCard'; import ItemCard from '../../components/ItemCard';
import { Item } from '../../types'; import { Item } from '../../types';
import { getPublicImageUrl } from '../../services/uploadService'; import { getImageUrl, getPublicImageUrl } from '../../services/uploadService';
// Mock the uploadService // Mock the uploadService
jest.mock('../../services/uploadService', () => ({ vi.mock('../../services/uploadService', () => ({
getPublicImageUrl: jest.fn(), 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 // Helper to render with Router
const renderWithRouter = (component: React.ReactElement) => { const renderWithRouter = (component: React.ReactElement) => {
@@ -31,10 +34,15 @@ beforeEach(() => {
if (imagePath.startsWith('https://')) return imagePath; if (imagePath.startsWith('https://')) return imagePath;
return `https://test-bucket.s3.us-east-1.amazonaws.com/${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(() => { afterEach(() => {
jest.clearAllMocks(); vi.clearAllMocks();
}); });
// Mock item data // Mock item data

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,7 @@
* direct uploads, and signed URL generation for private content. * direct uploads, and signed URL generation for private content.
*/ */
import { vi, type Mocked } from 'vitest';
import api from '../../services/api'; import api from '../../services/api';
import { import {
getPublicImageUrl, getPublicImageUrl,
@@ -13,15 +14,14 @@ import {
uploadToS3, uploadToS3,
confirmUploads, confirmUploads,
uploadFile, uploadFile,
uploadFiles,
getSignedUrl, getSignedUrl,
PresignedUrlResponse, PresignedUrlResponse,
} from '../../services/uploadService'; } from '../../services/uploadService';
// Mock the api module // 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 // Mock XMLHttpRequest for uploadToS3 tests
class MockXMLHttpRequest { class MockXMLHttpRequest {
@@ -93,11 +93,11 @@ const originalXMLHttpRequest = global.XMLHttpRequest;
describe('Upload Service', () => { describe('Upload Service', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); vi.clearAllMocks();
MockXMLHttpRequest.reset(); MockXMLHttpRequest.reset();
// Reset environment variables // Reset environment variables using stubEnv for Vitest
process.env.REACT_APP_S3_BUCKET = 'test-bucket'; vi.stubEnv('VITE_S3_BUCKET', 'test-bucket');
process.env.REACT_APP_AWS_REGION = 'us-east-1'; vi.stubEnv('VITE_AWS_REGION', 'us-east-1');
// Mock XMLHttpRequest globally // Mock XMLHttpRequest globally
(global as unknown as { XMLHttpRequest: typeof MockXMLHttpRequest }).XMLHttpRequest = MockXMLHttpRequest; (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'; const key = 'forum/550e8400-e29b-41d4-a716-446655440000.jpg';
expect(getPublicImageUrl(key)).toContain('forum/'); 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', () => { describe('getPresignedUrl', () => {
@@ -153,6 +147,7 @@ describe('Upload Service', () => {
const mockResponse: PresignedUrlResponse = { const mockResponse: PresignedUrlResponse = {
uploadUrl: 'https://presigned-url.s3.amazonaws.com/items/uuid.jpg?signature=abc', uploadUrl: 'https://presigned-url.s3.amazonaws.com/items/uuid.jpg?signature=abc',
key: 'items/550e8400-e29b-41d4-a716-446655440000.jpg', key: 'items/550e8400-e29b-41d4-a716-446655440000.jpg',
stagingKey: null,
publicUrl: 'https://bucket.s3.amazonaws.com/items/550e8400-e29b-41d4-a716-446655440000.jpg', publicUrl: 'https://bucket.s3.amazonaws.com/items/550e8400-e29b-41d4-a716-446655440000.jpg',
expiresAt: new Date().toISOString(), expiresAt: new Date().toISOString(),
}; };
@@ -213,19 +208,21 @@ describe('Upload Service', () => {
{ {
uploadUrl: 'https://presigned-url1.s3.amazonaws.com', uploadUrl: 'https://presigned-url1.s3.amazonaws.com',
key: 'items/uuid1.jpg', key: 'items/uuid1.jpg',
stagingKey: null,
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid1.jpg', publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid1.jpg',
expiresAt: new Date().toISOString(), expiresAt: new Date().toISOString(),
}, },
{ {
uploadUrl: 'https://presigned-url2.s3.amazonaws.com', uploadUrl: 'https://presigned-url2.s3.amazonaws.com',
key: 'items/uuid2.png', key: 'items/uuid2.png',
stagingKey: null,
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid2.png', publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid2.png',
expiresAt: new Date().toISOString(), expiresAt: new Date().toISOString(),
}, },
]; ];
it('should request batch presigned URLs', async () => { 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); const result = await getPresignedUrls('item', mockFiles);
@@ -236,15 +233,15 @@ describe('Upload Service', () => {
{ contentType: 'image/png', fileName: 'photo2.png', fileSize: mockFiles[1].size }, { 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 () => { it('should handle empty file array', async () => {
mockedApi.post.mockResolvedValue({ data: { uploads: [] } }); mockedApi.post.mockResolvedValue({ data: { uploads: [], baseKey: undefined } });
const result = await getPresignedUrls('item', []); 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 () => { it('should call onProgress callback during upload', async () => {
const onProgress = jest.fn(); const onProgress = vi.fn();
await uploadToS3(mockFile, mockUploadUrl, { onProgress }); await uploadToS3(mockFile, mockUploadUrl, { onProgress });
@@ -318,6 +315,7 @@ describe('Upload Service', () => {
const presignResponse: PresignedUrlResponse = { const presignResponse: PresignedUrlResponse = {
uploadUrl: 'https://presigned.s3.amazonaws.com/items/uuid.jpg', uploadUrl: 'https://presigned.s3.amazonaws.com/items/uuid.jpg',
key: 'items/uuid.jpg', key: 'items/uuid.jpg',
stagingKey: null,
publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid.jpg', publicUrl: 'https://bucket.s3.amazonaws.com/items/uuid.jpg',
expiresAt: new Date().toISOString(), expiresAt: new Date().toISOString(),
}; };
@@ -362,7 +360,7 @@ describe('Upload Service', () => {
}); });
it('should pass onProgress to uploadToS3', async () => { it('should pass onProgress to uploadToS3', async () => {
const onProgress = jest.fn(); const onProgress = vi.fn();
mockedApi.post.mockResolvedValueOnce({ data: presignResponse }); mockedApi.post.mockResolvedValueOnce({ data: presignResponse });
mockedApi.post.mockResolvedValueOnce({ mockedApi.post.mockResolvedValueOnce({
@@ -396,150 +394,8 @@ describe('Upload Service', () => {
}); });
}); });
describe('uploadFiles', () => { // Note: uploadFiles function was removed from uploadService and replaced with uploadImagesWithVariants
const mockFiles = [ // Tests for batch uploads would need to be updated to test the new function
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',
}));
});
});
describe('getSignedUrl', () => { describe('getSignedUrl', () => {
it('should request signed URL for private content', async () => { it('should request signed URL for private content', async () => {

View File

@@ -1,7 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import axios from "axios"; 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 AlphaGate: React.FC = () => {
const [code, setCode] = useState(""); const [code, setCode] = useState("");

View File

@@ -101,7 +101,7 @@ const AuthModal: React.FC<AuthModalProps> = ({
}; };
const handleGoogleLogin = () => { 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 redirectUri = `${window.location.origin}/auth/google/callback`;
const scope = "openid email profile"; const scope = "openid email profile";
const responseType = "code"; const responseType = "code";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,7 +3,6 @@ import ReactDOM from 'react-dom/client';
import 'bootstrap/dist/js/bootstrap.bundle.min.js'; import 'bootstrap/dist/js/bootstrap.bundle.min.js';
import './index.css'; import './index.css';
import App from './App'; import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement document.getElementById('root') as HTMLElement
@@ -13,8 +12,3 @@ root.render(
<App /> <App />
</React.StrictMode> </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. * This provides a simpler setup that works with all Node versions.
*/ */
import { vi } from 'vitest';
import { mockUser, mockUnverifiedUser, mockItem, mockRental } from './handlers'; import { mockUser, mockUnverifiedUser, mockItem, mockRental } from './handlers';
// Re-export mock data // Re-export mock data
@@ -10,23 +11,23 @@ export { mockUser, mockUnverifiedUser, mockItem, mockRental };
// Mock server interface for compatibility with setup // Mock server interface for compatibility with setup
export const server = { export const server = {
listen: jest.fn(), listen: vi.fn(),
resetHandlers: jest.fn(), resetHandlers: vi.fn(),
close: jest.fn(), close: vi.fn(),
use: jest.fn(), use: vi.fn(),
}; };
// Setup axios mock // Setup axios mock
jest.mock('axios', () => { vi.mock('axios', () => {
const mockAxiosInstance = { const mockAxiosInstance = {
get: jest.fn(), get: vi.fn(),
post: jest.fn(), post: vi.fn(),
put: jest.fn(), put: vi.fn(),
delete: jest.fn(), delete: vi.fn(),
patch: jest.fn(), patch: vi.fn(),
interceptors: { interceptors: {
request: { use: jest.fn(), eject: jest.fn() }, request: { use: vi.fn(), eject: vi.fn() },
response: { use: jest.fn(), eject: jest.fn() }, response: { use: vi.fn(), eject: vi.fn() },
}, },
defaults: { defaults: {
headers: { headers: {
@@ -36,7 +37,7 @@ jest.mock('axios', () => {
}; };
return { return {
create: jest.fn(() => mockAxiosInstance), create: vi.fn(() => mockAxiosInstance),
default: mockAxiosInstance, default: mockAxiosInstance,
...mockAxiosInstance, ...mockAxiosInstance,
}; };

View File

@@ -5,7 +5,7 @@ import { rentalAPI } from "../services/api";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
const stripePromise = loadStripe( const stripePromise = loadStripe(
process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY || "" import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ""
); );
const CompletePayment: React.FC = () => { 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"; 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 // CSRF token management
let csrfToken: string | null = null; let csrfToken: string | null = null;

View File

@@ -43,7 +43,7 @@ class SocketService {
*/ */
private getSocketUrl(): string { private getSocketUrl(): string {
// Use environment variable or default to localhost:5001 (matches backend) // 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) * Get the public URL for an image (S3 only)
*/ */
export const getPublicImageUrl = ( export const getPublicImageUrl = (
imagePath: string | null | undefined imagePath: string | null | undefined,
): string => { ): string => {
if (!imagePath) return ""; if (!imagePath) return "";
@@ -19,8 +19,8 @@ export const getPublicImageUrl = (
} }
// S3 key (e.g., "profiles/uuid.jpg", "items/uuid.jpg", "forum/uuid.jpg") // S3 key (e.g., "profiles/uuid.jpg", "items/uuid.jpg", "forum/uuid.jpg")
const s3Bucket = process.env.REACT_APP_S3_BUCKET || ""; const s3Bucket = import.meta.env.VITE_S3_BUCKET;
const s3Region = process.env.REACT_APP_AWS_REGION || "us-east-1"; const s3Region = import.meta.env.VITE_AWS_REGION;
return `https://${s3Bucket}.s3.${s3Region}.amazonaws.com/${imagePath}`; return `https://${s3Bucket}.s3.${s3Region}.amazonaws.com/${imagePath}`;
}; };
@@ -49,7 +49,7 @@ interface UploadOptions {
*/ */
export async function getPresignedUrl( export async function getPresignedUrl(
uploadType: UploadType, uploadType: UploadType,
file: File file: File,
): Promise<PresignedUrlResponse> { ): Promise<PresignedUrlResponse> {
const response = await api.post("/upload/presign", { const response = await api.post("/upload/presign", {
uploadType, uploadType,
@@ -71,7 +71,7 @@ interface BatchPresignResponse {
*/ */
export async function getPresignedUrls( export async function getPresignedUrls(
uploadType: UploadType, uploadType: UploadType,
files: File[] files: File[],
): Promise<BatchPresignResponse> { ): Promise<BatchPresignResponse> {
const response = await api.post("/upload/presign-batch", { const response = await api.post("/upload/presign-batch", {
uploadType, uploadType,
@@ -90,7 +90,7 @@ export async function getPresignedUrls(
export async function uploadToS3( export async function uploadToS3(
file: File, file: File,
uploadUrl: string, uploadUrl: string,
options: UploadOptions = {} options: UploadOptions = {},
): Promise<void> { ): Promise<void> {
const { onProgress, maxRetries = 3 } = options; const { onProgress, maxRetries = 3 } = options;
@@ -133,7 +133,7 @@ export async function uploadToS3(
* Confirm that files have been uploaded to S3 * Confirm that files have been uploaded to S3
*/ */
export async function confirmUploads( export async function confirmUploads(
keys: string[] keys: string[],
): Promise<{ confirmed: string[]; total: number }> { ): Promise<{ confirmed: string[]; total: number }> {
const response = await api.post("/upload/confirm", { keys }); const response = await api.post("/upload/confirm", { keys });
return response.data; return response.data;
@@ -145,7 +145,7 @@ export async function confirmUploads(
export async function uploadFile( export async function uploadFile(
uploadType: UploadType, uploadType: UploadType,
file: File, file: File,
options: UploadOptions = {} options: UploadOptions = {},
): Promise<{ key: string; publicUrl: string }> { ): Promise<{ key: string; publicUrl: string }> {
// Get presigned URL // Get presigned URL
const presigned = await getPresignedUrl(uploadType, file); const presigned = await getPresignedUrl(uploadType, file);
@@ -170,7 +170,7 @@ export async function uploadFile(
*/ */
export async function getSignedUrl(key: string): Promise<string> { export async function getSignedUrl(key: string): Promise<string> {
const response = await api.get( const response = await api.get(
`/upload/signed-url/${encodeURIComponent(key)}` `/upload/signed-url/${encodeURIComponent(key)}`,
); );
return response.data.url; return response.data.url;
} }
@@ -181,7 +181,7 @@ export async function getSignedUrl(key: string): Promise<string> {
*/ */
export async function getSignedImageUrl( export async function getSignedImageUrl(
baseKey: string, baseKey: string,
size: "thumbnail" | "medium" | "original" = "original" size: "thumbnail" | "medium" | "original" = "original",
): Promise<string> { ): Promise<string> {
const suffix = getSizeSuffix(size); const suffix = getSizeSuffix(size);
const variantKey = getVariantKey(baseKey, suffix); const variantKey = getVariantKey(baseKey, suffix);
@@ -194,7 +194,7 @@ export async function getSignedImageUrl(
*/ */
export function getImageUrl( export function getImageUrl(
baseKey: string | null | undefined, baseKey: string | null | undefined,
size: "thumbnail" | "medium" | "original" = "original" size: "thumbnail" | "medium" | "original" = "original",
): string { ): string {
if (!baseKey) return ""; if (!baseKey) return "";
@@ -215,14 +215,18 @@ export interface UploadWithResizeOptions extends UploadOptions {
export async function uploadImageWithVariants( export async function uploadImageWithVariants(
uploadType: UploadType, uploadType: UploadType,
file: File, file: File,
options: UploadWithResizeOptions = {} options: UploadWithResizeOptions = {},
): Promise<{ baseKey: string; publicUrl: string; variants: string[] }> { ): Promise<{ baseKey: string; publicUrl: string; variants: string[] }> {
const { onProgress, skipResize } = options; const { onProgress, skipResize } = options;
// If skipping resize, use regular upload // If skipping resize, use regular upload
if (skipResize) { if (skipResize) {
const result = await uploadFile(uploadType, file, { onProgress }); 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 // Generate resized variants
@@ -247,13 +251,20 @@ export async function uploadImageWithVariants(
if (onProgress) { if (onProgress) {
const fileContribution = (variantFile.size / totalBytes) * percent; const fileContribution = (variantFile.size / totalBytes) * percent;
// Approximate combined progress // Approximate combined progress
onProgress(Math.min(99, Math.round(uploadedBytes / totalBytes * 100 + fileContribution))); onProgress(
Math.min(
99,
Math.round(
(uploadedBytes / totalBytes) * 100 + fileContribution,
),
),
);
} }
}, },
}).then(() => { }).then(() => {
uploadedBytes += files[i].size; uploadedBytes += files[i].size;
}) }),
) ),
); );
// Confirm all uploads - use stagingKey if present (image processing enabled), else key // 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) // Use the final keys for database storage (not staging keys)
const finalKeys = presignedUrls.map((p) => p.key); 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 { return {
baseKey: originalKey, baseKey: originalKey,
@@ -280,12 +293,12 @@ export async function uploadImageWithVariants(
export async function uploadImagesWithVariants( export async function uploadImagesWithVariants(
uploadType: UploadType, uploadType: UploadType,
files: File[], files: File[],
options: UploadWithResizeOptions = {} options: UploadWithResizeOptions = {},
): Promise<{ baseKey: string; publicUrl: string }[]> { ): Promise<{ baseKey: string; publicUrl: string }[]> {
if (files.length === 0) return []; if (files.length === 0) return [];
const results = await Promise.all( 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 })); 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. // Vitest setup file
// allows you to do things like: import '@testing-library/jest-dom/vitest';
// expect(element).toHaveTextContent(/react/i) import { vi, beforeAll, afterAll } from 'vitest';
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// Mock window.location for tests that use navigation // Mock window.location for tests that use navigation
const mockLocation = { const mockLocation = {
...window.location, ...window.location,
href: 'http://localhost:3000', href: 'http://localhost:3000',
pathname: '/', pathname: '/',
assign: jest.fn(), assign: vi.fn(),
replace: jest.fn(), replace: vi.fn(),
reload: jest.fn(), reload: vi.fn(),
}; };
Object.defineProperty(window, 'location', { 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": { "compilerOptions": {
"target": "es5", "target": "ES2020",
"lib": [ "lib": ["ES2020", "DOM", "DOM.Iterable"],
"dom", "module": "ESNext",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": 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": [ "include": ["src"],
"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,
},
},
},
};
});