imageFilenames and imageFilename, backend integration tests, frontend tests, removed username references

This commit is contained in:
jackiettran
2025-11-26 23:13:23 -05:00
parent f2d3aac029
commit 11593606aa
52 changed files with 2815 additions and 150 deletions

View File

@@ -6,7 +6,8 @@ module.exports = {
'!src/reportWebVitals.ts',
'!src/**/*.d.ts',
'!src/setupTests.ts',
'!src/test-polyfills.js'
'!src/test-polyfills.js',
'!src/mocks/**'
],
coverageReporters: ['text', 'lcov', 'html'],
setupFilesAfterEnv: ['<rootDir>/src/setupTests.ts'],
@@ -15,7 +16,7 @@ module.exports = {
'<rootDir>/src/**/*.{spec,test}.{js,jsx,ts,tsx}'
],
transformIgnorePatterns: [
'node_modules/(?!(axios|@stripe)/)'
'/node_modules/(?!(axios|@stripe)/).*'
],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'

25
frontend/jest.env.js Normal file
View File

@@ -0,0 +1,25 @@
const JSDOMEnvironment = require('jest-environment-jsdom').default;
const { TextEncoder, TextDecoder } = require('util');
class CustomEnvironment extends JSDOMEnvironment {
constructor(config, context) {
super(config, context);
// Add polyfills to global before any test code runs
this.global.TextEncoder = TextEncoder;
this.global.TextDecoder = TextDecoder;
// BroadcastChannel polyfill
this.global.BroadcastChannel = class BroadcastChannel {
constructor(name) {
this.name = name;
}
postMessage() {}
close() {}
addEventListener() {}
removeEventListener() {}
};
}
}
module.exports = CustomEnvironment;

View File

@@ -33,6 +33,7 @@
},
"devDependencies": {
"@types/google.maps": "^3.58.1",
"cross-fetch": "^4.1.0",
"dotenv-cli": "^9.0.0",
"msw": "^2.11.2"
}
@@ -6238,6 +6239,16 @@
"node": ">=10"
}
},
"node_modules/cross-fetch": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.1.0.tgz",
"integrity": "sha512-uKm5PU+MHTootlWEY+mZ4vvXoCn4fLQxT9dSc1sXVMSFkINTJVN8cAQROpwcKm8bJ/c7rgZVIBWzH5T78sNZZw==",
"dev": true,
"license": "MIT",
"dependencies": {
"node-fetch": "^2.7.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -12174,6 +12185,52 @@
"tslib": "^2.0.3"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"dev": true,
"license": "MIT"
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-forge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",

View File

@@ -57,6 +57,7 @@
},
"devDependencies": {
"@types/google.maps": "^3.58.1",
"cross-fetch": "^4.1.0",
"dotenv-cli": "^9.0.0",
"msw": "^2.11.2"
}

View File

@@ -0,0 +1,82 @@
/**
* Manual axios mock for Jest
* This avoids ESM transformation issues with the axios package
*/
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: {} })),
interceptors: {
request: {
use: jest.fn(() => 0),
eject: jest.fn(),
clear: jest.fn(),
},
response: {
use: jest.fn(() => 0),
eject: jest.fn(),
clear: jest.fn(),
},
},
defaults: {
headers: {
common: {},
get: {},
post: {},
put: {},
delete: {},
patch: {},
},
baseURL: '',
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: {} })),
};
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(),
CancelToken: {
source: jest.fn(() => ({
token: {},
cancel: jest.fn(),
})),
},
Axios: jest.fn(),
AxiosError: jest.fn(),
Cancel: jest.fn(),
CanceledError: jest.fn(),
VERSION: '1.0.0',
default: mockAxiosInstance,
};
export default axios;
export const AxiosError = class extends Error {
isAxiosError = true;
response?: any;
request?: any;
config?: any;
code?: string;
constructor(message?: string) {
super(message);
this.name = 'AxiosError';
}
};
export type { AxiosRequestConfig } from 'axios';

View File

@@ -0,0 +1,461 @@
import React from 'react';
import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
import { AuthProvider, useAuth } from '../../contexts/AuthContext';
import { mockUser } from '../../mocks/handlers';
// Mock the API module
jest.mock('../../services/api', () => {
const mockAuthAPI = {
login: jest.fn(),
register: jest.fn(),
googleLogin: jest.fn(),
logout: jest.fn(),
getStatus: jest.fn(),
};
const mockFetchCSRFToken = jest.fn().mockResolvedValue('test-csrf-token');
const mockResetCSRFToken = jest.fn();
return {
authAPI: mockAuthAPI,
fetchCSRFToken: mockFetchCSRFToken,
resetCSRFToken: mockResetCSRFToken,
};
});
// 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>;
// Test component that uses the auth context
const TestComponent: React.FC = () => {
const auth = useAuth();
return (
<div>
<div data-testid="loading">{auth.loading ? 'loading' : 'not-loading'}</div>
<div data-testid="user">{auth.user ? auth.user.email : 'no-user'}</div>
<div data-testid="verified">{auth.user?.isVerified ? 'verified' : 'not-verified'}</div>
<div data-testid="modal-open">{auth.showAuthModal ? 'open' : 'closed'}</div>
<div data-testid="modal-mode">{auth.authModalMode}</div>
<button onClick={() => auth.login('test@example.com', 'password123')}>Login</button>
<button onClick={() => auth.register({ email: 'new@example.com', username: 'newuser', password: 'password123' })}>Register</button>
<button onClick={() => auth.googleLogin('valid-google-code')}>Google Login</button>
<button onClick={() => auth.logout()}>Logout</button>
<button onClick={() => auth.openAuthModal('login')}>Open Login Modal</button>
<button onClick={() => auth.openAuthModal('signup')}>Open Signup Modal</button>
<button onClick={() => auth.closeAuthModal()}>Close Modal</button>
<button onClick={() => auth.checkAuth()}>Check Auth</button>
</div>
);
};
// Wrapper component for testing
const renderWithAuth = (ui: React.ReactElement = <TestComponent />) => {
return render(
<AuthProvider>
{ui}
</AuthProvider>
);
};
describe('AuthContext', () => {
beforeEach(() => {
jest.clearAllMocks();
// Default: user is authenticated
mockAuthAPI.getStatus.mockResolvedValue({
data: { 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(() => {});
expect(() => render(<TestComponent />)).toThrow('useAuth must be used within an AuthProvider');
consoleError.mockRestore();
});
});
describe('Initial State', () => {
it('starts with loading state', () => {
renderWithAuth();
expect(screen.getByTestId('loading')).toHaveTextContent('loading');
});
it('checks authentication status on mount', async () => {
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
expect(screen.getByTestId('user')).toHaveTextContent('test@example.com');
expect(mockAuthAPI.getStatus).toHaveBeenCalled();
});
it('sets user to null when not authenticated', async () => {
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: false, user: null },
});
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
expect(screen.getByTestId('user')).toHaveTextContent('no-user');
});
it('handles network errors gracefully', async () => {
mockAuthAPI.getStatus.mockRejectedValue(new Error('Network error'));
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
expect(screen.getByTestId('user')).toHaveTextContent('no-user');
});
});
describe('Login', () => {
it('logs in successfully with valid credentials', async () => {
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: false, user: null },
});
mockAuthAPI.login.mockResolvedValue({
data: { user: mockUser },
});
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
expect(screen.getByTestId('user')).toHaveTextContent('no-user');
await act(async () => {
fireEvent.click(screen.getByText('Login'));
});
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('test@example.com');
});
expect(mockAuthAPI.login).toHaveBeenCalledWith({
email: 'test@example.com',
password: 'password123',
});
});
it('keeps user as null when login fails', async () => {
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: false, user: null },
});
mockAuthAPI.login.mockRejectedValue(new Error('Invalid credentials'));
// Create a test component that captures login errors
const LoginErrorTestComponent: React.FC = () => {
const auth = useAuth();
const [error, setError] = React.useState<string | null>(null);
const handleLogin = async () => {
try {
await auth.login('test@example.com', 'wrongpassword');
} catch (err: any) {
setError(err.message);
}
};
return (
<div>
<div data-testid="loading">{auth.loading ? 'loading' : 'not-loading'}</div>
<div data-testid="user">{auth.user ? auth.user.email : 'no-user'}</div>
<div data-testid="error">{error || 'no-error'}</div>
<button onClick={handleLogin}>Login</button>
</div>
);
};
render(
<AuthProvider>
<LoginErrorTestComponent />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
await act(async () => {
fireEvent.click(screen.getByText('Login'));
});
await waitFor(() => {
expect(screen.getByTestId('error')).toHaveTextContent('Invalid credentials');
});
// User should still be null after failed login
expect(screen.getByTestId('user')).toHaveTextContent('no-user');
});
});
describe('Registration', () => {
it('registers a new user successfully', async () => {
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: false, user: null },
});
mockAuthAPI.register.mockResolvedValue({
data: { user: { ...mockUser, email: 'new@example.com', isVerified: false } },
});
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
await act(async () => {
fireEvent.click(screen.getByText('Register'));
});
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('new@example.com');
});
});
});
describe('Google Login', () => {
it('logs in with Google successfully', async () => {
mockAuthAPI.getStatus.mockResolvedValue({
data: { authenticated: false, user: null },
});
mockAuthAPI.googleLogin.mockResolvedValue({
data: { user: mockUser },
});
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
await act(async () => {
fireEvent.click(screen.getByText('Google Login'));
});
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('test@example.com');
});
expect(mockAuthAPI.googleLogin).toHaveBeenCalledWith('valid-google-code');
});
});
describe('Logout', () => {
it('logs out successfully', async () => {
mockAuthAPI.logout.mockResolvedValue({ data: { message: 'Logged out' } });
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('test@example.com');
});
await act(async () => {
fireEvent.click(screen.getByText('Logout'));
});
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('no-user');
});
expect(mockResetCSRFToken).toHaveBeenCalled();
});
it('clears user state even if logout API fails', async () => {
mockAuthAPI.logout.mockRejectedValue(new Error('Server error'));
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('test@example.com');
});
await act(async () => {
fireEvent.click(screen.getByText('Logout'));
});
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('no-user');
});
});
});
describe('Auth Modal', () => {
it('opens login modal', async () => {
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
expect(screen.getByTestId('modal-open')).toHaveTextContent('closed');
await act(async () => {
fireEvent.click(screen.getByText('Open Login Modal'));
});
expect(screen.getByTestId('modal-open')).toHaveTextContent('open');
expect(screen.getByTestId('modal-mode')).toHaveTextContent('login');
});
it('opens signup modal', async () => {
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
await act(async () => {
fireEvent.click(screen.getByText('Open Signup Modal'));
});
expect(screen.getByTestId('modal-open')).toHaveTextContent('open');
expect(screen.getByTestId('modal-mode')).toHaveTextContent('signup');
});
it('closes modal', async () => {
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
await act(async () => {
fireEvent.click(screen.getByText('Open Login Modal'));
});
expect(screen.getByTestId('modal-open')).toHaveTextContent('open');
await act(async () => {
fireEvent.click(screen.getByText('Close Modal'));
});
expect(screen.getByTestId('modal-open')).toHaveTextContent('closed');
});
});
describe('updateUser', () => {
it('updates user state', async () => {
const TestComponentWithUpdate: React.FC = () => {
const auth = useAuth();
return (
<div>
<div data-testid="loading">{auth.loading ? 'loading' : 'not-loading'}</div>
<div data-testid="user-email">{auth.user?.email || 'no-user'}</div>
<div data-testid="user-name">{auth.user?.firstName || 'no-name'}</div>
<button onClick={() => auth.updateUser({
...auth.user!,
firstName: 'Updated',
lastName: 'Name',
})}>
Update User
</button>
</div>
);
};
renderWithAuth(<TestComponentWithUpdate />);
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
expect(screen.getByTestId('user-name')).toHaveTextContent('Test');
await act(async () => {
fireEvent.click(screen.getByText('Update User'));
});
expect(screen.getByTestId('user-name')).toHaveTextContent('Updated');
});
});
describe('checkAuth', () => {
it('refreshes authentication status', async () => {
let callCount = 0;
mockAuthAPI.getStatus.mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.resolve({ data: { authenticated: false, user: null } });
}
return Promise.resolve({ data: { authenticated: true, user: mockUser } });
});
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
expect(screen.getByTestId('user')).toHaveTextContent('no-user');
await act(async () => {
fireEvent.click(screen.getByText('Check Auth'));
});
await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('test@example.com');
});
});
});
describe('OAuth Callback Handling', () => {
it('skips auth check on OAuth callback page', async () => {
// Mock being on the OAuth callback page
Object.defineProperty(window, 'location', {
value: {
...window.location,
pathname: '/auth/google/callback',
},
writable: true,
});
mockAuthAPI.getStatus.mockClear();
renderWithAuth();
await waitFor(() => {
expect(screen.getByTestId('loading')).toHaveTextContent('not-loading');
});
// Status should not be called on OAuth callback page
expect(mockAuthAPI.getStatus).not.toHaveBeenCalled();
// Reset location
Object.defineProperty(window, 'location', {
value: {
...window.location,
pathname: '/',
},
writable: true,
});
});
});
});

View File

@@ -0,0 +1,346 @@
import { renderHook } from '@testing-library/react';
import { useAddressAutocomplete, usStates } from '../../hooks/useAddressAutocomplete';
import { PlaceDetails } from '../../services/placesService';
describe('useAddressAutocomplete', () => {
describe('usStates', () => {
it('contains all 50 US states', () => {
expect(usStates).toHaveLength(50);
});
it('includes common states', () => {
expect(usStates).toContain('California');
expect(usStates).toContain('New York');
expect(usStates).toContain('Texas');
expect(usStates).toContain('Florida');
});
it('states are in alphabetical order', () => {
const sorted = [...usStates].sort();
expect(usStates).toEqual(sorted);
});
});
describe('parsePlace', () => {
const { result } = renderHook(() => useAddressAutocomplete());
it('parses a complete place correctly', () => {
const place: PlaceDetails = {
formattedAddress: '123 Main Street, Los Angeles, CA 90210, USA',
addressComponents: {
streetNumber: '123',
route: 'Main Street',
locality: 'Los Angeles',
administrativeAreaLevel1: 'CA',
administrativeAreaLevel1Long: 'California',
postalCode: '90210',
country: 'US',
},
geometry: {
latitude: 34.0522,
longitude: -118.2437,
},
placeId: 'test-place-id',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.address1).toBe('123 Main Street');
expect(parsed!.city).toBe('Los Angeles');
expect(parsed!.state).toBe('California');
expect(parsed!.zipCode).toBe('90210');
expect(parsed!.country).toBe('US');
expect(parsed!.latitude).toBe(34.0522);
expect(parsed!.longitude).toBe(-118.2437);
});
it('converts state codes to full names', () => {
const place: PlaceDetails = {
formattedAddress: '456 Oak Ave, New York, NY 10001, USA',
addressComponents: {
streetNumber: '456',
route: 'Oak Ave',
locality: 'New York',
administrativeAreaLevel1: 'NY',
administrativeAreaLevel1Long: 'New York',
postalCode: '10001',
country: 'US',
},
geometry: {
latitude: 40.7128,
longitude: -74.006,
},
placeId: 'test-place-id-2',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.state).toBe('New York');
});
it('uses formatted address when street number and route are missing', () => {
const place: PlaceDetails = {
formattedAddress: 'Some Place, Austin, TX 78701, USA',
addressComponents: {
locality: 'Austin',
administrativeAreaLevel1: 'TX',
administrativeAreaLevel1Long: 'Texas',
postalCode: '78701',
country: 'US',
},
geometry: {
latitude: 30.2672,
longitude: -97.7431,
},
placeId: 'test-place-id-3',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.address1).toBe('Some Place, Austin, TX 78701, USA');
});
it('handles missing postal code gracefully', () => {
const place: PlaceDetails = {
formattedAddress: '789 Pine St, Seattle, WA, USA',
addressComponents: {
streetNumber: '789',
route: 'Pine St',
locality: 'Seattle',
administrativeAreaLevel1: 'WA',
administrativeAreaLevel1Long: 'Washington',
country: 'US',
},
geometry: {
latitude: 47.6062,
longitude: -122.3321,
},
placeId: 'test-place-id-4',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.zipCode).toBe('');
expect(parsed!.state).toBe('Washington');
});
it('handles missing city gracefully', () => {
const place: PlaceDetails = {
formattedAddress: '100 Rural Road, CO 80000, USA',
addressComponents: {
streetNumber: '100',
route: 'Rural Road',
administrativeAreaLevel1: 'CO',
administrativeAreaLevel1Long: 'Colorado',
postalCode: '80000',
country: 'US',
},
geometry: {
latitude: 39.5501,
longitude: -105.7821,
},
placeId: 'test-place-id-5',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.city).toBe('');
});
it('sets state to empty string for unknown states', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
const place: PlaceDetails = {
formattedAddress: '123 Street, City, XX 12345, USA',
addressComponents: {
streetNumber: '123',
route: 'Street',
locality: 'City',
administrativeAreaLevel1: 'XX',
administrativeAreaLevel1Long: 'Unknown State',
postalCode: '12345',
country: 'US',
},
geometry: {
latitude: 0,
longitude: 0,
},
placeId: 'test-place-id-6',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.state).toBe('');
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('State not found in dropdown options')
);
consoleSpy.mockRestore();
});
it('handles DC (District of Columbia) correctly', () => {
const place: PlaceDetails = {
formattedAddress: '1600 Pennsylvania Ave, Washington, DC 20500, USA',
addressComponents: {
streetNumber: '1600',
route: 'Pennsylvania Ave',
locality: 'Washington',
administrativeAreaLevel1: 'DC',
administrativeAreaLevel1Long: 'District of Columbia',
postalCode: '20500',
country: 'US',
},
geometry: {
latitude: 38.8977,
longitude: -77.0365,
},
placeId: 'test-place-id-dc',
};
const parsed = result.current.parsePlace(place);
// DC is not in the 50 states list, so state should be empty
expect(parsed).not.toBeNull();
expect(parsed!.city).toBe('Washington');
});
it('uses long state name from administrativeAreaLevel1Long when available', () => {
const place: PlaceDetails = {
formattedAddress: '500 Beach Blvd, Miami, FL 33101, USA',
addressComponents: {
streetNumber: '500',
route: 'Beach Blvd',
locality: 'Miami',
administrativeAreaLevel1: 'FL',
administrativeAreaLevel1Long: 'Florida',
postalCode: '33101',
country: 'US',
},
geometry: {
latitude: 25.7617,
longitude: -80.1918,
},
placeId: 'test-place-id-fl',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.state).toBe('Florida');
});
it('returns null and logs error when parsing fails', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
// Pass an object that will cause an error when accessing nested properties
const invalidPlace = {
formattedAddress: 'Test',
addressComponents: null,
geometry: { latitude: 0, longitude: 0 },
placeId: 'test',
} as unknown as PlaceDetails;
const parsed = result.current.parsePlace(invalidPlace);
expect(parsed).toBeNull();
expect(consoleSpy).toHaveBeenCalledWith(
'Error parsing place details:',
expect.any(Error)
);
consoleSpy.mockRestore();
});
it('handles only street number without route', () => {
const place: PlaceDetails = {
formattedAddress: '42, Chicago, IL 60601, USA',
addressComponents: {
streetNumber: '42',
locality: 'Chicago',
administrativeAreaLevel1: 'IL',
administrativeAreaLevel1Long: 'Illinois',
postalCode: '60601',
country: 'US',
},
geometry: {
latitude: 41.8781,
longitude: -87.6298,
},
placeId: 'test-place-id-number-only',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.address1).toBe('42');
});
it('handles only route without street number', () => {
const place: PlaceDetails = {
formattedAddress: 'Main Street, Boston, MA 02101, USA',
addressComponents: {
route: 'Main Street',
locality: 'Boston',
administrativeAreaLevel1: 'MA',
administrativeAreaLevel1Long: 'Massachusetts',
postalCode: '02101',
country: 'US',
},
geometry: {
latitude: 42.3601,
longitude: -71.0589,
},
placeId: 'test-place-id-route-only',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.address1).toBe('Main Street');
});
it('defaults country to US when not provided', () => {
const place: PlaceDetails = {
formattedAddress: '999 Test St, Denver, CO 80202',
addressComponents: {
streetNumber: '999',
route: 'Test St',
locality: 'Denver',
administrativeAreaLevel1: 'CO',
administrativeAreaLevel1Long: 'Colorado',
postalCode: '80202',
},
geometry: {
latitude: 39.7392,
longitude: -104.9903,
},
placeId: 'test-place-id-no-country',
};
const parsed = result.current.parsePlace(place);
expect(parsed).not.toBeNull();
expect(parsed!.country).toBe('US');
});
});
describe('hook stability', () => {
it('returns stable parsePlace function', () => {
const { result, rerender } = renderHook(() => useAddressAutocomplete());
const firstParsePlace = result.current.parsePlace;
rerender();
const secondParsePlace = result.current.parsePlace;
expect(firstParsePlace).toBe(secondParsePlace);
});
});
});

View File

@@ -0,0 +1,211 @@
/**
* API Service Tests
*
* Tests the API service module structure and exported functions.
* API interceptor behavior is tested in integration with AuthContext.
*/
import {
authAPI,
userAPI,
itemAPI,
rentalAPI,
messageAPI,
mapsAPI,
stripeAPI,
addressAPI,
conditionCheckAPI,
forumAPI,
feedbackAPI,
fetchCSRFToken,
resetCSRFToken,
getMessageImageUrl,
getForumImageUrl,
} from '../../services/api';
import api from '../../services/api';
describe('API Service', () => {
describe('Module Exports', () => {
it('exports authAPI with correct methods', () => {
expect(authAPI).toBeDefined();
expect(typeof authAPI.login).toBe('function');
expect(typeof authAPI.register).toBe('function');
expect(typeof authAPI.logout).toBe('function');
expect(typeof authAPI.googleLogin).toBe('function');
expect(typeof authAPI.getStatus).toBe('function');
expect(typeof authAPI.verifyEmail).toBe('function');
expect(typeof authAPI.forgotPassword).toBe('function');
expect(typeof authAPI.resetPassword).toBe('function');
});
it('exports userAPI with correct methods', () => {
expect(userAPI).toBeDefined();
expect(typeof userAPI.getProfile).toBe('function');
expect(typeof userAPI.updateProfile).toBe('function');
expect(typeof userAPI.uploadProfileImage).toBe('function');
});
it('exports itemAPI with correct methods', () => {
expect(itemAPI).toBeDefined();
expect(typeof itemAPI.getItems).toBe('function');
expect(typeof itemAPI.getItem).toBe('function');
expect(typeof itemAPI.createItem).toBe('function');
expect(typeof itemAPI.updateItem).toBe('function');
expect(typeof itemAPI.deleteItem).toBe('function');
});
it('exports rentalAPI with correct methods', () => {
expect(rentalAPI).toBeDefined();
expect(typeof rentalAPI.createRental).toBe('function');
expect(typeof rentalAPI.getRentals).toBe('function');
expect(typeof rentalAPI.getListings).toBe('function');
expect(typeof rentalAPI.updateRentalStatus).toBe('function');
expect(typeof rentalAPI.cancelRental).toBe('function');
});
it('exports messageAPI with correct methods', () => {
expect(messageAPI).toBeDefined();
expect(typeof messageAPI.getMessages).toBe('function');
expect(typeof messageAPI.getConversations).toBe('function');
expect(typeof messageAPI.sendMessage).toBe('function');
expect(typeof messageAPI.getUnreadCount).toBe('function');
});
it('exports mapsAPI with correct methods', () => {
expect(mapsAPI).toBeDefined();
expect(typeof mapsAPI.placesAutocomplete).toBe('function');
expect(typeof mapsAPI.placeDetails).toBe('function');
expect(typeof mapsAPI.geocode).toBe('function');
});
it('exports stripeAPI with correct methods', () => {
expect(stripeAPI).toBeDefined();
expect(typeof stripeAPI.getCheckoutSession).toBe('function');
expect(typeof stripeAPI.createConnectedAccount).toBe('function');
expect(typeof stripeAPI.createAccountLink).toBe('function');
expect(typeof stripeAPI.getAccountStatus).toBe('function');
});
it('exports CSRF token management functions', () => {
expect(typeof fetchCSRFToken).toBe('function');
expect(typeof resetCSRFToken).toBe('function');
});
it('exports helper functions for image URLs', () => {
expect(typeof getMessageImageUrl).toBe('function');
expect(typeof getForumImageUrl).toBe('function');
});
});
describe('Helper Functions', () => {
it('getMessageImageUrl constructs correct URL', () => {
const url = getMessageImageUrl('test-image.jpg');
expect(url).toContain('/messages/images/test-image.jpg');
});
it('getForumImageUrl constructs correct URL', () => {
const url = getForumImageUrl('forum-image.jpg');
expect(url).toContain('/uploads/forum/forum-image.jpg');
});
});
describe('CSRF Token Management', () => {
it('resetCSRFToken clears the token', () => {
// Should not throw
expect(() => resetCSRFToken()).not.toThrow();
});
});
describe('API Configuration', () => {
it('creates axios instance with correct base URL', () => {
expect(api).toBeDefined();
expect(api.defaults).toBeDefined();
});
});
});
describe('API Namespaces', () => {
describe('authAPI', () => {
it('has all authentication methods', () => {
const expectedMethods = [
'register',
'login',
'googleLogin',
'logout',
'refresh',
'getCSRFToken',
'getStatus',
'verifyEmail',
'resendVerification',
'forgotPassword',
'verifyResetToken',
'resetPassword',
];
expectedMethods.forEach((method) => {
expect((authAPI as any)[method]).toBeDefined();
expect(typeof (authAPI as any)[method]).toBe('function');
});
});
});
describe('addressAPI', () => {
it('has all address management methods', () => {
const expectedMethods = [
'getAddresses',
'createAddress',
'updateAddress',
'deleteAddress',
];
expectedMethods.forEach((method) => {
expect((addressAPI as any)[method]).toBeDefined();
expect(typeof (addressAPI as any)[method]).toBe('function');
});
});
});
describe('conditionCheckAPI', () => {
it('has all condition check methods', () => {
const expectedMethods = [
'submitConditionCheck',
'getConditionChecks',
'getConditionCheckTimeline',
'getAvailableChecks',
];
expectedMethods.forEach((method) => {
expect((conditionCheckAPI as any)[method]).toBeDefined();
expect(typeof (conditionCheckAPI as any)[method]).toBe('function');
});
});
});
describe('forumAPI', () => {
it('has all forum methods', () => {
const expectedMethods = [
'getPosts',
'getPost',
'createPost',
'updatePost',
'deletePost',
'createComment',
'updateComment',
'deleteComment',
'getTags',
];
expectedMethods.forEach((method) => {
expect((forumAPI as any)[method]).toBeDefined();
expect(typeof (forumAPI as any)[method]).toBe('function');
});
});
});
describe('feedbackAPI', () => {
it('has feedback submission method', () => {
expect(feedbackAPI.submitFeedback).toBeDefined();
expect(typeof feedbackAPI.submitFeedback).toBe('function');
});
});
});

View File

@@ -437,9 +437,9 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
{/* Header */}
<div className="bg-primary text-white p-3 d-flex align-items-center justify-content-between flex-shrink-0">
<div className="d-flex align-items-center">
{recipient.profileImage ? (
{recipient.imageFilename ? (
<img
src={recipient.profileImage}
src={recipient.imageFilename}
alt={`${recipient.firstName} ${recipient.lastName}`}
className="rounded-circle me-2"
style={{ width: "35px", height: "35px", objectFit: "cover" }}
@@ -525,10 +525,10 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
wordBreak: "break-word",
}}
>
{message.imagePath && (
{message.imageFilename && (
<div className="mb-2">
<img
src={getMessageImageUrl(message.imagePath)}
src={getMessageImageUrl(message.imageFilename)}
alt="Shared image"
style={{
width: "100%",
@@ -539,7 +539,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}}
onClick={() =>
window.open(
getMessageImageUrl(message.imagePath!),
getMessageImageUrl(message.imageFilename!),
"_blank"
)
}

View File

@@ -212,9 +212,9 @@ const CommentThread: React.FC<CommentThreadProps> = ({
<p className="card-text mb-2" style={{ whiteSpace: "pre-wrap" }}>
{comment.content}
</p>
{comment.images && comment.images.length > 0 && (
{comment.imageFilenames && comment.imageFilenames.length > 0 && (
<div className="row g-2 mb-2">
{comment.images.map((image, index) => (
{comment.imageFilenames.map((image, index) => (
<div key={index} className="col-4 col-md-3">
<img
src={getForumImageUrl(image)}

View File

@@ -47,9 +47,9 @@ const ItemCard: React.FC<ItemCardProps> = ({
return (
<Link to={`/items/${item.id}`} className="text-decoration-none">
<div className="card h-100" style={{ cursor: 'pointer' }}>
{item.images && item.images[0] ? (
{item.imageFilenames && item.imageFilenames[0] ? (
<img
src={item.images[0]}
src={item.imageFilenames[0]}
className="card-img-top"
alt={item.name}
style={{

View File

@@ -29,9 +29,9 @@ const ItemMarkerInfo: React.FC<ItemMarkerInfoProps> = ({ item, onViewDetails })
return (
<div style={{ width: 'min(280px, 90vw)', maxWidth: '280px' }}>
<div className="card border-0">
{item.images && item.images[0] ? (
{item.imageFilenames && item.imageFilenames[0] ? (
<img
src={item.images[0]}
src={item.imageFilenames[0]}
className="card-img-top"
alt={item.name}
style={{

View File

@@ -85,9 +85,9 @@ const ItemReviews: React.FC<ItemReviewsProps> = ({ itemId }) => {
onClick={() => rental.renter && navigate(`/users/${rental.renterId}`)}
style={{ cursor: "pointer" }}
>
{rental.renter?.profileImage ? (
{rental.renter?.imageFilename ? (
<img
src={rental.renter.profileImage}
src={rental.renter.imageFilename}
alt={`${rental.renter.firstName} ${rental.renter.lastName}`}
className="rounded-circle"
style={{

View File

@@ -102,9 +102,9 @@ const ReviewItemModal: React.FC<ReviewItemModalProps> = ({
{rental.owner && rental.item && (
<div className="mb-4 text-center">
<div className="d-flex justify-content-center mb-3">
{rental.owner.profileImage ? (
{rental.owner.imageFilename ? (
<img
src={rental.owner.profileImage}
src={rental.owner.imageFilename}
alt={`${rental.owner.firstName} ${rental.owner.lastName}`}
className="rounded-circle"
style={{

View File

@@ -102,9 +102,9 @@ const ReviewRenterModal: React.FC<ReviewRenterModalProps> = ({
{rental.renter && rental.item && (
<div className="mb-4 text-center">
<div className="d-flex justify-content-center mb-3">
{rental.renter.profileImage ? (
{rental.renter.imageFilename ? (
<img
src={rental.renter.profileImage}
src={rental.renter.imageFilename}
alt={`${rental.renter.firstName} ${rental.renter.lastName}`}
className="rounded-circle"
style={{

View File

@@ -0,0 +1,72 @@
/**
* Mock data for tests
*/
// Mock user data
export const mockUser = {
id: '1',
username: 'testuser',
email: 'test@example.com',
firstName: 'Test',
lastName: 'User',
isVerified: true,
role: 'user' as const,
};
export const mockUnverifiedUser = {
...mockUser,
id: '2',
email: 'unverified@example.com',
isVerified: false,
};
// Mock item data
export const mockItem = {
id: '1',
name: 'Test Item',
description: 'A test item for rental',
pricePerDay: 25,
replacementCost: 500,
condition: 'excellent' as const,
isAvailable: true,
images: ['image1.jpg'],
ownerId: '2',
city: 'Test City',
state: 'California',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Mock rental data
export const mockRental = {
id: '1',
itemId: '1',
renterId: '1',
ownerId: '2',
startDateTime: new Date().toISOString(),
endDateTime: new Date(Date.now() + 86400000).toISOString(),
totalAmount: 25,
status: 'pending' as const,
paymentStatus: 'pending' as const,
deliveryMethod: 'pickup' as const,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
// Mock API response helpers
export const createMockResponse = <T>(data: T, status = 200) => ({
data,
status,
statusText: 'OK',
headers: {},
config: {},
});
export const createMockError = (message: string, status: number, code?: string) => {
const error = new Error(message) as any;
error.response = {
status,
data: { message, code },
};
return error;
};

View File

@@ -0,0 +1,43 @@
/**
* Mock server using Jest mocks instead of MSW.
* This provides a simpler setup that works with all Node versions.
*/
import { mockUser, mockUnverifiedUser, mockItem, mockRental } from './handlers';
// Re-export mock data
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(),
};
// Setup axios mock
jest.mock('axios', () => {
const mockAxiosInstance = {
get: jest.fn(),
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
patch: jest.fn(),
interceptors: {
request: { use: jest.fn(), eject: jest.fn() },
response: { use: jest.fn(), eject: jest.fn() },
},
defaults: {
headers: {
common: {},
},
},
};
return {
create: jest.fn(() => mockAxiosInstance),
default: mockAxiosInstance,
...mockAxiosInstance,
};
});

View File

@@ -162,8 +162,8 @@ const EditItem: React.FC = () => {
});
// Set existing images as previews
if (item.images && item.images.length > 0) {
setImagePreviews(item.images);
if (item.imageFilenames && item.imageFilenames.length > 0) {
setImagePreviews(item.imageFilenames);
}
// Determine which pricing unit to select based on existing data

View File

@@ -343,9 +343,9 @@ const ForumPostDetail: React.FC = () => {
{post.content}
</div>
{post.images && post.images.length > 0 && (
{post.imageFilenames && post.imageFilenames.length > 0 && (
<div className="row g-2 mb-3">
{post.images.map((image, index) => (
{post.imageFilenames.map((image, index) => (
<div key={index} className="col-6 col-md-4">
<img
src={getForumImageUrl(image)}

View File

@@ -414,10 +414,10 @@ const ItemDetail: React.FC = () => {
<div className="row">
<div className="col-md-8">
{/* Images */}
{item.images.length > 0 ? (
{item.imageFilenames.length > 0 ? (
<div className="mb-4">
<img
src={item.images[selectedImage]}
src={item.imageFilenames[selectedImage]}
alt={item.name}
className="img-fluid rounded mb-3"
style={{
@@ -426,9 +426,9 @@ const ItemDetail: React.FC = () => {
objectFit: "cover",
}}
/>
{item.images.length > 1 && (
{item.imageFilenames.length > 1 && (
<div className="d-flex gap-2 overflow-auto justify-content-center">
{item.images.map((image, index) => (
{item.imageFilenames.map((image, index) => (
<img
key={index}
src={image}
@@ -478,9 +478,9 @@ const ItemDetail: React.FC = () => {
onClick={() => navigate(`/users/${item.ownerId}`)}
style={{ cursor: "pointer" }}
>
{item.owner.profileImage ? (
{item.owner.imageFilename ? (
<img
src={item.owner.profileImage}
src={item.owner.imageFilename}
alt={`${item.owner.firstName} ${item.owner.lastName}`}
className="rounded-circle me-2"
style={{

View File

@@ -230,9 +230,9 @@ const Messages: React.FC = () => {
<div className="d-flex w-100 justify-content-between align-items-start">
<div className="d-flex align-items-center flex-grow-1">
{/* Profile Picture */}
{conversation.partner.profileImage ? (
{conversation.partner.imageFilename ? (
<img
src={conversation.partner.profileImage}
src={conversation.partner.imageFilename}
alt={`${conversation.partner.firstName} ${conversation.partner.lastName}`}
className="rounded-circle me-3"
style={{

View File

@@ -306,9 +306,9 @@ const Owning: React.FC = () => {
{allOwnerRentals.map((rental) => (
<div key={rental.id} className="col-md-6 col-lg-4 mb-4">
<div className="card h-100">
{rental.item?.images && rental.item.images[0] && (
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
<img
src={rental.item.images[0]}
src={rental.item.imageFilenames[0]}
className="card-img-top"
alt={rental.item.name}
style={{ height: "200px", objectFit: "cover" }}
@@ -527,9 +527,9 @@ const Owning: React.FC = () => {
navigate(`/items/${item.id}`);
}}
>
{item.images && item.images[0] && (
{item.imageFilenames && item.imageFilenames[0] && (
<img
src={item.images[0]}
src={item.imageFilenames[0]}
className="card-img-top"
alt={item.name}
style={{ height: "200px", objectFit: "cover" }}

View File

@@ -39,7 +39,7 @@ const Profile: React.FC = () => {
state: string;
zipCode: string;
country: string;
profileImage: string;
imageFilename: string;
itemRequestNotificationRadius: number | null;
}>({
firstName: "",
@@ -52,7 +52,7 @@ const Profile: React.FC = () => {
state: "",
zipCode: "",
country: "",
profileImage: "",
imageFilename: "",
itemRequestNotificationRadius: 10,
});
const [imageFile, setImageFile] = useState<File | null>(null);
@@ -156,12 +156,12 @@ const Profile: React.FC = () => {
state: response.data.state || "",
zipCode: response.data.zipCode || "",
country: response.data.country || "",
profileImage: response.data.profileImage || "",
imageFilename: response.data.imageFilename || "",
itemRequestNotificationRadius:
response.data.itemRequestNotificationRadius || 10,
});
if (response.data.profileImage) {
setImagePreview(getImageUrl(response.data.profileImage));
if (response.data.imageFilename) {
setImagePreview(getImageUrl(response.data.imageFilename));
}
} catch (err: any) {
setError(err.response?.data?.message || "Failed to fetch profile");
@@ -304,14 +304,14 @@ const Profile: React.FC = () => {
// Upload image immediately
try {
const formData = new FormData();
formData.append("profileImage", file);
formData.append("imageFilename", file);
const response = await userAPI.uploadProfileImage(formData);
// Update the profileImage in formData with the new filename
// Update the imageFilename in formData with the new filename
setFormData((prev) => ({
...prev,
profileImage: response.data.filename,
imageFilename: response.data.filename,
}));
// Update preview to use the uploaded image URL
@@ -322,8 +322,8 @@ const Profile: React.FC = () => {
// Reset on error
setImageFile(null);
setImagePreview(
profileData?.profileImage
? getImageUrl(profileData.profileImage)
profileData?.imageFilename
? getImageUrl(profileData.imageFilename)
: null
);
}
@@ -336,8 +336,8 @@ const Profile: React.FC = () => {
setSuccess(null);
try {
// Don't send profileImage in the update data as it's handled separately
const { profileImage, ...updateData } = formData;
// Don't send imageFilename in the update data as it's handled separately
const { imageFilename, ...updateData } = formData;
const response = await userAPI.updateProfile(updateData);
setProfileData(response.data);
@@ -379,12 +379,12 @@ const Profile: React.FC = () => {
state: profileData.state || "",
zipCode: profileData.zipCode || "",
country: profileData.country || "",
profileImage: profileData.profileImage || "",
imageFilename: profileData.imageFilename || "",
itemRequestNotificationRadius:
profileData.itemRequestNotificationRadius || 10,
});
setImagePreview(
profileData.profileImage ? getImageUrl(profileData.profileImage) : null
profileData.imageFilename ? getImageUrl(profileData.imageFilename) : null
);
}
};
@@ -774,7 +774,7 @@ const Profile: React.FC = () => {
)}
{editing && (
<label
htmlFor="profileImageOverview"
htmlFor="imageFilenameOverview"
className="position-absolute bottom-0 end-0 btn btn-sm btn-primary rounded-circle"
style={{
width: "35px",
@@ -785,7 +785,7 @@ const Profile: React.FC = () => {
<i className="bi bi-camera-fill"></i>
<input
type="file"
id="profileImageOverview"
id="imageFilenameOverview"
accept="image/*"
onChange={handleImageChange}
className="d-none"
@@ -1222,9 +1222,9 @@ const Profile: React.FC = () => {
className="col-md-6 col-lg-4 mb-4"
>
<div className="card h-100">
{rental.item?.images && rental.item.images[0] && (
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
<img
src={rental.item.images[0]}
src={rental.item.imageFilenames[0]}
className="card-img-top"
alt={rental.item.name}
style={{
@@ -1359,9 +1359,9 @@ const Profile: React.FC = () => {
className="col-md-6 col-lg-4 mb-4"
>
<div className="card h-100">
{rental.item?.images && rental.item.images[0] && (
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
<img
src={rental.item.images[0]}
src={rental.item.imageFilenames[0]}
className="card-img-top"
alt={rental.item.name}
style={{

View File

@@ -71,9 +71,9 @@ const PublicProfile: React.FC = () => {
<div className="card">
<div className="card-body">
<div className="text-center mb-4">
{user.profileImage ? (
{user.imageFilename ? (
<img
src={user.profileImage}
src={user.imageFilename}
alt={`${user.firstName} ${user.lastName}`}
className="rounded-circle mb-3"
style={{ width: '150px', height: '150px', objectFit: 'cover' }}
@@ -111,9 +111,9 @@ const PublicProfile: React.FC = () => {
onClick={() => navigate(`/items/${item.id}`)}
style={{ cursor: 'pointer' }}
>
{item.images.length > 0 ? (
{item.imageFilenames.length > 0 ? (
<img
src={item.images[0]}
src={item.imageFilenames[0]}
className="card-img-top"
alt={item.name}
style={{ height: '200px', objectFit: 'cover' }}

View File

@@ -341,9 +341,9 @@ const RentItem: React.FC = () => {
<div className="col-md-4">
<div className="card">
<div className="card-body">
{item.images && item.images[0] && (
{item.imageFilenames && item.imageFilenames[0] && (
<img
src={item.images[0]}
src={item.imageFilenames[0]}
alt={item.name}
className="img-fluid rounded mb-3"
style={{

View File

@@ -230,9 +230,9 @@ const Renting: React.FC = () => {
className="card h-100"
style={{ cursor: rental.item ? "pointer" : "default" }}
>
{rental.item?.images && rental.item.images[0] && (
{rental.item?.imageFilenames && rental.item.imageFilenames[0] && (
<img
src={rental.item.images[0]}
src={rental.item.imageFilenames[0]}
className="card-img-top"
alt={rental.item.name}
style={{ height: "200px", objectFit: "cover" }}

View File

@@ -3,3 +3,53 @@
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// 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(),
};
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
});
// Suppress console errors during tests (optional, comment out for debugging)
const originalConsoleError = console.error;
const originalConsoleWarn = console.warn;
beforeAll(() => {
console.error = (...args: any[]) => {
// Filter out known React warnings during tests
if (
typeof args[0] === 'string' &&
(args[0].includes('Warning: ReactDOM.render is no longer supported') ||
args[0].includes('Warning: An update to') ||
args[0].includes('act(...)'))
) {
return;
}
originalConsoleError.call(console, ...args);
};
console.warn = (...args: any[]) => {
// Filter out known warnings
if (
typeof args[0] === 'string' &&
args[0].includes('componentWillReceiveProps')
) {
return;
}
originalConsoleWarn.call(console, ...args);
};
});
afterAll(() => {
console.error = originalConsoleError;
console.warn = originalConsoleWarn;
});

View File

@@ -0,0 +1,26 @@
// Polyfills for MSW 2.x - must be loaded before MSW
const { TextEncoder, TextDecoder } = require('util');
global.TextEncoder = TextEncoder;
global.TextDecoder = TextDecoder;
// Polyfill for fetch, Request, Response, Headers
const { fetch, Headers, Request, Response } = require('cross-fetch');
global.fetch = fetch;
global.Headers = Headers;
global.Request = Request;
global.Response = Response;
// BroadcastChannel polyfill for MSW
class BroadcastChannel {
constructor(name) {
this.name = name;
}
postMessage() {}
close() {}
addEventListener() {}
removeEventListener() {}
}
global.BroadcastChannel = BroadcastChannel;

View File

@@ -27,7 +27,7 @@ export interface User {
state?: string;
zipCode?: string;
country?: string;
profileImage?: string;
imageFilename?: string;
isVerified: boolean;
role?: "user" | "admin";
stripeConnectedAccountId?: string;
@@ -41,7 +41,7 @@ export interface Message {
receiverId: string;
content: string;
isRead: boolean;
imagePath?: string;
imageFilename?: string;
sender?: User;
receiver?: User;
createdAt: string;
@@ -84,7 +84,7 @@ export interface Item {
country?: string;
latitude?: number;
longitude?: number;
images: string[];
imageFilenames: string[];
condition: "excellent" | "good" | "fair" | "poor";
isAvailable: boolean;
rules?: string;
@@ -187,7 +187,7 @@ export interface ConditionCheck {
| "rental_start_renter"
| "rental_end_renter"
| "post_rental_owner";
photos: string[];
imageFilenames: string[];
notes?: string;
submittedBy: string;
submittedAt: string;
@@ -212,7 +212,7 @@ export interface DamageAssessment {
needsReplacement: boolean;
replacementCost?: number;
proofOfOwnership?: string[];
photos?: string[];
imageFilenames?: string[];
assessedAt: string;
assessedBy: string;
feeCalculation: {
@@ -265,7 +265,7 @@ export interface ForumPost {
commentCount: number;
isPinned: boolean;
acceptedAnswerId?: string;
images?: string[];
imageFilenames?: string[];
isDeleted?: boolean;
deletedBy?: string;
deletedAt?: string;
@@ -287,7 +287,7 @@ export interface ForumComment {
content: string;
parentCommentId?: string;
isDeleted: boolean;
images?: string[];
imageFilenames?: string[];
deletedBy?: string;
deletedAt?: string;
author?: User;