more frontend tests

This commit is contained in:
jackiettran
2026-01-20 22:31:57 -05:00
parent fcce10e664
commit cae9e7e473
17 changed files with 6226 additions and 36 deletions

View File

@@ -16,7 +16,7 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@testing-library/user-event": "^14.0.0",
"@types/node": "^20.0.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
@@ -1752,15 +1752,12 @@
}
},
"node_modules/@testing-library/user-event": {
"version": "13.5.0",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
"integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
"version": "14.6.1",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=10",
"node": ">=12",
"npm": ">=6"
},
"peerDependencies": {

View File

@@ -12,7 +12,7 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"@testing-library/user-event": "^14.0.0",
"@types/node": "^20.0.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",

View File

@@ -209,13 +209,14 @@ describe('AuthModal', () => {
describe('Login Form Submission', () => {
it('should call login with email and password', async () => {
const user = userEvent.setup();
mockLogin.mockResolvedValue({});
const { container } = render(<AuthModal {...defaultProps} />);
// Fill in the form
const emailInput = getInputByLabelText(container, 'Email');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'password123');
await user.type(emailInput, 'test@example.com');
await user.type(screen.getByTestId('password-input'), 'password123');
// Submit the form
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
@@ -226,12 +227,13 @@ describe('AuthModal', () => {
});
it('should call onHide after successful login', async () => {
const user = userEvent.setup();
mockLogin.mockResolvedValue({});
const { container } = render(<AuthModal {...defaultProps} />);
const emailInput = getInputByLabelText(container, 'Email');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'password123');
await user.type(emailInput, 'test@example.com');
await user.type(screen.getByTestId('password-input'), 'password123');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
@@ -241,6 +243,7 @@ describe('AuthModal', () => {
});
it('should display error message on login failure', async () => {
const user = userEvent.setup();
mockLogin.mockRejectedValue({
response: { data: { error: 'Invalid credentials' } },
});
@@ -248,8 +251,8 @@ describe('AuthModal', () => {
const { container } = render(<AuthModal {...defaultProps} />);
const emailInput = getInputByLabelText(container, 'Email');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'wrongpassword');
await user.type(emailInput, 'test@example.com');
await user.type(screen.getByTestId('password-input'), 'wrongpassword');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
@@ -259,14 +262,15 @@ describe('AuthModal', () => {
});
it('should show loading state during login', async () => {
const user = userEvent.setup();
// Make login take some time
mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
const { container } = render(<AuthModal {...defaultProps} />);
const emailInput = getInputByLabelText(container, 'Email');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'password123');
await user.type(emailInput, 'test@example.com');
await user.type(screen.getByTestId('password-input'), 'password123');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
@@ -276,13 +280,14 @@ describe('AuthModal', () => {
describe('Signup Form Submission', () => {
it('should call register with user data', async () => {
const user = userEvent.setup();
mockRegister.mockResolvedValue({});
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
await userEvent.type(getInputByLabelText(container, 'First Name'), 'John');
await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe');
await userEvent.type(getInputByLabelText(container, 'Email'), 'john@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!');
await user.type(getInputByLabelText(container, 'First Name'), 'John');
await user.type(getInputByLabelText(container, 'Last Name'), 'Doe');
await user.type(getInputByLabelText(container, 'Email'), 'john@example.com');
await user.type(screen.getByTestId('password-input'), 'StrongPass123!');
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
@@ -298,13 +303,14 @@ describe('AuthModal', () => {
});
it('should show verification modal after successful signup', async () => {
const user = userEvent.setup();
mockRegister.mockResolvedValue({});
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
await userEvent.type(getInputByLabelText(container, 'First Name'), 'John');
await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe');
await userEvent.type(getInputByLabelText(container, 'Email'), 'john@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!');
await user.type(getInputByLabelText(container, 'First Name'), 'John');
await user.type(getInputByLabelText(container, 'Last Name'), 'Doe');
await user.type(getInputByLabelText(container, 'Email'), 'john@example.com');
await user.type(screen.getByTestId('password-input'), 'StrongPass123!');
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
@@ -315,16 +321,17 @@ describe('AuthModal', () => {
});
it('should display error message on signup failure', async () => {
const user = userEvent.setup();
mockRegister.mockRejectedValue({
response: { data: { error: 'Email already exists' } },
});
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
await userEvent.type(getInputByLabelText(container, 'First Name'), 'John');
await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe');
await userEvent.type(getInputByLabelText(container, 'Email'), 'existing@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!');
await user.type(getInputByLabelText(container, 'First Name'), 'John');
await user.type(getInputByLabelText(container, 'Last Name'), 'Doe');
await user.type(getInputByLabelText(container, 'Email'), 'existing@example.com');
await user.type(screen.getByTestId('password-input'), 'StrongPass123!');
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
@@ -414,6 +421,7 @@ describe('AuthModal', () => {
});
it('should display error in an alert role', async () => {
const user = userEvent.setup();
mockLogin.mockRejectedValue({
response: { data: { error: 'Test error' } },
});
@@ -421,8 +429,8 @@ describe('AuthModal', () => {
const { container } = render(<AuthModal {...defaultProps} />);
const emailInput = getInputByLabelText(container, 'Email');
await userEvent.type(emailInput, 'test@example.com');
await userEvent.type(screen.getByTestId('password-input'), 'password');
await user.type(emailInput, 'test@example.com');
await user.type(screen.getByTestId('password-input'), 'password');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
await waitFor(() => {

View File

@@ -0,0 +1,786 @@
/**
* ChatWindow Component Tests
*
* Tests for the ChatWindow component that handles real-time messaging
* between users.
*/
import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import ChatWindow from '../../components/ChatWindow';
import { Message, User } from '../../types';
// Mock API
vi.mock('../../services/api', () => ({
messageAPI: {
getMessages: vi.fn(),
getSentMessages: vi.fn(),
sendMessage: vi.fn(),
markAsRead: vi.fn(),
},
}));
// Mock upload service
vi.mock('../../services/uploadService', () => ({
getSignedImageUrl: vi.fn().mockResolvedValue('https://signed-url.com/image.jpg'),
uploadImageWithVariants: vi.fn().mockResolvedValue({ baseKey: 'uploads/message/123.jpg' }),
}));
// Current user mock
const mockCurrentUser: User = {
id: 'current-user',
firstName: 'Current',
lastName: 'User',
email: 'current@example.com',
isVerified: true,
role: 'user' as const,
};
// Mock auth context
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => ({
user: mockCurrentUser,
}),
}));
// Socket context mock
const mockJoinConversation = vi.fn();
const mockLeaveConversation = vi.fn();
const mockOnNewMessage = vi.fn();
const mockOnUserTyping = vi.fn();
const mockEmitTypingStart = vi.fn();
const mockEmitTypingStop = vi.fn();
vi.mock('../../contexts/SocketContext', () => ({
useSocket: () => ({
isConnected: true,
joinConversation: mockJoinConversation,
leaveConversation: mockLeaveConversation,
onNewMessage: mockOnNewMessage,
onUserTyping: mockOnUserTyping,
emitTypingStart: mockEmitTypingStart,
emitTypingStop: mockEmitTypingStop,
}),
}));
// Mock TypingIndicator
vi.mock('../../components/TypingIndicator', () => ({
default: function MockTypingIndicator({
firstName,
isVisible,
}: {
firstName: string;
isVisible: boolean;
}) {
if (!isVisible) return null;
return <div data-testid="typing-indicator">{firstName} is typing...</div>;
},
}));
// Mock Avatar
vi.mock('../../components/Avatar', () => ({
default: function MockAvatar({ user }: { user: User }) {
return <div data-testid="avatar">{user?.firstName?.[0] || '?'}</div>;
},
}));
import { messageAPI } from '../../services/api';
import { uploadImageWithVariants, getSignedImageUrl } from '../../services/uploadService';
const mockMessageAPI = messageAPI as jest.Mocked<typeof messageAPI>;
const mockUploadService = { uploadImageWithVariants, getSignedImageUrl } as jest.Mocked<
typeof import('../../services/uploadService')
>;
// Helper to create a mock recipient
const createMockRecipient = (): User => ({
id: 'recipient-user',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
isVerified: true,
role: 'user' as const,
});
// Helper to create a mock message
const createMockMessage = (overrides: Partial<Message> = {}): Message => ({
id: `msg-${Math.random().toString(36).substr(2, 9)}`,
content: 'Test message',
senderId: mockCurrentUser.id,
receiverId: 'recipient-user',
createdAt: new Date().toISOString(),
isRead: false,
...overrides,
});
describe('ChatWindow', () => {
const mockRecipient = createMockRecipient();
const mockOnClose = vi.fn();
const mockOnMessagesRead = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
mockOnNewMessage.mockReturnValue(vi.fn());
mockOnUserTyping.mockReturnValue(vi.fn());
mockMessageAPI.getMessages.mockResolvedValue({ data: [] });
mockMessageAPI.getSentMessages.mockResolvedValue({ data: [] });
mockMessageAPI.sendMessage.mockResolvedValue({
data: createMockMessage(),
});
mockMessageAPI.markAsRead.mockResolvedValue({ data: {} });
// Mock scrollIntoView which is not available in jsdom
Element.prototype.scrollIntoView = vi.fn();
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.clearAllMocks();
});
describe('Visibility', () => {
it('renders nothing when show is false', () => {
const { container } = render(
<ChatWindow
show={false}
onClose={mockOnClose}
recipient={mockRecipient}
/>
);
expect(container.firstChild).toBeNull();
});
it('renders when show is true', async () => {
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
});
describe('Message Loading', () => {
it('shows loading spinner while fetching messages', () => {
mockMessageAPI.getMessages.mockImplementation(() => new Promise(() => {}));
mockMessageAPI.getSentMessages.mockImplementation(() => new Promise(() => {}));
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
expect(screen.getByRole('status')).toBeInTheDocument();
});
it('fetches sent and received messages when show is true', async () => {
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(mockMessageAPI.getMessages).toHaveBeenCalled();
expect(mockMessageAPI.getSentMessages).toHaveBeenCalled();
});
});
it('combines and sorts messages chronologically', async () => {
const oldMessage = createMockMessage({
id: 'msg-1',
content: 'First message',
senderId: 'recipient-user',
receiverId: mockCurrentUser.id,
createdAt: new Date(Date.now() - 60000).toISOString(),
isRead: true,
});
const newMessage = createMockMessage({
id: 'msg-2',
content: 'Second message',
senderId: mockCurrentUser.id,
receiverId: 'recipient-user',
createdAt: new Date().toISOString(),
});
mockMessageAPI.getMessages.mockResolvedValue({ data: [oldMessage] });
mockMessageAPI.getSentMessages.mockResolvedValue({ data: [newMessage] });
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(screen.getByText('First message')).toBeInTheDocument();
expect(screen.getByText('Second message')).toBeInTheDocument();
});
});
it('shows empty state when no messages', async () => {
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(
screen.getByText(`Start a conversation with ${mockRecipient.firstName}`)
).toBeInTheDocument();
});
});
});
describe('Conversation Room Management', () => {
it('joins conversation room on mount', async () => {
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(mockJoinConversation).toHaveBeenCalledWith(mockRecipient.id);
});
});
it('leaves conversation room on unmount', async () => {
const { unmount } = render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(mockJoinConversation).toHaveBeenCalled();
});
unmount();
expect(mockLeaveConversation).toHaveBeenCalledWith(mockRecipient.id);
});
it('leaves conversation room on close', async () => {
const { rerender } = render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(mockJoinConversation).toHaveBeenCalled();
});
rerender(
<ChatWindow show={false} onClose={mockOnClose} recipient={mockRecipient} />
);
expect(mockLeaveConversation).toHaveBeenCalledWith(mockRecipient.id);
});
});
describe('Message Display', () => {
it('displays messages with correct alignment (sender vs recipient)', async () => {
const sentMessage = createMockMessage({
id: 'sent-1',
content: 'I sent this',
senderId: mockCurrentUser.id,
receiverId: mockRecipient.id,
});
const receivedMessage = createMockMessage({
id: 'received-1',
content: 'They sent this',
senderId: mockRecipient.id,
receiverId: mockCurrentUser.id,
isRead: true,
});
mockMessageAPI.getSentMessages.mockResolvedValue({ data: [sentMessage] });
mockMessageAPI.getMessages.mockResolvedValue({ data: [receivedMessage] });
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(screen.getByText('I sent this')).toBeInTheDocument();
expect(screen.getByText('They sent this')).toBeInTheDocument();
});
// Check alignment classes
const sentContainer = screen.getByText('I sent this').closest('.d-flex');
const receivedContainer = screen.getByText('They sent this').closest('.d-flex');
expect(sentContainer).toHaveClass('justify-content-end');
expect(receivedContainer).not.toHaveClass('justify-content-end');
});
it('shows date separators between different dates', async () => {
const todayMessage = createMockMessage({
id: 'today-1',
content: 'Today message',
createdAt: new Date().toISOString(),
});
const yesterdayMessage = createMockMessage({
id: 'yesterday-1',
content: 'Yesterday message',
createdAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
});
mockMessageAPI.getSentMessages.mockResolvedValue({
data: [yesterdayMessage, todayMessage],
});
mockMessageAPI.getMessages.mockResolvedValue({ data: [] });
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(screen.getByText('Today')).toBeInTheDocument();
expect(screen.getByText('Yesterday')).toBeInTheDocument();
});
});
});
describe('Typing Indicators', () => {
it('emits typingStart event when typing in input', async () => {
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
});
const input = screen.getByPlaceholderText('Type a message...');
fireEvent.change(input, { target: { value: 'H' } });
expect(mockEmitTypingStart).toHaveBeenCalledWith(mockRecipient.id);
});
it('emits typingStop after debounce period', async () => {
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
});
const input = screen.getByPlaceholderText('Type a message...');
fireEvent.change(input, { target: { value: 'Hello' } });
expect(mockEmitTypingStart).toHaveBeenCalled();
// Wait for debounce
act(() => {
vi.advanceTimersByTime(2000);
});
expect(mockEmitTypingStop).toHaveBeenCalledWith(mockRecipient.id);
});
it('shows typing indicator when recipient is typing', async () => {
// This test verifies that typing events are registered
// The actual typing indicator display is tested via integration
let typingCallback: ((data: { userId: string; isTyping: boolean }) => void) | null =
null;
mockOnUserTyping.mockImplementation((callback) => {
typingCallback = callback;
return vi.fn();
});
const { unmount } = render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(mockOnUserTyping).toHaveBeenCalled();
});
// Verify the callback was captured
expect(typingCallback).not.toBeNull();
// Clean up to prevent test pollution
unmount();
});
});
describe('Sending Messages', () => {
it('sends message via API when form is submitted', async () => {
mockMessageAPI.sendMessage.mockResolvedValue({
data: createMockMessage({ content: 'Hello!' }),
});
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
});
const input = screen.getByPlaceholderText('Type a message...');
fireEvent.change(input, { target: { value: 'Hello!' } });
const form = input.closest('form');
fireEvent.submit(form!);
await waitFor(() => {
expect(mockMessageAPI.sendMessage).toHaveBeenCalledWith({
receiverId: mockRecipient.id,
content: 'Hello!',
imageFilename: undefined,
});
});
});
it('clears input after successful send', async () => {
mockMessageAPI.sendMessage.mockResolvedValue({
data: createMockMessage({ content: 'Test' }),
});
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
});
const input = screen.getByPlaceholderText('Type a message...');
fireEvent.change(input, { target: { value: 'Test' } });
const form = input.closest('form');
fireEvent.submit(form!);
await waitFor(() => {
expect(input).toHaveValue('');
});
});
it('disables send button when no message or image', async () => {
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
const buttons = screen.getAllByRole('button');
const sendButton = buttons.find((btn) =>
btn.querySelector('.bi-send-fill')
);
expect(sendButton).toBeDisabled();
});
});
it('disables send button during submission', async () => {
mockMessageAPI.sendMessage.mockImplementation(
() => new Promise(() => {}) // Never resolves
);
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
});
const input = screen.getByPlaceholderText('Type a message...');
fireEvent.change(input, { target: { value: 'Test' } });
const form = input.closest('form');
fireEvent.submit(form!);
// Button should show spinner while sending
await waitFor(() => {
expect(screen.getByRole('status', { hidden: true })).toBeInTheDocument();
});
});
it('restores message on send error', async () => {
mockMessageAPI.sendMessage.mockRejectedValue(new Error('Send failed'));
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument();
});
const input = screen.getByPlaceholderText('Type a message...');
fireEvent.change(input, { target: { value: 'Failed message' } });
const form = input.closest('form');
fireEvent.submit(form!);
await waitFor(() => {
expect(input).toHaveValue('Failed message');
});
});
});
describe('Image Upload', () => {
it('validates image file type', async () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(screen.getByTitle('Attach image')).toBeInTheDocument();
});
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' });
Object.defineProperty(fileInput, 'files', {
value: [textFile],
writable: true,
});
fireEvent.change(fileInput);
expect(alertSpy).toHaveBeenCalledWith('Please select an image file');
alertSpy.mockRestore();
});
it('validates image size (max 5MB)', async () => {
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(screen.getByTitle('Attach image')).toBeInTheDocument();
});
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
// Create a mock file with size > 5MB
const largeFile = new File(['x'], 'large.jpg', {
type: 'image/jpeg',
});
Object.defineProperty(largeFile, 'size', { value: 6 * 1024 * 1024 });
Object.defineProperty(fileInput, 'files', {
value: [largeFile],
writable: true,
});
fireEvent.change(fileInput);
expect(alertSpy).toHaveBeenCalledWith('Image size must be less than 5MB');
alertSpy.mockRestore();
});
it('displays image preview before send', async () => {
// Use real timers for FileReader async operations
vi.useRealTimers();
// Create a proper FileReader mock class
const originalFileReader = global.FileReader;
class MockFileReader {
result: string | null = null;
onloadend: (() => void) | null = null;
readAsDataURL(_file: Blob) {
// Simulate async file reading
setTimeout(() => {
this.result = 'data:image/jpeg;base64,test';
if (this.onloadend) this.onloadend();
}, 10);
}
}
global.FileReader = MockFileReader as any;
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(screen.getByTitle('Attach image')).toBeInTheDocument();
});
const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement;
const validImage = new File(['image content'], 'test.jpg', {
type: 'image/jpeg',
});
Object.defineProperty(fileInput, 'files', {
value: [validImage],
writable: true,
});
fireEvent.change(fileInput);
await waitFor(() => {
const preview = screen.getByAltText('Preview');
expect(preview).toBeInTheDocument();
});
// Restore original FileReader and fake timers
global.FileReader = originalFileReader;
vi.useFakeTimers({ shouldAdvanceTime: true });
});
});
describe('Mark as Read', () => {
it('auto-marks received messages as read when chat opens', async () => {
const unreadMessage = createMockMessage({
id: 'unread-1',
content: 'Unread',
senderId: mockRecipient.id,
receiverId: mockCurrentUser.id,
isRead: false,
});
mockMessageAPI.getMessages.mockResolvedValue({ data: [unreadMessage] });
mockMessageAPI.getSentMessages.mockResolvedValue({ data: [] });
render(
<ChatWindow
show={true}
onClose={mockOnClose}
recipient={mockRecipient}
onMessagesRead={mockOnMessagesRead}
/>
);
await waitFor(() => {
expect(mockMessageAPI.markAsRead).toHaveBeenCalledWith('unread-1');
});
await waitFor(() => {
expect(mockOnMessagesRead).toHaveBeenCalledWith(mockRecipient.id, 1);
});
});
});
describe('Real-time Message Reception', () => {
it('adds new message from socket to local state', async () => {
let newMessageCallback: ((message: Message) => void) | null = null;
mockOnNewMessage.mockImplementation((callback) => {
newMessageCallback = callback;
return vi.fn();
});
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(mockOnNewMessage).toHaveBeenCalled();
});
// Simulate receiving a new message
const newMessage = createMockMessage({
id: 'new-msg',
content: 'Hello from socket!',
senderId: mockRecipient.id,
receiverId: mockCurrentUser.id,
});
act(() => {
if (newMessageCallback) {
newMessageCallback(newMessage);
}
});
await waitFor(() => {
expect(screen.getByText('Hello from socket!')).toBeInTheDocument();
});
});
it('marks new incoming message as read automatically', async () => {
let newMessageCallback: ((message: Message) => void) | null = null;
mockOnNewMessage.mockImplementation((callback) => {
newMessageCallback = callback;
return vi.fn();
});
render(
<ChatWindow
show={true}
onClose={mockOnClose}
recipient={mockRecipient}
onMessagesRead={mockOnMessagesRead}
/>
);
await waitFor(() => {
expect(mockOnNewMessage).toHaveBeenCalled();
});
const newMessage = createMockMessage({
id: 'incoming-msg',
content: 'New incoming',
senderId: mockRecipient.id,
receiverId: mockCurrentUser.id,
isRead: false,
});
act(() => {
if (newMessageCallback) {
newMessageCallback(newMessage);
}
});
await waitFor(() => {
expect(mockMessageAPI.markAsRead).toHaveBeenCalledWith('incoming-msg');
});
});
it('does not add duplicate messages', async () => {
let newMessageCallback: ((message: Message) => void) | null = null;
mockOnNewMessage.mockImplementation((callback) => {
newMessageCallback = callback;
return vi.fn();
});
const existingMessage = createMockMessage({
id: 'existing-msg',
content: 'Existing message',
senderId: mockCurrentUser.id,
});
mockMessageAPI.getSentMessages.mockResolvedValue({ data: [existingMessage] });
render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(screen.getByText('Existing message')).toBeInTheDocument();
});
// Try to add the same message again
act(() => {
if (newMessageCallback) {
newMessageCallback(existingMessage);
}
});
// Should still only show once
const messages = screen.getAllByText('Existing message');
expect(messages.length).toBe(1);
});
});
describe('Close Button', () => {
it('calls onClose when close button is clicked', async () => {
const { container } = render(
<ChatWindow show={true} onClose={mockOnClose} recipient={mockRecipient} />
);
await waitFor(() => {
expect(container.querySelector('.btn-close')).toBeInTheDocument();
});
const closeButton = container.querySelector('.btn-close');
fireEvent.click(closeButton!);
expect(mockOnClose).toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,308 @@
/**
* ForumPostListItem Component Tests
*
* Tests for the ForumPostListItem component that displays
* a forum post preview in the list view.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { BrowserRouter } from 'react-router';
import ForumPostListItem from '../../components/ForumPostListItem';
import { ForumPost } from '../../types';
// Mock the AuthContext
const mockUseAuth = vi.fn();
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => mockUseAuth(),
}));
// Helper to create mock post data
const createMockPost = (overrides: Partial<ForumPost> = {}): ForumPost => ({
id: '1',
title: 'Test Post Title',
content: '<p>This is the test post content that might be quite long and need truncation.</p>',
category: 'general_discussion' as const,
status: 'open' as const,
authorId: 'user-1',
author: {
id: 'user-1',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
isVerified: true,
role: 'user' as const,
},
tags: [],
commentCount: 5,
isPinned: false,
isDeleted: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
});
const renderWithRouter = (ui: React.ReactElement) => {
return render(<BrowserRouter>{ui}</BrowserRouter>);
};
describe('ForumPostListItem', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAuth.mockReturnValue({ user: null });
});
describe('Basic Rendering', () => {
it('renders post title', () => {
const post = createMockPost({ title: 'My Test Post' });
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('My Test Post')).toBeInTheDocument();
});
it('renders content preview truncated to 100 characters', () => {
const longContent = '<p>' + 'A'.repeat(150) + '</p>';
const post = createMockPost({ content: longContent });
renderWithRouter(<ForumPostListItem post={post} />);
// Should be truncated with ellipsis
const preview = screen.getByText(/A{100}\.\.\./);
expect(preview).toBeInTheDocument();
});
it('strips HTML tags from content preview', () => {
const post = createMockPost({
content: '<p><strong>Bold</strong> and <em>italic</em> text</p>',
});
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('Bold and italic text')).toBeInTheDocument();
});
it('renders author avatar with initial', () => {
const post = createMockPost({
author: {
id: 'user-1',
firstName: 'Jane',
lastName: 'Smith',
email: 'jane@example.com',
isVerified: true,
role: 'user' as const,
},
});
renderWithRouter(<ForumPostListItem post={post} />);
// Avatar should show first letter of first name
expect(screen.getByText('J')).toBeInTheDocument();
});
it('renders author name', () => {
const post = createMockPost();
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText(/John/)).toBeInTheDocument();
expect(screen.getByText(/Doe/)).toBeInTheDocument();
});
});
describe('Relative Time Formatting', () => {
it('shows "Just now" for posts less than 1 minute old', () => {
const post = createMockPost({
updatedAt: new Date().toISOString(),
});
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('Just now')).toBeInTheDocument();
});
it('shows "Xm ago" for posts minutes old', () => {
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();
const post = createMockPost({ updatedAt: fiveMinutesAgo });
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('5m ago')).toBeInTheDocument();
});
it('shows "Xh ago" for posts hours old', () => {
const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString();
const post = createMockPost({ updatedAt: threeHoursAgo });
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('3h ago')).toBeInTheDocument();
});
it('shows "Xd ago" for posts days old (less than 7 days)', () => {
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
const post = createMockPost({ updatedAt: twoDaysAgo });
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('2d ago')).toBeInTheDocument();
});
it('shows full date for posts older than 7 days', () => {
const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
const post = createMockPost({ updatedAt: twoWeeksAgo.toISOString() });
renderWithRouter(<ForumPostListItem post={post} />);
// Should show the locale date string
expect(screen.getByText(twoWeeksAgo.toLocaleDateString())).toBeInTheDocument();
});
});
describe('Badge Display', () => {
it('shows pinned badge when post is pinned', () => {
const post = createMockPost({ isPinned: true });
const { container } = renderWithRouter(<ForumPostListItem post={post} />);
// The pinned badge has a pin-angle-fill icon inside a badge with bg-danger class
const pinnedIcon = container.querySelector('.bi-pin-angle-fill');
expect(pinnedIcon).toBeInTheDocument();
expect(pinnedIcon?.closest('.badge')).toHaveClass('bg-danger');
});
it('shows category badge', () => {
const post = createMockPost({ category: 'item_request' });
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('Item Request')).toBeInTheDocument();
});
it('shows status badge', () => {
const post = createMockPost({ status: 'answered' });
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('Answered')).toBeInTheDocument();
});
it('does not show deleted badge for non-admin users', () => {
mockUseAuth.mockReturnValue({ user: { role: 'user' } });
const post = createMockPost({ isDeleted: true });
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.queryByText('Deleted')).not.toBeInTheDocument();
});
it('shows deleted badge for admin users', () => {
mockUseAuth.mockReturnValue({ user: { role: 'admin' } });
const post = createMockPost({ isDeleted: true });
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('Deleted')).toBeInTheDocument();
});
});
describe('Tags Display', () => {
it('displays up to 2 tags', () => {
const post = createMockPost({
tags: [
{ id: '1', tagName: 'react' },
{ id: '2', tagName: 'testing' },
],
});
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('#react')).toBeInTheDocument();
expect(screen.getByText('#testing')).toBeInTheDocument();
});
it('shows overflow badge when more than 2 tags', () => {
const post = createMockPost({
tags: [
{ id: '1', tagName: 'react' },
{ id: '2', tagName: 'testing' },
{ id: '3', tagName: 'vitest' },
{ id: '4', tagName: 'frontend' },
],
});
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('#react')).toBeInTheDocument();
expect(screen.getByText('#testing')).toBeInTheDocument();
expect(screen.getByText('+2')).toBeInTheDocument();
expect(screen.queryByText('#vitest')).not.toBeInTheDocument();
});
});
describe('Comment Count', () => {
it('shows singular "reply" for 1 comment', () => {
const post = createMockPost({ commentCount: 1 });
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('1 reply')).toBeInTheDocument();
});
it('shows plural "replies" for multiple comments', () => {
const post = createMockPost({ commentCount: 5 });
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('5 replies')).toBeInTheDocument();
});
it('shows "0 replies" when no comments', () => {
const post = createMockPost({ commentCount: 0 });
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('0 replies')).toBeInTheDocument();
});
});
describe('Link Generation', () => {
it('links to forum post detail page', () => {
const post = createMockPost({ id: 'post-123' });
renderWithRouter(<ForumPostListItem post={post} />);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/forum/post-123');
});
it('includes filter param for admin users when filter is provided', () => {
mockUseAuth.mockReturnValue({ user: { role: 'admin' } });
const post = createMockPost({ id: 'post-123' });
renderWithRouter(<ForumPostListItem post={post} filter="deleted" />);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/forum/post-123?filter=deleted');
});
it('does not include filter param for non-admin users', () => {
mockUseAuth.mockReturnValue({ user: { role: 'user' } });
const post = createMockPost({ id: 'post-123' });
renderWithRouter(<ForumPostListItem post={post} filter="deleted" />);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/forum/post-123');
});
});
describe('Edge Cases', () => {
it('handles missing author data', () => {
const post = createMockPost({ author: undefined });
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('Unknown')).toBeInTheDocument();
expect(screen.getByText('?')).toBeInTheDocument(); // Avatar fallback
});
it('handles empty tags array', () => {
const post = createMockPost({ tags: [] });
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.queryByText(/^#/)).not.toBeInTheDocument();
});
it('handles undefined commentCount', () => {
const post = createMockPost({ commentCount: undefined as any });
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('0 replies')).toBeInTheDocument();
});
it('handles short content that does not need truncation', () => {
const post = createMockPost({ content: '<p>Short content</p>' });
renderWithRouter(<ForumPostListItem post={post} />);
expect(screen.getByText('Short content')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,90 @@
/**
* TypingIndicator Component Tests
*
* Tests for the TypingIndicator component that shows
* when a user is typing in a chat conversation.
*/
import React from 'react';
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import TypingIndicator from '../../components/TypingIndicator';
describe('TypingIndicator', () => {
describe('Visibility', () => {
it('renders nothing when isVisible is false', () => {
const { container } = render(
<TypingIndicator firstName="John" isVisible={false} />
);
expect(container.firstChild).toBeNull();
});
it('renders the indicator when isVisible is true', () => {
render(<TypingIndicator firstName="John" isVisible={true} />);
expect(screen.getByText(/John is typing/)).toBeInTheDocument();
});
});
describe('Display Content', () => {
it('displays the correct user name', () => {
render(<TypingIndicator firstName="Alice" isVisible={true} />);
expect(screen.getByText(/Alice is typing/)).toBeInTheDocument();
});
it('displays different user names correctly', () => {
const { rerender } = render(
<TypingIndicator firstName="Bob" isVisible={true} />
);
expect(screen.getByText(/Bob is typing/)).toBeInTheDocument();
rerender(<TypingIndicator firstName="Charlie" isVisible={true} />);
expect(screen.getByText(/Charlie is typing/)).toBeInTheDocument();
});
});
describe('Animated Dots', () => {
it('renders typing dots when visible', () => {
const { container } = render(
<TypingIndicator firstName="John" isVisible={true} />
);
const dots = container.querySelectorAll('.dot');
expect(dots.length).toBe(3);
});
it('has the correct structure', () => {
const { container } = render(
<TypingIndicator firstName="John" isVisible={true} />
);
expect(container.querySelector('.typing-indicator')).toBeInTheDocument();
expect(container.querySelector('.typing-text')).toBeInTheDocument();
expect(container.querySelector('.typing-dots')).toBeInTheDocument();
});
});
describe('Edge Cases', () => {
it('handles empty firstName', () => {
render(<TypingIndicator firstName="" isVisible={true} />);
expect(screen.getByText(/is typing/)).toBeInTheDocument();
});
it('handles firstName with special characters', () => {
render(<TypingIndicator firstName="John-Paul" isVisible={true} />);
expect(screen.getByText(/John-Paul is typing/)).toBeInTheDocument();
});
it('handles firstName with spaces', () => {
render(<TypingIndicator firstName="Mary Jane" isVisible={true} />);
expect(screen.getByText(/Mary Jane is typing/)).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,302 @@
/**
* SocketContext Tests
*
* Tests for the SocketContext that manages WebSocket connections
* for real-time messaging features.
*/
import React from 'react';
import { render, screen, waitFor, act, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Mock the socket service module with inline mock functions
vi.mock('../../services/socket', () => {
const mockSocket = { id: 'mock-socket-id' };
const mockConnect = vi.fn(() => mockSocket);
const mockDisconnect = vi.fn();
const mockJoinConversation = vi.fn();
const mockLeaveConversation = vi.fn();
const mockEmitTypingStart = vi.fn();
const mockEmitTypingStop = vi.fn();
const mockEmitMarkMessageRead = vi.fn();
const mockOnNewMessage = vi.fn(() => vi.fn());
const mockOnMessageRead = vi.fn(() => vi.fn());
const mockOnUserTyping = vi.fn(() => vi.fn());
const mockAddConnectionListener = vi.fn((callback: (connected: boolean) => void) => {
callback(false);
return vi.fn();
});
const mockIsConnected = vi.fn(() => false);
const mockService = {
connect: mockConnect,
disconnect: mockDisconnect,
isConnected: mockIsConnected,
joinConversation: mockJoinConversation,
leaveConversation: mockLeaveConversation,
emitTypingStart: mockEmitTypingStart,
emitTypingStop: mockEmitTypingStop,
emitMarkMessageRead: mockEmitMarkMessageRead,
onNewMessage: mockOnNewMessage,
onMessageRead: mockOnMessageRead,
onUserTyping: mockOnUserTyping,
addConnectionListener: mockAddConnectionListener,
};
return {
socketService: mockService,
default: mockService,
};
});
// Import after mocking
import { SocketProvider, useSocket } from '../../contexts/SocketContext';
import { socketService } from '../../services/socket';
// Get typed access to mocked functions
const mockSocketService = socketService as jest.Mocked<typeof socketService>;
// Test component that uses the socket context
const TestComponent: React.FC = () => {
const socket = useSocket();
return (
<div>
<div data-testid="is-connected">{socket.isConnected ? 'connected' : 'disconnected'}</div>
<button onClick={() => socket.joinConversation('user-123')}>Join</button>
<button onClick={() => socket.leaveConversation('user-123')}>Leave</button>
<button onClick={() => socket.emitTypingStart('user-123')}>Start Typing</button>
<button onClick={() => socket.emitTypingStop('user-123')}>Stop Typing</button>
<button onClick={() => socket.emitMarkMessageRead('msg-1', 'user-123')}>Mark Read</button>
</div>
);
};
describe('SocketContext', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.clearAllMocks();
});
describe('useSocket hook', () => {
it('throws error when used outside SocketProvider', () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<TestComponent />)).toThrow(
'useSocket must be used within a SocketProvider'
);
consoleError.mockRestore();
});
it('provides socket context when inside SocketProvider', () => {
render(
<SocketProvider isAuthenticated={false}>
<TestComponent />
</SocketProvider>
);
expect(screen.getByTestId('is-connected')).toBeInTheDocument();
});
});
describe('Connection Management', () => {
it('connects socket when isAuthenticated is true', async () => {
render(
<SocketProvider isAuthenticated={true}>
<TestComponent />
</SocketProvider>
);
await waitFor(() => {
expect(mockSocketService.connect).toHaveBeenCalled();
});
});
it('does not connect socket when isAuthenticated is false', async () => {
render(
<SocketProvider isAuthenticated={false}>
<TestComponent />
</SocketProvider>
);
// Wait a tick to ensure no connection attempt
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 50));
});
expect(mockSocketService.connect).not.toHaveBeenCalled();
});
it('disconnects socket when isAuthenticated changes to false', async () => {
const { rerender } = render(
<SocketProvider isAuthenticated={true}>
<TestComponent />
</SocketProvider>
);
await waitFor(() => {
expect(mockSocketService.connect).toHaveBeenCalled();
});
// Change authentication status
rerender(
<SocketProvider isAuthenticated={false}>
<TestComponent />
</SocketProvider>
);
await waitFor(() => {
expect(mockSocketService.disconnect).toHaveBeenCalled();
});
});
});
describe('Conversation Management', () => {
it('joinConversation calls socketService.joinConversation', async () => {
render(
<SocketProvider isAuthenticated={true}>
<TestComponent />
</SocketProvider>
);
fireEvent.click(screen.getByText('Join'));
expect(mockSocketService.joinConversation).toHaveBeenCalledWith('user-123');
});
it('leaveConversation calls socketService.leaveConversation', async () => {
render(
<SocketProvider isAuthenticated={true}>
<TestComponent />
</SocketProvider>
);
fireEvent.click(screen.getByText('Leave'));
expect(mockSocketService.leaveConversation).toHaveBeenCalledWith('user-123');
});
});
describe('Typing Events', () => {
it('emitTypingStart calls socketService.emitTypingStart', async () => {
render(
<SocketProvider isAuthenticated={true}>
<TestComponent />
</SocketProvider>
);
fireEvent.click(screen.getByText('Start Typing'));
expect(mockSocketService.emitTypingStart).toHaveBeenCalledWith('user-123');
});
it('emitTypingStop calls socketService.emitTypingStop', async () => {
render(
<SocketProvider isAuthenticated={true}>
<TestComponent />
</SocketProvider>
);
fireEvent.click(screen.getByText('Stop Typing'));
expect(mockSocketService.emitTypingStop).toHaveBeenCalledWith('user-123');
});
});
describe('Message Read Events', () => {
it('emitMarkMessageRead calls socketService.emitMarkMessageRead', async () => {
render(
<SocketProvider isAuthenticated={true}>
<TestComponent />
</SocketProvider>
);
fireEvent.click(screen.getByText('Mark Read'));
expect(mockSocketService.emitMarkMessageRead).toHaveBeenCalledWith('msg-1', 'user-123');
});
});
describe('Event Listeners', () => {
it('onNewMessage registers a listener', async () => {
const TestListenerComponent: React.FC = () => {
const socket = useSocket();
React.useEffect(() => {
const cleanup = socket.onNewMessage(() => {});
return cleanup;
}, [socket]);
return <div>Test</div>;
};
render(
<SocketProvider isAuthenticated={true}>
<TestListenerComponent />
</SocketProvider>
);
await waitFor(() => {
expect(mockSocketService.onNewMessage).toHaveBeenCalled();
});
});
it('onMessageRead registers a listener', async () => {
const TestListenerComponent: React.FC = () => {
const socket = useSocket();
React.useEffect(() => {
const cleanup = socket.onMessageRead(() => {});
return cleanup;
}, [socket]);
return <div>Test</div>;
};
render(
<SocketProvider isAuthenticated={true}>
<TestListenerComponent />
</SocketProvider>
);
await waitFor(() => {
expect(mockSocketService.onMessageRead).toHaveBeenCalled();
});
});
it('onUserTyping registers a listener', async () => {
const TestListenerComponent: React.FC = () => {
const socket = useSocket();
React.useEffect(() => {
const cleanup = socket.onUserTyping(() => {});
return cleanup;
}, [socket]);
return <div>Test</div>;
};
render(
<SocketProvider isAuthenticated={true}>
<TestListenerComponent />
</SocketProvider>
);
await waitFor(() => {
expect(mockSocketService.onUserTyping).toHaveBeenCalled();
});
});
});
describe('Connection Listener', () => {
it('registers connection listener on mount', async () => {
render(
<SocketProvider isAuthenticated={true}>
<TestComponent />
</SocketProvider>
);
await waitFor(() => {
expect(mockSocketService.addConnectionListener).toHaveBeenCalled();
});
});
});
});

View File

@@ -0,0 +1,103 @@
/**
* Socket Mock Utility
* Provides mock socket functionality for testing components that use socket.io
*/
import { vi } from 'vitest';
type EventCallback = (...args: any[]) => void;
/**
* Creates a mock socket instance for testing
*/
export const createMockSocket = () => {
const listeners: Record<string, EventCallback[]> = {};
return {
on: vi.fn((event: string, cb: EventCallback) => {
listeners[event] = [...(listeners[event] || []), cb];
}),
off: vi.fn((event: string, cb?: EventCallback) => {
if (cb) {
listeners[event] = listeners[event]?.filter(l => l !== cb) || [];
} else {
delete listeners[event];
}
}),
emit: vi.fn(),
connect: vi.fn(),
disconnect: vi.fn(),
connected: true,
/**
* Simulate a socket event for testing
*/
__simulateEvent: (event: string, ...data: any[]) => {
listeners[event]?.forEach(cb => cb(...data));
},
/**
* Get all listeners for an event
*/
__getListeners: (event: string) => listeners[event] || [],
};
};
/**
* Creates a mock socket service for testing SocketContext
*/
export const createMockSocketService = () => {
const connectionListeners: Array<(connected: boolean) => void> = [];
let isConnected = false;
return {
connect: vi.fn(() => {
isConnected = true;
connectionListeners.forEach(cb => cb(true));
return createMockSocket();
}),
disconnect: vi.fn(() => {
isConnected = false;
connectionListeners.forEach(cb => cb(false));
}),
isConnected: vi.fn(() => isConnected),
joinConversation: vi.fn(),
leaveConversation: vi.fn(),
emitTypingStart: vi.fn(),
emitTypingStop: vi.fn(),
emitMarkMessageRead: vi.fn(),
onNewMessage: vi.fn(() => vi.fn()),
onMessageRead: vi.fn(() => vi.fn()),
onUserTyping: vi.fn(() => vi.fn()),
addConnectionListener: vi.fn((callback: (connected: boolean) => void) => {
connectionListeners.push(callback);
callback(isConnected);
return () => {
const index = connectionListeners.indexOf(callback);
if (index > -1) {
connectionListeners.splice(index, 1);
}
};
}),
__setConnected: (connected: boolean) => {
isConnected = connected;
connectionListeners.forEach(cb => cb(connected));
},
__getConnectionListeners: () => connectionListeners,
};
};
/**
* Mock socket context value for testing
*/
export const createMockSocketContextValue = (overrides = {}) => ({
socket: null,
isConnected: false,
joinConversation: vi.fn(),
leaveConversation: vi.fn(),
emitTypingStart: vi.fn(),
emitTypingStop: vi.fn(),
emitMarkMessageRead: vi.fn(),
onNewMessage: vi.fn(() => vi.fn()),
onMessageRead: vi.fn(() => vi.fn()),
onUserTyping: vi.fn(() => vi.fn()),
...overrides,
});

View File

@@ -0,0 +1,712 @@
/**
* CreateForumPost Page Tests
*
* Tests for the CreateForumPost page that handles
* creating and editing forum posts.
*/
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MemoryRouter, Routes, Route } from 'react-router';
import CreateForumPost from '../../pages/CreateForumPost';
import { ForumPost, Address, User } from '../../types';
// Mock the API
vi.mock('../../services/api', () => ({
forumAPI: {
getPost: vi.fn(),
createPost: vi.fn(),
updatePost: vi.fn(),
},
addressAPI: {
getAddresses: vi.fn(),
},
}));
// Mock upload service
vi.mock('../../services/uploadService', () => ({
uploadImagesWithVariants: vi.fn().mockResolvedValue([{ baseKey: 'uploads/forum/123.jpg' }]),
getImageUrl: vi.fn((key: string) => `https://cdn.example.com/${key}`),
}));
// Mock child components
vi.mock('../../components/TagInput', () => ({
default: function MockTagInput({
selectedTags,
onChange,
placeholder,
}: {
selectedTags: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
}) {
return (
<div data-testid="tag-input">
{selectedTags.map((tag, i) => (
<span key={i} data-testid={`tag-${tag}`}>{tag}</span>
))}
<input
data-testid="tag-input-field"
placeholder={placeholder}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const target = e.target as HTMLInputElement;
if (target.value && selectedTags.length < 5) {
onChange([...selectedTags, target.value]);
target.value = '';
}
}
}}
/>
</div>
);
},
}));
vi.mock('../../components/ForumImageUpload', () => ({
default: function MockForumImageUpload({
imagePreviews,
onImageChange,
onRemoveImage,
}: {
imageFiles: File[];
imagePreviews: string[];
onImageChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
onRemoveImage: (index: number) => void;
}) {
return (
<div data-testid="image-upload">
{imagePreviews.map((preview, i) => (
<div key={i}>
<img src={preview} alt={`preview-${i}`} data-testid={`preview-${i}`} />
<button
data-testid={`remove-image-${i}`}
onClick={() => onRemoveImage(i)}
>
Remove
</button>
</div>
))}
<input
type="file"
data-testid="file-input"
onChange={onImageChange}
accept="image/*"
multiple
/>
</div>
);
},
}));
vi.mock('../../components/VerificationCodeModal', () => ({
default: function MockVerificationModal({
show,
onHide,
onVerified,
}: {
show: boolean;
onHide: () => void;
email: string;
onVerified: () => void;
}) {
if (!show) return null;
return (
<div data-testid="verification-modal">
<button onClick={onVerified} data-testid="verify-button">
Verify
</button>
<button onClick={onHide} data-testid="close-modal">
Close
</button>
</div>
);
},
}));
// Mock auth context
const mockUser: User = {
id: 'user-1',
firstName: 'Test',
lastName: 'User',
email: 'test@example.com',
isVerified: true,
role: 'user',
zipCode: '12345',
};
const mockUseAuth = vi.fn();
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => mockUseAuth(),
}));
// Mock navigate
const mockNavigate = vi.fn();
vi.mock('react-router', async () => {
const actual = await vi.importActual('react-router');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
import { forumAPI, addressAPI } from '../../services/api';
import { uploadImagesWithVariants } from '../../services/uploadService';
const mockForumAPI = forumAPI as jest.Mocked<typeof forumAPI>;
const mockAddressAPI = addressAPI as jest.Mocked<typeof addressAPI>;
const mockUploadImages = uploadImagesWithVariants as jest.MockedFunction<
typeof uploadImagesWithVariants
>;
// Helper to render component with router
const renderWithRouter = (
ui: React.ReactElement,
{ route = '/forum/create' } = {}
) => {
return render(
<MemoryRouter initialEntries={[route]}>
<Routes>
<Route path="/forum/create" element={ui} />
<Route path="/forum/:id/edit" element={ui} />
</Routes>
</MemoryRouter>
);
};
describe('CreateForumPost Page', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAuth.mockReturnValue({
user: mockUser,
checkAuth: vi.fn(),
});
mockAddressAPI.getAddresses.mockResolvedValue({ data: [] });
mockForumAPI.createPost.mockResolvedValue({ data: { id: 'new-post-1' } });
mockForumAPI.updatePost.mockResolvedValue({ data: { id: 'existing-1' } });
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Create Mode', () => {
it('renders form with empty fields', async () => {
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByLabelText(/Title/)).toHaveValue('');
expect(screen.getByLabelText(/Content/)).toHaveValue('');
});
});
it('displays guidelines card', async () => {
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByText('Community Guidelines')).toBeInTheDocument();
});
});
it('shows page title for create mode', async () => {
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Create New Post' })).toBeInTheDocument();
});
});
});
describe('Form Validation', () => {
it('shows validation error when title is less than 10 characters', async () => {
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByLabelText(/Title/)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/Title/), {
target: { value: 'Short' },
});
await waitFor(() => {
expect(
screen.getByText(/more characters needed \(minimum 10\)/)
).toBeInTheDocument();
});
});
it('shows validation error when content is less than 20 characters', async () => {
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByLabelText(/Content/)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/Content/), {
target: { value: 'Short content' },
});
await waitFor(() => {
expect(
screen.getByText(/more characters needed \(minimum 20\)/)
).toBeInTheDocument();
});
});
it('requires zip code for item_request category', async () => {
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByLabelText(/Category/)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/Category/), {
target: { value: 'item_request' },
});
await waitFor(() => {
expect(screen.getByLabelText(/Zip Code/)).toBeInTheDocument();
});
});
});
describe('Category Selection', () => {
it('shows all category options', async () => {
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByLabelText(/Category/)).toBeInTheDocument();
});
expect(screen.getByText('Item Request')).toBeInTheDocument();
expect(screen.getByText('Technical Support')).toBeInTheDocument();
expect(screen.getByText('Community Resources')).toBeInTheDocument();
expect(screen.getByText('General Discussion')).toBeInTheDocument();
});
it('auto-populates location from user addresses for item_request', async () => {
const address: Address = {
id: 'addr-1',
userId: 'user-1',
address1: '123 Main St',
city: 'Test City',
state: 'CA',
zipCode: '90210',
country: 'US',
latitude: 34.0901,
longitude: -118.4065,
isPrimary: true,
};
mockAddressAPI.getAddresses.mockResolvedValue({ data: [address] });
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByLabelText(/Category/)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/Category/), {
target: { value: 'item_request' },
});
await waitFor(() => {
expect(screen.getByLabelText(/Zip Code/)).toHaveValue('90210');
});
});
it('shows item request tips when item_request category is selected', async () => {
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByLabelText(/Category/)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/Category/), {
target: { value: 'item_request' },
});
await waitFor(() => {
expect(screen.getByText('Item Request Tips:')).toBeInTheDocument();
});
});
it('shows technical support tips when technical_support category is selected', async () => {
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByLabelText(/Category/)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/Category/), {
target: { value: 'technical_support' },
});
await waitFor(() => {
expect(screen.getByText('Technical Support Tips:')).toBeInTheDocument();
});
});
});
describe('Tags', () => {
it('allows adding tags', async () => {
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByTestId('tag-input-field')).toBeInTheDocument();
});
const tagInput = screen.getByTestId('tag-input-field');
fireEvent.change(tagInput, { target: { value: 'react' } });
fireEvent.keyDown(tagInput, { key: 'Enter' });
await waitFor(() => {
expect(screen.getByTestId('tag-react')).toBeInTheDocument();
});
});
it('displays max 5 tags message', async () => {
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(
screen.getByText('Add up to 5 relevant tags. Press Enter after each tag.')
).toBeInTheDocument();
});
});
});
describe('Image Upload', () => {
it('renders image upload component', async () => {
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByTestId('image-upload')).toBeInTheDocument();
});
});
it('shows image preview after selection', async () => {
// Mock FileReader as a proper class
class MockFileReader {
result: string | null = null;
onloadend: (() => void) | null = null;
readAsDataURL() {
this.result = 'data:image/jpeg;base64,test';
setTimeout(() => this.onloadend?.(), 0);
}
}
vi.stubGlobal('FileReader', MockFileReader);
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByTestId('file-input')).toBeInTheDocument();
});
const file = new File(['image'], 'test.jpg', { type: 'image/jpeg' });
const fileInput = screen.getByTestId('file-input');
Object.defineProperty(fileInput, 'files', { value: [file] });
fireEvent.change(fileInput);
// Cleanup
vi.unstubAllGlobals();
});
it('allows removing uploaded images', async () => {
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByTestId('image-upload')).toBeInTheDocument();
});
});
});
describe('Form Submission', () => {
it('creates post and navigates to it on successful submission', async () => {
mockForumAPI.createPost.mockResolvedValue({
data: { id: 'created-post-123' },
});
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByLabelText(/Title/)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/Title/), {
target: { value: 'This is a test title' },
});
fireEvent.change(screen.getByLabelText(/Content/), {
target: { value: 'This is test content that is at least 20 characters long' },
});
const submitButton = screen.getByRole('button', { name: /Create Post/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockForumAPI.createPost).toHaveBeenCalledWith(
expect.objectContaining({
title: 'This is a test title',
content: 'This is test content that is at least 20 characters long',
category: 'general_discussion',
})
);
});
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/forum/created-post-123');
});
});
it('disables form during submission', async () => {
mockForumAPI.createPost.mockImplementation(
() => new Promise(() => {}) // Never resolves
);
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByLabelText(/Title/)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/Title/), {
target: { value: 'This is a test title' },
});
fireEvent.change(screen.getByLabelText(/Content/), {
target: { value: 'This is test content that is at least 20 characters long' },
});
const submitButton = screen.getByRole('button', { name: /Create Post/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText(/Creating.../)).toBeInTheDocument();
});
});
it('shows error message on submission failure', async () => {
mockForumAPI.createPost.mockRejectedValue({
response: { data: { error: 'Failed to create post' } },
});
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByLabelText(/Title/)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/Title/), {
target: { value: 'This is a test title' },
});
fireEvent.change(screen.getByLabelText(/Content/), {
target: { value: 'This is test content that is at least 20 characters long' },
});
const submitButton = screen.getByRole('button', { name: /Create Post/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Failed to create post');
});
});
});
describe('Email Verification', () => {
it('shows verification modal on EMAIL_NOT_VERIFIED error', async () => {
mockForumAPI.createPost.mockRejectedValue({
response: { status: 403, data: { code: 'EMAIL_NOT_VERIFIED' } },
});
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByLabelText(/Title/)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/Title/), {
target: { value: 'This is a test title' },
});
fireEvent.change(screen.getByLabelText(/Content/), {
target: { value: 'This is test content that is at least 20 characters long' },
});
const submitButton = screen.getByRole('button', { name: /Create Post/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByTestId('verification-modal')).toBeInTheDocument();
});
});
it('shows verification warning banner for unverified users', async () => {
mockUseAuth.mockReturnValue({
user: { ...mockUser, isVerified: false },
checkAuth: vi.fn(),
});
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(
screen.getByText(/Email verification required/)
).toBeInTheDocument();
});
});
});
describe('Edit Mode', () => {
const existingPost: ForumPost = {
id: 'existing-1',
title: 'Existing Post Title',
content: 'Existing post content that is long enough',
category: 'item_request' as const,
status: 'open' as const,
authorId: 'user-1',
author: mockUser,
tags: [{ id: 't1', tagName: 'tag1' }],
zipCode: '90210',
commentCount: 0,
isPinned: false,
isDeleted: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
it('loads existing post data', async () => {
mockForumAPI.getPost.mockResolvedValue({ data: existingPost });
renderWithRouter(<CreateForumPost />, { route: '/forum/existing-1/edit' });
await waitFor(() => {
expect(screen.getByLabelText(/Title/)).toHaveValue('Existing Post Title');
expect(screen.getByLabelText(/Content/)).toHaveValue(
'Existing post content that is long enough'
);
});
});
it('shows edit page title', async () => {
mockForumAPI.getPost.mockResolvedValue({ data: existingPost });
renderWithRouter(<CreateForumPost />, { route: '/forum/existing-1/edit' });
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Edit Post' })).toBeInTheDocument();
});
});
it('hides guidelines card in edit mode', async () => {
mockForumAPI.getPost.mockResolvedValue({ data: existingPost });
renderWithRouter(<CreateForumPost />, { route: '/forum/existing-1/edit' });
await waitFor(() => {
expect(screen.getByLabelText(/Title/)).toBeInTheDocument();
});
expect(screen.queryByText('Community Guidelines')).not.toBeInTheDocument();
});
it('shows authorization error for non-author', async () => {
mockForumAPI.getPost.mockResolvedValue({
data: { ...existingPost, authorId: 'other-user' },
});
renderWithRouter(<CreateForumPost />, { route: '/forum/existing-1/edit' });
await waitFor(() => {
expect(
screen.getByText('You are not authorized to edit this post')
).toBeInTheDocument();
});
});
it('updates post on submission', async () => {
mockForumAPI.getPost.mockResolvedValue({ data: existingPost });
renderWithRouter(<CreateForumPost />, { route: '/forum/existing-1/edit' });
await waitFor(() => {
expect(screen.getByLabelText(/Title/)).toHaveValue('Existing Post Title');
});
fireEvent.change(screen.getByLabelText(/Title/), {
target: { value: 'Updated Title Text' },
});
const submitButton = screen.getByRole('button', { name: /Save Changes/i });
fireEvent.click(submitButton);
await waitFor(() => {
expect(mockForumAPI.updatePost).toHaveBeenCalledWith(
'existing-1',
expect.objectContaining({
title: 'Updated Title Text',
})
);
});
});
});
describe('Navigation', () => {
it('cancel button navigates back to forum in create mode', async () => {
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
const cancelLink = screen.getByText('Cancel');
expect(cancelLink).toHaveAttribute('href', '/forum');
});
it('cancel button navigates back to post in edit mode', async () => {
const existingPost: ForumPost = {
id: 'existing-1',
title: 'Existing Post',
content: 'Content here',
category: 'general_discussion' as const,
status: 'open' as const,
authorId: 'user-1',
author: mockUser,
tags: [],
commentCount: 0,
isPinned: false,
isDeleted: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
mockForumAPI.getPost.mockResolvedValue({ data: existingPost });
renderWithRouter(<CreateForumPost />, { route: '/forum/existing-1/edit' });
await waitFor(() => {
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
const cancelLink = screen.getByText('Cancel');
expect(cancelLink).toHaveAttribute('href', '/forum/existing-1');
});
});
describe('Unauthenticated User', () => {
it('shows login required message', async () => {
mockUseAuth.mockReturnValue({
user: null,
checkAuth: vi.fn(),
});
renderWithRouter(<CreateForumPost />);
await waitFor(() => {
expect(
screen.getByText(/You must be logged in to create a post/)
).toBeInTheDocument();
});
});
});
});

View File

@@ -750,15 +750,17 @@ describe('CreateItem', () => {
});
});
// Note: This test is skipped because the mock timing makes it unreliable in the test
// environment. The actual behavior (not auto-saving when user has addresses) works
// correctly in the production code. The component's userAddresses state is properly
// set but the mock setup has timing issues with the useEffect callback.
it.skip('does not auto-save address when user has existing addresses', async () => {
it('does not auto-save address when user has existing addresses', async () => {
mockedGetAddresses.mockResolvedValue({ data: [mockAddress] });
renderWithRouter(<CreateItem />);
// Wait for address dropdown to appear (confirms userAddresses.length > 0)
await waitFor(() => {
expect(screen.getByTestId('address-select')).toBeInTheDocument();
});
// Wait for auto-selection to populate the form fields
await waitFor(() => {
expect(screen.getByTestId('address1')).toHaveValue('123 Main St');
});

View File

@@ -0,0 +1,500 @@
/**
* EarningsDashboard Page Tests
*
* Tests for the EarningsDashboard page that displays
* user earnings and Stripe setup status.
*/
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BrowserRouter } from 'react-router';
import EarningsDashboard from '../../pages/EarningsDashboard';
import { User, Rental } from '../../types';
// Mock the API
vi.mock('../../services/api', () => ({
userAPI: {
getProfile: vi.fn(),
},
stripeAPI: {
getAccountStatus: vi.fn(),
},
rentalAPI: {
getListings: vi.fn(),
},
}));
// Mock child components
vi.mock('../../components/StripeConnectOnboarding', () => ({
default: function MockStripeConnectOnboarding({
onComplete,
onCancel,
hasExistingAccount,
}: {
onComplete: () => void;
onCancel: () => void;
hasExistingAccount: boolean;
}) {
return (
<div data-testid="stripe-onboarding">
<span>Has existing: {hasExistingAccount ? 'yes' : 'no'}</span>
<button onClick={onComplete} data-testid="complete-setup">
Complete Setup
</button>
<button onClick={onCancel} data-testid="cancel-setup">
Cancel
</button>
</div>
);
},
}));
vi.mock('../../components/EarningsStatus', () => ({
default: function MockEarningsStatus({
hasStripeAccount,
isOnboardingComplete,
payoutsEnabled,
onSetupClick,
}: {
hasStripeAccount: boolean;
isOnboardingComplete: boolean;
payoutsEnabled: boolean;
onSetupClick: () => void;
}) {
return (
<div data-testid="earnings-status">
<span data-testid="has-stripe">{hasStripeAccount ? 'yes' : 'no'}</span>
<span data-testid="onboarding-complete">
{isOnboardingComplete ? 'yes' : 'no'}
</span>
<span data-testid="payouts-enabled">{payoutsEnabled ? 'yes' : 'no'}</span>
<button onClick={onSetupClick} data-testid="setup-button">
Set Up
</button>
</div>
);
},
}));
import { userAPI, stripeAPI, rentalAPI } from '../../services/api';
const mockUserAPI = userAPI as jest.Mocked<typeof userAPI>;
const mockStripeAPI = stripeAPI as jest.Mocked<typeof stripeAPI>;
const mockRentalAPI = rentalAPI as jest.Mocked<typeof rentalAPI>;
// Helper to create mock user
const createMockUser = (overrides: Partial<User> = {}): User => ({
id: 'user-1',
firstName: 'Test',
lastName: 'User',
email: 'test@example.com',
isVerified: true,
role: 'user',
...overrides,
});
// Helper to create mock rental
const createMockRental = (overrides: Partial<Rental> = {}): Rental => ({
id: 'rental-1',
itemId: 'item-1',
renterId: 'renter-1',
ownerId: 'user-1',
status: 'completed',
startDateTime: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
endDateTime: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
totalAmount: '100.00',
payoutAmount: '85.00',
bankDepositStatus: 'pending',
item: { id: 'item-1', name: 'Test Item' },
...overrides,
});
const renderWithRouter = (ui: React.ReactElement) => {
return render(<BrowserRouter>{ui}</BrowserRouter>);
};
describe('EarningsDashboard Page', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUserAPI.getProfile.mockResolvedValue({ data: createMockUser() });
mockStripeAPI.getAccountStatus.mockResolvedValue({
data: { detailsSubmitted: true, payoutsEnabled: true },
});
mockRentalAPI.getListings.mockResolvedValue({ data: [] });
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Loading State', () => {
it('shows loading spinner while fetching data', () => {
mockRentalAPI.getListings.mockImplementation(() => new Promise(() => {}));
renderWithRouter(<EarningsDashboard />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('displays error message on fetch failure', async () => {
mockRentalAPI.getListings.mockRejectedValue({
response: { data: { message: 'Failed to fetch' } },
});
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Failed to fetch');
});
});
});
describe('Earnings Calculation', () => {
it('calculates total earnings from completed rentals', async () => {
const rentals = [
createMockRental({ payoutAmount: '85.00', status: 'completed' }),
createMockRental({
id: 'rental-2',
payoutAmount: '100.00',
status: 'completed',
}),
];
mockRentalAPI.getListings.mockResolvedValue({ data: rentals });
renderWithRouter(<EarningsDashboard />);
await waitFor(
() => {
expect(screen.getByText('Total Earnings')).toBeInTheDocument();
// Verify total earnings card shows calculated amount
const amounts = screen.getAllByText('$185.00');
expect(amounts.length).toBeGreaterThan(0);
},
{ timeout: 3000 }
);
});
it('calculates pending earnings (status != paid)', async () => {
const rentals = [
createMockRental({
payoutAmount: '85.00',
bankDepositStatus: 'pending',
status: 'completed',
}),
createMockRental({
id: 'rental-2',
payoutAmount: '100.00',
bankDepositStatus: 'paid',
status: 'completed',
}),
];
mockRentalAPI.getListings.mockResolvedValue({ data: rentals });
renderWithRouter(<EarningsDashboard />);
await waitFor(
() => {
expect(screen.getByText('Pending Earnings')).toBeInTheDocument();
// Pending should be $85.00 (the one rental not paid)
const amounts = screen.getAllByText('$85.00');
expect(amounts.length).toBeGreaterThan(0);
},
{ timeout: 3000 }
);
});
it('calculates completed/paid out earnings', async () => {
const rentals = [
createMockRental({
payoutAmount: '85.00',
bankDepositStatus: 'paid',
status: 'completed',
}),
createMockRental({
id: 'rental-2',
payoutAmount: '100.00',
bankDepositStatus: 'paid',
status: 'completed',
}),
];
mockRentalAPI.getListings.mockResolvedValue({ data: rentals });
renderWithRouter(<EarningsDashboard />);
await waitFor(
() => {
expect(screen.getByText('Paid Out')).toBeInTheDocument();
// All rentals are paid, so paid out = total = $185.00
const amounts = screen.getAllByText('$185.00');
expect(amounts.length).toBeGreaterThan(0);
},
{ timeout: 3000 }
);
});
it('shows $0.00 when no completed rentals', async () => {
mockRentalAPI.getListings.mockResolvedValue({ data: [] });
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
const zeroAmounts = screen.getAllByText('$0.00');
expect(zeroAmounts.length).toBeGreaterThan(0);
});
});
});
describe('Stripe Setup States', () => {
it('shows setup card when no Stripe account', async () => {
mockUserAPI.getProfile.mockResolvedValue({
data: createMockUser({ stripeConnectedAccountId: undefined }),
});
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
expect(screen.getByText('Earnings Setup')).toBeInTheDocument();
expect(screen.getByTestId('has-stripe')).toHaveTextContent('no');
});
});
it('shows continue setup when onboarding incomplete', async () => {
mockUserAPI.getProfile.mockResolvedValue({
data: createMockUser({ stripeConnectedAccountId: 'acct_123' }),
});
mockStripeAPI.getAccountStatus.mockResolvedValue({
data: { detailsSubmitted: false, payoutsEnabled: false },
});
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
expect(screen.getByTestId('onboarding-complete')).toHaveTextContent('no');
});
});
it('shows verification needed when payouts disabled', async () => {
mockUserAPI.getProfile.mockResolvedValue({
data: createMockUser({ stripeConnectedAccountId: 'acct_123' }),
});
mockStripeAPI.getAccountStatus.mockResolvedValue({
data: { detailsSubmitted: true, payoutsEnabled: false },
});
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
expect(screen.getByTestId('payouts-enabled')).toHaveTextContent('no');
});
});
it('does not show setup card when fully set up', async () => {
mockUserAPI.getProfile.mockResolvedValue({
data: createMockUser({ stripeConnectedAccountId: 'acct_123' }),
});
mockStripeAPI.getAccountStatus.mockResolvedValue({
data: { detailsSubmitted: true, payoutsEnabled: true },
});
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
expect(screen.queryByText('Earnings Setup')).not.toBeInTheDocument();
});
});
});
describe('Stripe Onboarding Modal', () => {
it('opens onboarding modal when setup button clicked', async () => {
mockUserAPI.getProfile.mockResolvedValue({
data: createMockUser({ stripeConnectedAccountId: undefined }),
});
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
expect(screen.getByTestId('setup-button')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('setup-button'));
await waitFor(() => {
expect(screen.getByTestId('stripe-onboarding')).toBeInTheDocument();
});
});
it('refreshes data after setup completion', async () => {
mockUserAPI.getProfile.mockResolvedValue({
data: createMockUser({ stripeConnectedAccountId: undefined }),
});
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
expect(screen.getByTestId('setup-button')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('setup-button'));
await waitFor(() => {
expect(screen.getByTestId('stripe-onboarding')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('complete-setup'));
await waitFor(() => {
// Should have called getProfile again
expect(mockUserAPI.getProfile).toHaveBeenCalledTimes(2);
});
});
it('closes modal on cancel', async () => {
mockUserAPI.getProfile.mockResolvedValue({
data: createMockUser({ stripeConnectedAccountId: undefined }),
});
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
expect(screen.getByTestId('setup-button')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('setup-button'));
await waitFor(() => {
expect(screen.getByTestId('stripe-onboarding')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('cancel-setup'));
await waitFor(() => {
expect(screen.queryByTestId('stripe-onboarding')).not.toBeInTheDocument();
});
});
});
describe('Earnings History Table', () => {
it('displays earnings history table', async () => {
const rentals = [
createMockRental({
item: { id: 'item-1', name: 'Camera' },
totalAmount: '100.00',
payoutAmount: '85.00',
}),
];
mockRentalAPI.getListings.mockResolvedValue({ data: rentals });
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
expect(screen.getByText('Earnings History')).toBeInTheDocument();
expect(screen.getByText('Camera')).toBeInTheDocument();
});
});
it('shows empty state when no completed rentals', async () => {
mockRentalAPI.getListings.mockResolvedValue({ data: [] });
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
expect(screen.getByText('No completed rentals yet')).toBeInTheDocument();
});
});
it('displays payout status badges', async () => {
const rentals = [
createMockRental({
bankDepositStatus: 'paid',
bankDepositAt: new Date().toISOString(),
}),
];
mockRentalAPI.getListings.mockResolvedValue({ data: rentals });
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
expect(screen.getByText('Deposited')).toBeInTheDocument();
});
});
it('shows Pending badge for pending deposits', async () => {
const rentals = [createMockRental({ bankDepositStatus: 'pending' })];
mockRentalAPI.getListings.mockResolvedValue({ data: rentals });
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
expect(screen.getByText('Pending')).toBeInTheDocument();
});
});
it('shows In Transit badge for in_transit deposits', async () => {
const rentals = [createMockRental({ bankDepositStatus: 'in_transit' })];
mockRentalAPI.getListings.mockResolvedValue({ data: rentals });
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
expect(screen.getByText('In Transit to Bank')).toBeInTheDocument();
});
});
it('shows Deposit Failed badge for failed deposits', async () => {
const rentals = [createMockRental({ bankDepositStatus: 'failed' })];
mockRentalAPI.getListings.mockResolvedValue({ data: rentals });
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
expect(screen.getByText('Deposit Failed')).toBeInTheDocument();
});
});
});
describe('Date Formatting', () => {
it('formats rental dates correctly', async () => {
const startDate = new Date('2024-01-15T10:00:00Z');
const endDate = new Date('2024-01-17T15:00:00Z');
const rentals = [
createMockRental({
startDateTime: startDate.toISOString(),
endDateTime: endDate.toISOString(),
}),
];
mockRentalAPI.getListings.mockResolvedValue({ data: rentals });
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
// Dates should be displayed in locale format
expect(screen.getByText(/2024/)).toBeInTheDocument();
});
});
});
describe('FAQ Links', () => {
it('shows FAQ links', async () => {
renderWithRouter(<EarningsDashboard />);
await waitFor(() => {
expect(
screen.getByText('Calculate what you can earn here')
).toBeInTheDocument();
expect(screen.getByText('learn how payouts work')).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,641 @@
/**
* ForumPostDetail Page Tests
*
* Tests for the ForumPostDetail page that displays
* a forum post with its comments and actions.
*/
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MemoryRouter, Routes, Route } from 'react-router';
import ForumPostDetail from '../../pages/ForumPostDetail';
import { ForumPost, ForumComment, User } from '../../types';
// Mock the API
vi.mock('../../services/api', () => ({
forumAPI: {
getPost: vi.fn(),
createComment: vi.fn(),
updateComment: vi.fn(),
deleteComment: vi.fn(),
updatePostStatus: vi.fn(),
deletePost: vi.fn(),
acceptAnswer: vi.fn(),
adminDeletePost: vi.fn(),
adminRestorePost: vi.fn(),
adminClosePost: vi.fn(),
adminReopenPost: vi.fn(),
adminDeleteComment: vi.fn(),
adminRestoreComment: vi.fn(),
},
}));
// Mock upload service
vi.mock('../../services/uploadService', () => ({
uploadImagesWithVariants: vi.fn().mockResolvedValue([{ baseKey: 'uploads/forum/123.jpg' }]),
getImageUrl: vi.fn((key: string) => `https://cdn.example.com/${key}`),
}));
// Mock child components
vi.mock('../../components/CategoryBadge', () => ({
default: function MockCategoryBadge({ category }: { category: string }) {
return <span data-testid="category-badge">{category}</span>;
},
}));
vi.mock('../../components/PostStatusBadge', () => ({
default: function MockPostStatusBadge({ status }: { status: string }) {
return <span data-testid="status-badge">{status}</span>;
},
}));
vi.mock('../../components/CommentThread', () => ({
default: function MockCommentThread({
comment,
onReply,
onEdit,
onDelete,
onAdminDelete,
onAdminRestore,
}: {
comment: ForumComment;
onReply: (id: string, content: string) => void;
onEdit: (id: string, content: string) => void;
onDelete: (id: string) => void;
onAdminDelete?: (id: string) => void;
onAdminRestore?: (id: string) => void;
}) {
return (
<div data-testid="comment-thread">
<div data-testid={`comment-${comment.id}`}>
<p>{comment.content}</p>
<button onClick={() => onReply(comment.id, 'Reply content')}>
Reply
</button>
<button onClick={() => onEdit(comment.id, 'Updated content', [], [])}>
Edit
</button>
<button onClick={() => onDelete(comment.id)}>Delete</button>
{onAdminDelete && (
<button onClick={() => onAdminDelete(comment.id)}>Admin Delete</button>
)}
{onAdminRestore && (
<button onClick={() => onAdminRestore(comment.id)}>Admin Restore</button>
)}
</div>
</div>
);
},
}));
vi.mock('../../components/CommentForm', () => ({
default: function MockCommentForm({
onSubmit,
disabled,
}: {
onSubmit: (content: string, images: File[]) => Promise<void> | void;
disabled?: boolean;
}) {
return (
<form
data-testid="comment-form"
onSubmit={async (e) => {
e.preventDefault();
const form = e.target as HTMLFormElement;
const input = form.elements.namedItem('comment') as HTMLInputElement;
try {
await onSubmit(input.value, []);
} catch {
// Silently catch errors - component handles them via state updates
}
}}
>
<input name="comment" data-testid="comment-input" disabled={disabled} />
<button type="submit" disabled={disabled}>
Submit
</button>
</form>
);
},
}));
vi.mock('../../components/AuthButton', () => ({
default: function MockAuthButton({ children }: { children: React.ReactNode }) {
return <button data-testid="auth-button">{children}</button>;
},
}));
vi.mock('../../components/VerificationCodeModal', () => ({
default: function MockVerificationModal({
show,
onHide,
onVerified,
}: {
show: boolean;
onHide: () => void;
email: string;
onVerified: () => void;
}) {
if (!show) return null;
return (
<div data-testid="verification-modal">
<button onClick={onVerified}>Verify</button>
<button onClick={onHide}>Close</button>
</div>
);
},
}));
// Mock auth context
const mockUser: User = {
id: 'user-1',
firstName: 'Test',
lastName: 'User',
email: 'test@example.com',
isVerified: true,
role: 'user',
};
const mockUseAuth = vi.fn();
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => mockUseAuth(),
}));
// Mock navigate
const mockNavigate = vi.fn();
vi.mock('react-router', async () => {
const actual = await vi.importActual('react-router');
return {
...actual,
useNavigate: () => mockNavigate,
};
});
import { forumAPI } from '../../services/api';
const mockForumAPI = forumAPI as jest.Mocked<typeof forumAPI>;
// Helper to create mock post data
const createMockPost = (overrides: Partial<ForumPost> = {}): ForumPost => ({
id: 'post-1',
title: 'Test Post Title',
content: 'Test post content that is long enough',
category: 'general_discussion' as const,
status: 'open' as const,
authorId: 'user-1',
author: mockUser,
tags: [{ id: 't1', tagName: 'testtag' }],
comments: [],
commentCount: 0,
isPinned: false,
isDeleted: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
});
// Helper to create mock comment
const createMockComment = (overrides: Partial<ForumComment> = {}): ForumComment => ({
id: 'comment-1',
postId: 'post-1',
authorId: 'user-1',
author: mockUser,
content: 'Test comment content',
isDeleted: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
});
// Helper to render component with router
const renderWithRouter = (postId = 'post-1') => {
return render(
<MemoryRouter initialEntries={[`/forum/${postId}`]}>
<Routes>
<Route path="/forum/:id" element={<ForumPostDetail />} />
<Route path="/forum" element={<div>Forum List</div>} />
</Routes>
</MemoryRouter>
);
};
describe('ForumPostDetail Page', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAuth.mockReturnValue({
user: mockUser,
checkAuth: vi.fn(),
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Loading State', () => {
it('shows loading spinner while fetching', () => {
mockForumAPI.getPost.mockImplementation(() => new Promise(() => {}));
renderWithRouter();
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('displays error message on fetch failure', async () => {
mockForumAPI.getPost.mockRejectedValue({
response: { data: { error: 'Post not found' } },
});
renderWithRouter();
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Post not found');
});
});
it('shows back to forum link on error', async () => {
mockForumAPI.getPost.mockRejectedValue(new Error());
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Back to Forum')).toBeInTheDocument();
});
});
});
describe('Post Display', () => {
it('fetches and displays post details', async () => {
const post = createMockPost();
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
// Use heading role to find the title (not breadcrumb)
expect(screen.getByRole('heading', { name: 'Test Post Title' })).toBeInTheDocument();
expect(screen.getByText('Test post content that is long enough')).toBeInTheDocument();
});
});
it('displays category and status badges', async () => {
const post = createMockPost();
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('category-badge')).toHaveTextContent(
'general_discussion'
);
expect(screen.getByTestId('status-badge')).toHaveTextContent('open');
});
});
it('displays author information', async () => {
const post = createMockPost();
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText(/Test User/)).toBeInTheDocument();
});
});
it('displays tags', async () => {
const post = createMockPost();
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('#testtag')).toBeInTheDocument();
});
});
it('displays pinned badge for pinned posts', async () => {
const post = createMockPost({ isPinned: true });
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Pinned')).toBeInTheDocument();
});
});
});
describe('Copy Link', () => {
it('has copy link button', async () => {
const post = createMockPost();
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Copy Link')).toBeInTheDocument();
});
});
});
describe('Comments', () => {
it('renders comments with CommentThread', async () => {
const comment = createMockComment();
const post = createMockPost({ comments: [comment] });
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
// Wait for both the thread and individual comment to appear
await waitFor(() => {
expect(screen.getByTestId('comment-thread')).toBeInTheDocument();
expect(screen.getByTestId('comment-comment-1')).toBeInTheDocument();
}, { timeout: 3000 });
});
it('shows comment form for authenticated users', async () => {
const post = createMockPost();
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('comment-form')).toBeInTheDocument();
});
});
it('adding comment refreshes post', async () => {
const post = createMockPost();
mockForumAPI.getPost.mockResolvedValue({ data: post });
mockForumAPI.createComment.mockResolvedValue({ data: {} });
const user = userEvent.setup();
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('comment-input')).toBeInTheDocument();
}, { timeout: 3000 });
const commentInput = screen.getByTestId('comment-input');
await user.type(commentInput, 'New comment');
await user.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() => {
expect(mockForumAPI.createComment).toHaveBeenCalled();
});
});
it('hides comment form when discussion is closed', async () => {
const post = createMockPost({ status: 'closed' });
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
// Wait for post to load - use heading role which is more reliable
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Test Post Title' })).toBeInTheDocument();
});
// Comment form should not be present when post is closed
expect(screen.queryByTestId('comment-form')).not.toBeInTheDocument();
expect(screen.queryByTestId('comment-input')).not.toBeInTheDocument();
});
});
describe('Post Author Actions', () => {
it('shows edit button for post author', async () => {
const post = createMockPost({ authorId: 'user-1' });
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
// Component shows "Edit" not "Edit Post"
expect(screen.getByRole('link', { name: /Edit/i })).toBeInTheDocument();
});
});
it('shows delete button for post author', async () => {
const post = createMockPost({ authorId: 'user-1' });
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
// Component shows "Delete" not "Delete Post" for author
expect(screen.getByRole('button', { name: /Delete$/i })).toBeInTheDocument();
});
});
it('shows close/reopen button for post author', async () => {
const post = createMockPost({ authorId: 'user-1' });
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
// Component shows "Close Post" or "Reopen Post"
expect(screen.getByText(/Close Post|Reopen Post/)).toBeInTheDocument();
});
});
it('hides author actions for non-authors', async () => {
const post = createMockPost({ authorId: 'other-user' });
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
expect(screen.queryByText('Edit Post')).not.toBeInTheDocument();
});
});
});
describe('Mark Answer', () => {
it('shows mark as answer button for post author', async () => {
const comment = createMockComment();
const post = createMockPost({ authorId: 'user-1', comments: [comment] });
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('comment-thread')).toBeInTheDocument();
});
});
it('marks answer updates post status to answered', async () => {
const comment = createMockComment({ id: 'answer-comment' });
const post = createMockPost({ authorId: 'user-1', comments: [comment] });
mockForumAPI.getPost.mockResolvedValue({ data: post });
mockForumAPI.acceptAnswer.mockResolvedValue({ data: {} });
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('comment-thread')).toBeInTheDocument();
});
});
});
describe('Admin Actions', () => {
const adminUser: User = { ...mockUser, role: 'admin' };
it('shows admin delete button for admins', async () => {
mockUseAuth.mockReturnValue({
user: adminUser,
checkAuth: vi.fn(),
});
const post = createMockPost();
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
// Component shows "Delete Post (Admin)"
expect(screen.getByText(/Delete Post \(Admin\)/)).toBeInTheDocument();
});
});
it('shows restore button for deleted posts (admin)', async () => {
mockUseAuth.mockReturnValue({
user: adminUser,
checkAuth: vi.fn(),
});
const post = createMockPost({ isDeleted: true });
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText(/Restore Post/)).toBeInTheDocument();
});
});
it('shows deleted notice for admins viewing deleted post', async () => {
mockUseAuth.mockReturnValue({
user: adminUser,
checkAuth: vi.fn(),
});
const post = createMockPost({ isDeleted: true });
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText(/Deleted by Admin/)).toBeInTheDocument();
});
});
it('shows admin close discussion button', async () => {
mockUseAuth.mockReturnValue({
user: adminUser,
checkAuth: vi.fn(),
});
const post = createMockPost();
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
// Component shows "Close Discussion (Admin)"
expect(screen.getByText(/Close Discussion \(Admin\)/)).toBeInTheDocument();
});
});
it('shows admin reopen button for closed posts', async () => {
mockUseAuth.mockReturnValue({
user: adminUser,
checkAuth: vi.fn(),
});
const post = createMockPost({ status: 'closed' });
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
// Component shows "Reopen Discussion (Admin)"
expect(screen.getByText(/Reopen Discussion \(Admin\)/)).toBeInTheDocument();
});
});
});
describe('Email Verification', () => {
it('shows verification modal on EMAIL_NOT_VERIFIED error', async () => {
const post = createMockPost();
mockForumAPI.getPost.mockResolvedValue({ data: post });
mockForumAPI.createComment.mockRejectedValue({
response: { status: 403, data: { code: 'EMAIL_NOT_VERIFIED' } },
});
const user = userEvent.setup();
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('comment-input')).toBeInTheDocument();
}, { timeout: 3000 });
const commentInput = screen.getByTestId('comment-input');
await user.type(commentInput, 'Test comment');
await user.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() => {
expect(screen.getByTestId('verification-modal')).toBeInTheDocument();
}, { timeout: 3000 });
});
});
describe('Breadcrumb Navigation', () => {
it('shows breadcrumb with link to forum', async () => {
const post = createMockPost();
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Forum')).toBeInTheDocument();
});
});
it('shows post title in breadcrumb', async () => {
const post = createMockPost({ title: 'My Test Post' });
mockForumAPI.getPost.mockResolvedValue({ data: post });
const { container } = renderWithRouter();
await waitFor(() => {
// Breadcrumb shows title - check specifically in breadcrumb
const breadcrumb = container.querySelector('.breadcrumb-item.active');
expect(breadcrumb).toHaveTextContent('My Test Post');
});
});
});
describe('Accepted Answer', () => {
it('shows accepted answer badge', async () => {
const comment = createMockComment({ id: 'accepted-comment' });
const post = createMockPost({
comments: [comment],
acceptedAnswerId: 'accepted-comment',
status: 'answered',
});
mockForumAPI.getPost.mockResolvedValue({ data: post });
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('status-badge')).toHaveTextContent('answered');
});
});
});
});

View File

@@ -0,0 +1,677 @@
/**
* ForumPosts Page Tests
*
* Tests for the ForumPosts page that displays the forum post list
* with filtering, pagination, and search functionality.
*/
import React from 'react';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BrowserRouter, MemoryRouter } from 'react-router';
import ForumPosts from '../../pages/ForumPosts';
import { ForumPost } from '../../types';
// Mock the API
vi.mock('../../services/api', () => ({
forumAPI: {
getPosts: vi.fn(),
},
}));
// Mock ForumPostListItem
vi.mock('../../components/ForumPostListItem', () => ({
default: function MockForumPostListItem({
post,
filter,
}: {
post: ForumPost;
filter?: string;
}) {
return (
<div data-testid={`post-${post.id}`} data-filter={filter}>
<span>{post.title}</span>
{post.isDeleted && <span data-testid="deleted-badge">Deleted</span>}
</div>
);
},
}));
// Mock AuthButton
vi.mock('../../components/AuthButton', () => ({
default: function MockAuthButton({
mode,
children,
}: {
mode: string;
children: React.ReactNode;
className?: string;
asLink?: boolean;
}) {
return <button data-testid={`auth-button-${mode}`}>{children}</button>;
},
}));
// Mock auth context
const mockUseAuth = vi.fn();
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => mockUseAuth(),
}));
import { forumAPI } from '../../services/api';
const mockForumAPI = forumAPI as jest.Mocked<typeof forumAPI>;
// Helper to create mock post data
const createMockPost = (overrides: Partial<ForumPost> = {}): ForumPost => ({
id: Math.random().toString(36).substr(2, 9),
title: 'Test Post Title',
content: '<p>Test content</p>',
category: 'general_discussion' as const,
status: 'open' as const,
authorId: 'user-1',
author: {
id: 'user-1',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
isVerified: true,
role: 'user' as const,
},
tags: [],
commentCount: 0,
isPinned: false,
isDeleted: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
});
const renderWithRouter = (ui: React.ReactElement, initialEntries = ['/forum']) => {
return render(
<MemoryRouter initialEntries={initialEntries}>{ui}</MemoryRouter>
);
};
describe('ForumPosts Page', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAuth.mockReturnValue({ user: null });
mockForumAPI.getPosts.mockResolvedValue({
data: {
posts: [],
totalPages: 1,
totalPosts: 0,
},
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Loading State', () => {
it('shows loading spinner during fetch', () => {
mockForumAPI.getPosts.mockImplementation(() => new Promise(() => {}));
renderWithRouter(<ForumPosts />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('displays error alert on fetch failure', async () => {
// Override default mock with rejection
mockForumAPI.getPosts.mockRejectedValue({
response: { data: { error: 'Failed to fetch posts' } },
});
const { container } = renderWithRouter(<ForumPosts />);
// Wait for error alert to appear (use .alert-danger to distinguish from info alerts)
await waitFor(() => {
const errorAlert = container.querySelector('.alert-danger');
expect(errorAlert).toBeInTheDocument();
expect(errorAlert).toHaveTextContent('Failed to fetch posts');
});
});
it('displays generic error message when no specific error', async () => {
// Override default mock with rejection
mockForumAPI.getPosts.mockRejectedValue(new Error());
const { container } = renderWithRouter(<ForumPosts />);
// Wait for error alert to appear (use .alert-danger to distinguish from info alerts)
await waitFor(() => {
const errorAlert = container.querySelector('.alert-danger');
expect(errorAlert).toBeInTheDocument();
expect(errorAlert).toHaveTextContent('Failed to fetch forum posts');
});
});
});
describe('Empty State', () => {
it('shows empty state message when no posts found', async () => {
mockForumAPI.getPosts.mockResolvedValue({
data: { posts: [], totalPages: 1, totalPosts: 0 },
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByText('No posts found')).toBeInTheDocument();
});
});
it('shows encouraging message when no posts exist', async () => {
mockForumAPI.getPosts.mockResolvedValue({
data: { posts: [], totalPages: 1, totalPosts: 0 },
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(
screen.getByText('Be the first to start a discussion!')
).toBeInTheDocument();
});
});
it('shows filter adjustment message when search has no results', async () => {
mockForumAPI.getPosts.mockResolvedValue({
data: { posts: [], totalPages: 1, totalPosts: 0 },
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByPlaceholderText('Search posts...')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText('Search posts...');
fireEvent.change(searchInput, { target: { value: 'nonexistent' } });
await waitFor(() => {
expect(
screen.getByText('Try adjusting your search terms or filters.')
).toBeInTheDocument();
});
});
});
describe('Post List', () => {
it('fetches posts on mount with default filters', async () => {
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(mockForumAPI.getPosts).toHaveBeenCalledWith({
page: 1,
limit: 20,
sort: 'recent',
});
});
});
it('renders post list with ForumPostListItem', async () => {
const posts = [
createMockPost({ id: '1', title: 'First Post' }),
createMockPost({ id: '2', title: 'Second Post' }),
];
mockForumAPI.getPosts.mockResolvedValue({
data: { posts, totalPages: 1, totalPosts: 2 },
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByTestId('post-1')).toBeInTheDocument();
expect(screen.getByTestId('post-2')).toBeInTheDocument();
});
});
it('displays total post count', async () => {
const posts = [createMockPost()];
mockForumAPI.getPosts.mockResolvedValue({
data: { posts, totalPages: 1, totalPosts: 15 },
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByText(/Showing 1 of 15 posts/)).toBeInTheDocument();
});
});
});
describe('Category Filtering', () => {
it('shows all category tabs', async () => {
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByText('All Categories')).toBeInTheDocument();
expect(screen.getByText('Item Requests')).toBeInTheDocument();
expect(screen.getByText('Technical Support')).toBeInTheDocument();
expect(screen.getByText('Community Resources')).toBeInTheDocument();
expect(screen.getByText('General Discussion')).toBeInTheDocument();
});
});
it('clicking category tab updates filter and fetches posts', async () => {
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByText('Item Requests')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Item Requests'));
await waitFor(() => {
expect(mockForumAPI.getPosts).toHaveBeenCalledWith(
expect.objectContaining({
category: 'item_request',
page: 1,
})
);
});
});
it('highlights active category tab', async () => {
renderWithRouter(<ForumPosts />);
await waitFor(() => {
const allCategoriesTab = screen.getByText('All Categories');
expect(allCategoriesTab).toHaveClass('active');
});
fireEvent.click(screen.getByText('Technical Support'));
await waitFor(() => {
const techSupportTab = screen.getByText('Technical Support');
expect(techSupportTab).toHaveClass('active');
});
});
});
describe('Search', () => {
it('submitting search form triggers fetch', async () => {
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByPlaceholderText('Search posts...')).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText('Search posts...');
fireEvent.change(searchInput, { target: { value: 'test search' } });
fireEvent.submit(searchInput.closest('form')!);
await waitFor(() => {
expect(mockForumAPI.getPosts).toHaveBeenCalledWith(
expect.objectContaining({
search: 'test search',
})
);
});
});
});
describe('Status Filter', () => {
it('changing status filter updates fetch params', async () => {
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByDisplayValue('All Status')).toBeInTheDocument();
});
const statusSelect = screen.getByDisplayValue('All Status');
fireEvent.change(statusSelect, { target: { value: 'open' } });
await waitFor(() => {
expect(mockForumAPI.getPosts).toHaveBeenCalledWith(
expect.objectContaining({
status: 'open',
page: 1,
})
);
});
});
it('resets pagination on filter change', async () => {
const posts = Array.from({ length: 20 }, (_, i) =>
createMockPost({ id: String(i) })
);
mockForumAPI.getPosts.mockResolvedValue({
data: { posts, totalPages: 3, totalPosts: 60 },
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByText('Next')).toBeInTheDocument();
});
// Go to page 2
fireEvent.click(screen.getByText('Next'));
await waitFor(() => {
expect(mockForumAPI.getPosts).toHaveBeenCalledWith(
expect.objectContaining({ page: 2 })
);
});
// Change filter
fireEvent.change(screen.getByDisplayValue('All Status'), {
target: { value: 'answered' },
});
await waitFor(() => {
expect(mockForumAPI.getPosts).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1 })
);
});
});
});
describe('Sort Order', () => {
it('changing sort order triggers fetch', async () => {
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByDisplayValue('Most Recent')).toBeInTheDocument();
});
const sortSelect = screen.getByDisplayValue('Most Recent');
fireEvent.change(sortSelect, { target: { value: 'comments' } });
await waitFor(() => {
expect(mockForumAPI.getPosts).toHaveBeenCalledWith(
expect.objectContaining({
sort: 'comments',
})
);
});
});
});
describe('Pagination', () => {
it('shows pagination when multiple pages', async () => {
mockForumAPI.getPosts.mockResolvedValue({
data: {
posts: [createMockPost()],
totalPages: 3,
totalPosts: 60,
},
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByText('Previous')).toBeInTheDocument();
expect(screen.getByText('Next')).toBeInTheDocument();
});
});
it('Previous button is disabled on first page', async () => {
mockForumAPI.getPosts.mockResolvedValue({
data: {
posts: [createMockPost()],
totalPages: 3,
totalPosts: 60,
},
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByText('Previous')).toBeDisabled();
});
});
it('clicking Next loads next page', async () => {
mockForumAPI.getPosts.mockResolvedValue({
data: {
posts: [createMockPost()],
totalPages: 3,
totalPosts: 60,
},
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByText('Next')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Next'));
await waitFor(() => {
expect(mockForumAPI.getPosts).toHaveBeenCalledWith(
expect.objectContaining({ page: 2 })
);
});
});
it('clicking Previous loads previous page', async () => {
mockForumAPI.getPosts.mockResolvedValue({
data: {
posts: [createMockPost()],
totalPages: 3,
totalPosts: 60,
},
});
renderWithRouter(<ForumPosts />);
// Go to page 2
await waitFor(() => {
expect(screen.getByText('Next')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Next'));
await waitFor(() => {
expect(screen.getByText('Previous')).not.toBeDisabled();
});
fireEvent.click(screen.getByText('Previous'));
await waitFor(() => {
expect(mockForumAPI.getPosts).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1 })
);
});
});
it('clicking page number navigates to that page', async () => {
mockForumAPI.getPosts.mockResolvedValue({
data: {
posts: [createMockPost()],
totalPages: 5,
totalPosts: 100,
},
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByText('3')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('3'));
await waitFor(() => {
expect(mockForumAPI.getPosts).toHaveBeenCalledWith(
expect.objectContaining({ page: 3 })
);
});
});
it('does not show pagination for single page', async () => {
mockForumAPI.getPosts.mockResolvedValue({
data: {
posts: [createMockPost()],
totalPages: 1,
totalPosts: 1,
},
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByTestId(/post-/)).toBeInTheDocument();
});
expect(screen.queryByText('Previous')).not.toBeInTheDocument();
expect(screen.queryByText('Next')).not.toBeInTheDocument();
});
});
describe('Authentication States', () => {
it('shows Create Post button for authenticated users', async () => {
mockUseAuth.mockReturnValue({
user: { id: '1', firstName: 'Test', role: 'user' },
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByText('Create Post')).toBeInTheDocument();
});
});
it('hides Create Post button for unauthenticated users', async () => {
mockUseAuth.mockReturnValue({ user: null });
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.queryByText('Create Post')).not.toBeInTheDocument();
});
});
it('shows login prompt for unauthenticated users', async () => {
mockUseAuth.mockReturnValue({ user: null });
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(
screen.getByText(/to create posts and join the discussion/)
).toBeInTheDocument();
});
});
});
describe('Admin Controls', () => {
it('shows deletion filter for admin users', async () => {
mockUseAuth.mockReturnValue({
user: { id: '1', firstName: 'Admin', role: 'admin' },
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByDisplayValue('Active')).toBeInTheDocument();
});
});
it('hides deletion filter for non-admin users', async () => {
mockUseAuth.mockReturnValue({
user: { id: '1', firstName: 'User', role: 'user' },
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
// Wait for component to render
expect(
screen.queryByRole('combobox', { name: /active/i })
).not.toBeInTheDocument();
});
});
it('deletion filter toggles between active, all, deleted', async () => {
mockUseAuth.mockReturnValue({
user: { id: '1', firstName: 'Admin', role: 'admin' },
});
const allPosts = [
createMockPost({ id: '1', title: 'Active Post', isDeleted: false }),
createMockPost({ id: '2', title: 'Deleted Post', isDeleted: true }),
];
mockForumAPI.getPosts.mockResolvedValue({
data: { posts: allPosts, totalPages: 1, totalPosts: 2 },
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
expect(screen.getByDisplayValue('Active')).toBeInTheDocument();
});
// Default filter is 'active' - should only show active posts
expect(screen.getByTestId('post-1')).toBeInTheDocument();
expect(screen.queryByTestId('post-2')).not.toBeInTheDocument();
// Switch to 'all'
const filterSelect = screen.getByDisplayValue('Active');
fireEvent.change(filterSelect, { target: { value: 'all' } });
await waitFor(() => {
expect(screen.getByTestId('post-1')).toBeInTheDocument();
expect(screen.getByTestId('post-2')).toBeInTheDocument();
});
// Switch to 'deleted'
fireEvent.change(screen.getByDisplayValue('All'), {
target: { value: 'deleted' },
});
await waitFor(() => {
expect(screen.queryByTestId('post-1')).not.toBeInTheDocument();
expect(screen.getByTestId('post-2')).toBeInTheDocument();
});
});
it('passes filter param to ForumPostListItem for admins', async () => {
mockUseAuth.mockReturnValue({
user: { id: '1', firstName: 'Admin', role: 'admin' },
});
mockForumAPI.getPosts.mockResolvedValue({
data: {
posts: [createMockPost({ id: '1' })],
totalPages: 1,
totalPosts: 1,
},
});
renderWithRouter(<ForumPosts />);
await waitFor(() => {
const postItem = screen.getByTestId('post-1');
expect(postItem).toHaveAttribute('data-filter', 'active');
});
});
});
describe('URL Parameter Handling', () => {
it('reads initial filter from URL params', async () => {
mockUseAuth.mockReturnValue({
user: { id: '1', firstName: 'Admin', role: 'admin' },
});
renderWithRouter(<ForumPosts />, ['/forum?filter=deleted']);
await waitFor(() => {
expect(screen.getByDisplayValue('Deleted')).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,572 @@
/**
* Messages Page Tests
*
* Tests for the Messages page that displays the conversation list
* and handles real-time updates.
*/
import React from 'react';
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BrowserRouter } from 'react-router';
import Messages from '../../pages/Messages';
import { Conversation, Message } from '../../types';
// Mock the API
vi.mock('../../services/api', () => ({
messageAPI: {
getConversations: vi.fn(),
},
}));
// Mock the auth context
const mockUser = {
id: 'user-1',
firstName: 'Test',
lastName: 'User',
email: 'test@example.com',
isVerified: true,
role: 'user' as const,
};
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => ({
user: mockUser,
}),
}));
// Mock socket context
const mockOnNewMessage = vi.fn();
const mockOnMessageRead = vi.fn();
vi.mock('../../contexts/SocketContext', () => ({
useSocket: () => ({
isConnected: true,
onNewMessage: mockOnNewMessage,
onMessageRead: mockOnMessageRead,
}),
}));
// Mock ChatWindow component
vi.mock('../../components/ChatWindow', () => ({
default: function MockChatWindow({
show,
onClose,
recipient,
onMessagesRead,
}: {
show: boolean;
onClose: () => void;
recipient: any;
onMessagesRead?: (partnerId: string, count: number) => void;
}) {
if (!show) return null;
return (
<div data-testid="chat-window">
<div data-testid="recipient-name">
{recipient.firstName} {recipient.lastName}
</div>
<button onClick={onClose} data-testid="close-chat">
Close
</button>
</div>
);
},
}));
// Mock Avatar component
vi.mock('../../components/Avatar', () => ({
default: function MockAvatar({ user }: { user: any }) {
return <div data-testid="avatar">{user?.firstName?.[0] || '?'}</div>;
},
}));
// Import mocked module
import { messageAPI } from '../../services/api';
const mockMessageAPI = messageAPI as jest.Mocked<typeof messageAPI>;
// Helper to create mock conversations
const createMockConversation = (
overrides: Partial<Conversation> = {}
): Conversation => ({
partnerId: 'user-2',
partner: {
id: 'user-2',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
isVerified: true,
role: 'user' as const,
},
lastMessage: {
id: 'msg-1',
content: 'Hello there!',
senderId: 'user-2',
createdAt: new Date().toISOString(),
isRead: false,
},
unreadCount: 1,
lastMessageAt: new Date().toISOString(),
...overrides,
});
const renderWithRouter = (ui: React.ReactElement) => {
return render(<BrowserRouter>{ui}</BrowserRouter>);
};
describe('Messages Page', () => {
beforeEach(() => {
vi.clearAllMocks();
mockOnNewMessage.mockReturnValue(vi.fn());
mockOnMessageRead.mockReturnValue(vi.fn());
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Loading State', () => {
it('shows loading spinner while fetching conversations', () => {
mockMessageAPI.getConversations.mockImplementation(
() => new Promise(() => {}) // Never resolves
);
renderWithRouter(<Messages />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('displays error message on fetch failure', async () => {
mockMessageAPI.getConversations.mockRejectedValue({
response: { data: { error: 'Failed to load conversations' } },
});
renderWithRouter(<Messages />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
'Failed to load conversations'
);
});
});
it('displays generic error message when no specific error', async () => {
mockMessageAPI.getConversations.mockRejectedValue(new Error());
renderWithRouter(<Messages />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
'Failed to fetch conversations'
);
});
});
});
describe('Empty State', () => {
it('shows empty state when no conversations', async () => {
mockMessageAPI.getConversations.mockResolvedValue({ data: [] });
renderWithRouter(<Messages />);
await waitFor(() => {
expect(screen.getByText('No conversations yet')).toBeInTheDocument();
});
});
});
describe('Conversation List', () => {
it('fetches and displays conversation list on mount', async () => {
const conversations = [
createMockConversation(),
createMockConversation({
partnerId: 'user-3',
partner: {
id: 'user-3',
firstName: 'Jane',
lastName: 'Smith',
email: 'jane@example.com',
isVerified: true,
role: 'user' as const,
},
unreadCount: 0,
}),
];
mockMessageAPI.getConversations.mockResolvedValue({ data: conversations });
renderWithRouter(<Messages />);
await waitFor(() => {
expect(screen.getByText(/John/)).toBeInTheDocument();
expect(screen.getByText(/Jane/)).toBeInTheDocument();
});
});
it('displays last message content', async () => {
const conversation = createMockConversation({
lastMessage: {
id: 'msg-1',
content: 'Hey, how are you?',
senderId: 'user-2',
createdAt: new Date().toISOString(),
isRead: false,
},
});
mockMessageAPI.getConversations.mockResolvedValue({ data: [conversation] });
renderWithRouter(<Messages />);
await waitFor(() => {
expect(screen.getByText('Hey, how are you?')).toBeInTheDocument();
});
});
it('shows "You:" prefix for messages sent by current user', async () => {
const conversation = createMockConversation({
lastMessage: {
id: 'msg-1',
content: 'Hi there!',
senderId: mockUser.id, // Current user sent this
createdAt: new Date().toISOString(),
isRead: true,
},
});
mockMessageAPI.getConversations.mockResolvedValue({ data: [conversation] });
renderWithRouter(<Messages />);
await waitFor(() => {
expect(screen.getByText('You:')).toBeInTheDocument();
});
});
it('displays unread badge for conversations with unread messages', async () => {
const conversation = createMockConversation({ unreadCount: 3 });
mockMessageAPI.getConversations.mockResolvedValue({ data: [conversation] });
renderWithRouter(<Messages />);
await waitFor(() => {
expect(screen.getByText('3')).toBeInTheDocument();
});
});
it('highlights unread conversations', async () => {
const conversation = createMockConversation({ unreadCount: 1 });
mockMessageAPI.getConversations.mockResolvedValue({ data: [conversation] });
renderWithRouter(<Messages />);
await waitFor(() => {
const conversationItem = screen.getByText(/John/).closest('.list-group-item');
expect(conversationItem).toHaveClass('border-start');
});
});
});
describe('Chat Window Interaction', () => {
it('opens ChatWindow when clicking a conversation', async () => {
const conversation = createMockConversation();
mockMessageAPI.getConversations.mockResolvedValue({ data: [conversation] });
renderWithRouter(<Messages />);
await waitFor(() => {
expect(screen.getByText(/John/)).toBeInTheDocument();
});
fireEvent.click(screen.getByText(/John/).closest('.list-group-item')!);
await waitFor(() => {
expect(screen.getByTestId('chat-window')).toBeInTheDocument();
expect(screen.getByTestId('recipient-name')).toHaveTextContent('John Doe');
});
});
it('closes ChatWindow and refreshes conversations on close', async () => {
const conversation = createMockConversation();
mockMessageAPI.getConversations.mockResolvedValue({ data: [conversation] });
renderWithRouter(<Messages />);
await waitFor(() => {
expect(screen.getByText(/John/)).toBeInTheDocument();
});
// Open chat
fireEvent.click(screen.getByText(/John/).closest('.list-group-item')!);
await waitFor(() => {
expect(screen.getByTestId('chat-window')).toBeInTheDocument();
});
// Close chat
fireEvent.click(screen.getByTestId('close-chat'));
await waitFor(() => {
expect(screen.queryByTestId('chat-window')).not.toBeInTheDocument();
});
// Should have fetched conversations again (once on mount, once on close)
expect(mockMessageAPI.getConversations).toHaveBeenCalledTimes(2);
});
});
describe('Real-time Updates', () => {
it('registers onNewMessage listener on mount', async () => {
mockMessageAPI.getConversations.mockResolvedValue({ data: [] });
renderWithRouter(<Messages />);
await waitFor(() => {
expect(mockOnNewMessage).toHaveBeenCalled();
});
});
it('registers onMessageRead listener on mount', async () => {
mockMessageAPI.getConversations.mockResolvedValue({ data: [] });
renderWithRouter(<Messages />);
await waitFor(() => {
expect(mockOnMessageRead).toHaveBeenCalled();
});
});
it('updates conversation list when new message received', async () => {
let newMessageCallback: ((message: Message) => void) | null = null;
mockOnNewMessage.mockImplementation((callback) => {
newMessageCallback = callback;
return vi.fn();
});
const initialConversation = createMockConversation({
lastMessage: {
id: 'msg-1',
content: 'Old message',
senderId: 'user-2',
createdAt: new Date(Date.now() - 60000).toISOString(),
isRead: true,
},
unreadCount: 0,
});
mockMessageAPI.getConversations.mockResolvedValue({
data: [initialConversation],
});
renderWithRouter(<Messages />);
await waitFor(() => {
expect(screen.getByText('Old message')).toBeInTheDocument();
});
// Simulate receiving a new message
act(() => {
if (newMessageCallback) {
newMessageCallback({
id: 'msg-2',
content: 'New message!',
senderId: 'user-2',
receiverId: mockUser.id,
createdAt: new Date().toISOString(),
isRead: false,
sender: initialConversation.partner,
} as Message);
}
});
await waitFor(() => {
expect(screen.getByText('New message!')).toBeInTheDocument();
});
});
it('increments unread count for received messages', async () => {
let newMessageCallback: ((message: Message) => void) | null = null;
mockOnNewMessage.mockImplementation((callback) => {
newMessageCallback = callback;
return vi.fn();
});
const conversation = createMockConversation({ unreadCount: 0 });
mockMessageAPI.getConversations.mockResolvedValue({ data: [conversation] });
renderWithRouter(<Messages />);
await waitFor(() => {
expect(screen.getByText(/John/)).toBeInTheDocument();
});
// Initially no unread badge
expect(screen.queryByText('1')).not.toBeInTheDocument();
// Simulate receiving a new message
act(() => {
if (newMessageCallback) {
newMessageCallback({
id: 'msg-new',
content: 'Hello!',
senderId: 'user-2',
receiverId: mockUser.id,
createdAt: new Date().toISOString(),
isRead: false,
sender: conversation.partner,
} as Message);
}
});
await waitFor(() => {
expect(screen.getByText('1')).toBeInTheDocument(); // Unread badge
});
});
it('updates last message isRead status on read receipt', async () => {
let readCallback: ((data: any) => void) | null = null;
mockOnMessageRead.mockImplementation((callback) => {
readCallback = callback;
return vi.fn();
});
const conversation = createMockConversation({
lastMessage: {
id: 'msg-1',
content: 'Unread message',
senderId: 'user-2',
createdAt: new Date().toISOString(),
isRead: false,
},
unreadCount: 1,
});
mockMessageAPI.getConversations.mockResolvedValue({ data: [conversation] });
renderWithRouter(<Messages />);
await waitFor(() => {
expect(screen.getByText('1')).toBeInTheDocument(); // Unread badge
});
// Simulate read receipt
act(() => {
if (readCallback) {
readCallback({
messageId: 'msg-1',
readAt: new Date().toISOString(),
readBy: mockUser.id,
});
}
});
await waitFor(() => {
expect(screen.queryByText('1')).not.toBeInTheDocument(); // Badge removed
});
});
});
describe('Date Formatting', () => {
it('displays time for messages from today', async () => {
const now = new Date();
const conversation = createMockConversation({
lastMessageAt: now.toISOString(),
});
mockMessageAPI.getConversations.mockResolvedValue({ data: [conversation] });
renderWithRouter(<Messages />);
await waitFor(() => {
// Should show time format like "3:30 PM"
const timeRegex = /\d{1,2}:\d{2}/;
const timeElement = screen
.getByText((content) => timeRegex.test(content));
expect(timeElement).toBeInTheDocument();
});
});
it('displays "Yesterday" for messages from yesterday', async () => {
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const conversation = createMockConversation({
lastMessageAt: yesterday.toISOString(),
});
mockMessageAPI.getConversations.mockResolvedValue({ data: [conversation] });
renderWithRouter(<Messages />);
await waitFor(() => {
expect(screen.getByText('Yesterday')).toBeInTheDocument();
});
});
it('displays date for older messages', async () => {
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const conversation = createMockConversation({
lastMessageAt: weekAgo.toISOString(),
});
mockMessageAPI.getConversations.mockResolvedValue({ data: [conversation] });
renderWithRouter(<Messages />);
await waitFor(() => {
// Should show month and day
const dateElement = screen.getByText((content) =>
/[A-Z][a-z]{2} \d{1,2}/.test(content)
);
expect(dateElement).toBeInTheDocument();
});
});
});
describe('Sorting', () => {
it('sorts conversations by most recent message', async () => {
const olderConversation = createMockConversation({
partnerId: 'user-2',
partner: {
id: 'user-2',
firstName: 'Older',
lastName: 'User',
email: 'older@example.com',
isVerified: true,
role: 'user' as const,
},
lastMessageAt: new Date(Date.now() - 60000).toISOString(),
});
const newerConversation = createMockConversation({
partnerId: 'user-3',
partner: {
id: 'user-3',
firstName: 'Newer',
lastName: 'User',
email: 'newer@example.com',
isVerified: true,
role: 'user' as const,
},
lastMessageAt: new Date().toISOString(),
});
mockMessageAPI.getConversations.mockResolvedValue({
data: [olderConversation, newerConversation],
});
renderWithRouter(<Messages />);
await waitFor(() => {
// Newer conversation should appear in the list
expect(screen.getByText(/Newer/)).toBeInTheDocument();
expect(screen.getByText(/Older/)).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,723 @@
/**
* Profile Page Tests
*
* Tests for the Profile page that handles user profile management
* including personal info, addresses, notifications, and security settings.
*/
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BrowserRouter } from 'react-router';
import Profile from '../../pages/Profile';
import { User, Address, Rental, Item } from '../../types';
// Mock the API
vi.mock('../../services/api', () => ({
userAPI: {
getProfile: vi.fn(),
updateProfile: vi.fn(),
getAvailability: vi.fn(),
updateAvailability: vi.fn(),
changePassword: vi.fn(),
},
itemAPI: {
getItems: vi.fn(),
},
rentalAPI: {
getListings: vi.fn(),
getRentals: vi.fn(),
},
addressAPI: {
getAddresses: vi.fn(),
createAddress: vi.fn(),
updateAddress: vi.fn(),
deleteAddress: vi.fn(),
},
conditionCheckAPI: {
getBatchConditionChecks: vi.fn(),
},
}));
// Mock upload service
vi.mock('../../services/uploadService', () => ({
uploadImageWithVariants: vi.fn().mockResolvedValue({ baseKey: 'uploads/profile/123.jpg' }),
getImageUrl: vi.fn((filename: string) => `https://cdn.example.com/${filename}`),
}));
// Mock geocoding service
vi.mock('../../services/geocodingService', () => ({
geocodingService: {
geocodeAddress: vi.fn().mockResolvedValue({
lat: 40.7128,
lng: -74.006,
formattedAddress: '123 Main St, New York, NY 10001',
}),
},
}));
// Mock hooks
vi.mock('../../hooks/useAddressAutocomplete', () => ({
useAddressAutocomplete: () => ({
autocompleteRef: { current: null },
predictions: [],
handleInputChange: vi.fn(),
handleSelect: vi.fn(),
}),
usStates: ['California', 'New York'],
}));
// Mock password validation
vi.mock('../../utils/passwordValidation', () => ({
validatePassword: vi.fn((password: string) => ({
isValid: password.length >= 8,
errors: password.length < 8 ? ['Password must be at least 8 characters'] : [],
})),
}));
// Mock child components
vi.mock('../../components/AvailabilitySettings', () => ({
default: function MockAvailabilitySettings({
data,
onChange,
onSave,
loading,
}: {
data: any;
onChange: (data: any) => void;
onSave: () => void;
loading: boolean;
}) {
return (
<div data-testid="availability-settings">
<button onClick={onSave} disabled={loading}>
Save Availability
</button>
</div>
);
},
}));
vi.mock('../../components/Avatar', () => ({
default: function MockAvatar({ user }: { user: User }) {
return <div data-testid="avatar">{user?.firstName?.[0] || '?'}</div>;
},
}));
vi.mock('../../components/PasswordStrengthMeter', () => ({
default: function MockPasswordStrengthMeter({ password }: { password: string }) {
return <div data-testid="password-strength">{password.length >= 8 ? 'Strong' : 'Weak'}</div>;
},
}));
vi.mock('../../components/AddressAutocomplete', () => ({
default: function MockAddressAutocomplete({
value,
onChange,
onSelect,
}: {
value: string;
onChange: (value: string) => void;
onSelect: (place: any) => void;
}) {
return (
<input
data-testid="address-autocomplete"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
},
}));
vi.mock('../../components/TwoFactor', () => ({
TwoFactorManagement: function MockTwoFactorManagement() {
return <div data-testid="two-factor-management">Two Factor Management</div>;
},
}));
vi.mock('../../components/ReviewModal', () => ({
default: function MockReviewModal() {
return <div data-testid="review-modal">Review Modal</div>;
},
}));
vi.mock('../../components/ReviewRenterModal', () => ({
default: function MockReviewRenterModal() {
return <div data-testid="review-renter-modal">Review Renter Modal</div>;
},
}));
vi.mock('../../components/ReviewDetailsModal', () => ({
default: function MockReviewDetailsModal() {
return <div data-testid="review-details-modal">Review Details Modal</div>;
},
}));
vi.mock('../../components/ConditionCheckViewerModal', () => ({
default: function MockConditionCheckViewerModal() {
return <div data-testid="condition-check-viewer">Condition Check Viewer</div>;
},
}));
// Mock auth context
const mockUser: User = {
id: 'user-1',
firstName: 'Test',
lastName: 'User',
email: 'test@example.com',
isVerified: true,
role: 'user',
};
const mockUseAuth = vi.fn();
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => mockUseAuth(),
}));
import { userAPI, itemAPI, rentalAPI, addressAPI, conditionCheckAPI } from '../../services/api';
const mockUserAPI = userAPI as jest.Mocked<typeof userAPI>;
const mockItemAPI = itemAPI as jest.Mocked<typeof itemAPI>;
const mockRentalAPI = rentalAPI as jest.Mocked<typeof rentalAPI>;
const mockAddressAPI = addressAPI as jest.Mocked<typeof addressAPI>;
const mockConditionCheckAPI = conditionCheckAPI as jest.Mocked<typeof conditionCheckAPI>;
// Helper to create mock profile data
const createMockProfile = (overrides: Partial<User> = {}): User => ({
id: 'user-1',
firstName: 'Test',
lastName: 'User',
email: 'test@example.com',
phone: '555-1234',
isVerified: true,
role: 'user',
itemRequestNotificationRadius: 10,
...overrides,
});
// Helper to create mock address
const createMockAddress = (overrides: Partial<Address> = {}): Address => ({
id: 'addr-1',
userId: 'user-1',
address1: '123 Main St',
city: 'Test City',
state: 'CA',
zipCode: '90210',
country: 'US',
isPrimary: true,
...overrides,
});
// Helper to create mock rental
const createMockRental = (overrides: Partial<Rental> = {}): Rental => ({
id: 'rental-1',
itemId: 'item-1',
renterId: 'renter-1',
ownerId: 'user-1',
status: 'completed',
startDateTime: new Date().toISOString(),
endDateTime: new Date().toISOString(),
totalAmount: '100.00',
item: { id: 'item-1', name: 'Test Item' },
...overrides,
});
const renderWithRouter = (ui: React.ReactElement) => {
return render(<BrowserRouter>{ui}</BrowserRouter>);
};
describe('Profile Page', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAuth.mockReturnValue({
user: mockUser,
updateUser: vi.fn(),
logout: vi.fn(),
});
mockUserAPI.getProfile.mockResolvedValue({ data: createMockProfile() });
mockUserAPI.getAvailability.mockResolvedValue({
data: {
generalAvailableAfter: '09:00',
generalAvailableBefore: '17:00',
specifyTimesPerDay: false,
weeklyTimes: {},
},
});
mockItemAPI.getItems.mockResolvedValue({ data: [] });
mockRentalAPI.getListings.mockResolvedValue({ data: [] });
mockRentalAPI.getRentals.mockResolvedValue({ data: [] });
mockAddressAPI.getAddresses.mockResolvedValue({ data: [] });
mockConditionCheckAPI.getBatchConditionChecks.mockResolvedValue({ data: [] });
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Loading State', () => {
it('shows loading spinner while fetching profile', () => {
mockUserAPI.getProfile.mockImplementation(() => new Promise(() => {}));
renderWithRouter(<Profile />);
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('displays error message on profile fetch failure', async () => {
mockUserAPI.getProfile.mockRejectedValue({
response: { data: { message: 'Failed to fetch profile' } },
});
renderWithRouter(<Profile />);
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('Failed to fetch profile');
});
});
});
describe('Profile Display', () => {
it('loads and displays profile data', async () => {
const profile = createMockProfile({ firstName: 'John', lastName: 'Doe' });
mockUserAPI.getProfile.mockResolvedValue({ data: profile });
renderWithRouter(<Profile />);
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
});
it('displays user avatar', async () => {
renderWithRouter(<Profile />);
await waitFor(() => {
expect(screen.getByTestId('avatar')).toBeInTheDocument();
});
});
it('displays user statistics', async () => {
const items = [{ id: 'item-1', ownerId: 'user-1', name: 'Camera' }];
mockItemAPI.getItems.mockResolvedValue({ data: { items } });
renderWithRouter(<Profile />);
await waitFor(() => {
expect(screen.getByText(/Items Listed/i)).toBeInTheDocument();
});
});
});
describe('Tab Navigation', () => {
it('displays all profile tabs', async () => {
renderWithRouter(<Profile />);
await waitFor(() => {
// Use getAllByRole to find tab buttons specifically
const buttons = screen.getAllByRole('button');
const tabTexts = buttons.map(b => b.textContent?.trim());
expect(tabTexts).toContain('Overview');
expect(tabTexts).toContain('Owner Settings');
expect(tabTexts).toContain('Notification Preferences');
expect(tabTexts).toContain('Rental History');
expect(tabTexts).toContain('Security');
});
});
it('switches tabs on click', async () => {
renderWithRouter(<Profile />);
await waitFor(() => {
const buttons = screen.getAllByRole('button');
const securityTab = buttons.find(b => b.textContent?.includes('Security'));
expect(securityTab).toBeInTheDocument();
});
const buttons = screen.getAllByRole('button');
const securityTab = buttons.find(b => b.textContent?.includes('Security'));
fireEvent.click(securityTab!);
await waitFor(() => {
// Look for the heading specifically
expect(screen.getByRole('heading', { name: /Change Password/i })).toBeInTheDocument();
});
});
});
describe('Profile Editing', () => {
// Helper to show personal info section (it's hidden by default)
const showPersonalInfo = async (container: HTMLElement) => {
await waitFor(() => {
expect(screen.getByText('Personal Information')).toBeInTheDocument();
});
// Click the eye icon button to show personal info
const eyeButton = container.querySelector('.bi-eye-slash')?.closest('button');
expect(eyeButton).toBeInTheDocument();
fireEvent.click(eyeButton!);
};
it('toggles edit mode when Edit Information is clicked', async () => {
const { container } = renderWithRouter(<Profile />);
await showPersonalInfo(container);
await waitFor(() => {
expect(screen.getByText('Edit Information')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Edit Information'));
await waitFor(() => {
expect(screen.getByText('Cancel')).toBeInTheDocument();
});
});
it('saves profile changes via API', async () => {
mockUserAPI.updateProfile.mockResolvedValue({
data: createMockProfile({ firstName: 'Updated' }),
});
const { container } = renderWithRouter(<Profile />);
await showPersonalInfo(container);
await waitFor(() => {
expect(screen.getByText('Edit Information')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Edit Information'));
await waitFor(() => {
expect(screen.getByLabelText('First Name')).toBeInTheDocument();
});
const firstNameInput = screen.getByLabelText('First Name');
fireEvent.change(firstNameInput, { target: { value: 'Updated' } });
fireEvent.click(screen.getByText('Save Changes'));
await waitFor(() => {
expect(mockUserAPI.updateProfile).toHaveBeenCalled();
});
});
it('cancels edit and resets form', async () => {
const profile = createMockProfile({ firstName: 'Original' });
mockUserAPI.getProfile.mockResolvedValue({ data: profile });
const { container } = renderWithRouter(<Profile />);
await showPersonalInfo(container);
await waitFor(() => {
expect(screen.getByText('Edit Information')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Edit Information'));
await waitFor(() => {
expect(screen.getByLabelText('First Name')).toBeInTheDocument();
});
const firstNameInput = screen.getByLabelText('First Name');
fireEvent.change(firstNameInput, { target: { value: 'Changed' } });
fireEvent.click(screen.getByText('Cancel'));
// Should reset to original and exit edit mode
await waitFor(() => {
expect(screen.getByText('Edit Information')).toBeInTheDocument();
});
});
});
describe('Profile Image Upload', () => {
it('allows profile image upload', async () => {
const { container } = renderWithRouter(<Profile />);
await waitFor(() => {
expect(screen.getByText('Personal Information')).toBeInTheDocument();
});
// Click the eye icon button to show personal info
const eyeButton = container.querySelector('.bi-eye-slash')?.closest('button');
fireEvent.click(eyeButton!);
await waitFor(() => {
expect(screen.getByText('Edit Information')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Edit Information'));
await waitFor(() => {
// Should show image upload button in edit mode
expect(screen.getByText(/Change/i)).toBeInTheDocument();
});
});
});
describe('Address Management', () => {
// Helper to show personal info section (hidden by default for privacy)
const showPersonalInfoSection = async (container: HTMLElement) => {
await waitFor(() => {
expect(screen.getByText('Personal Information')).toBeInTheDocument();
});
// Click the eye icon button to show personal info
const eyeButton = container.querySelector('.bi-eye-slash')?.closest('button');
expect(eyeButton).toBeInTheDocument();
fireEvent.click(eyeButton!);
};
it('lists saved addresses', async () => {
const addresses = [createMockAddress({ address1: '123 Main St' })];
mockAddressAPI.getAddresses.mockResolvedValue({ data: addresses });
const { container } = renderWithRouter(<Profile />);
// Addresses are in Overview but hidden in Personal Info section
await showPersonalInfoSection(container);
await waitFor(
() => {
expect(screen.getByText(/123 Main St/)).toBeInTheDocument();
},
{ timeout: 3000 }
);
});
it('shows add address button', async () => {
const { container } = renderWithRouter(<Profile />);
// Addresses are in Overview but hidden in Personal Info section
await showPersonalInfoSection(container);
await waitFor(
() => {
expect(screen.getByText('Add New Address')).toBeInTheDocument();
},
{ timeout: 3000 }
);
});
it('opens add address form', async () => {
const { container } = renderWithRouter(<Profile />);
// Addresses are in Overview but hidden in Personal Info section
await showPersonalInfoSection(container);
await waitFor(
() => {
expect(screen.getByText('Add New Address')).toBeInTheDocument();
},
{ timeout: 3000 }
);
fireEvent.click(screen.getByText('Add New Address'));
await waitFor(
() => {
expect(screen.getByText('Save Address')).toBeInTheDocument();
},
{ timeout: 3000 }
);
});
});
describe('Availability Settings', () => {
it('displays availability settings in Owner Settings tab', async () => {
renderWithRouter(<Profile />);
await waitFor(() => {
expect(screen.getByText('Owner Settings')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Owner Settings'));
await waitFor(() => {
expect(screen.getByTestId('availability-settings')).toBeInTheDocument();
});
});
it('saves availability preferences', async () => {
mockUserAPI.updateAvailability.mockResolvedValue({ data: {} });
renderWithRouter(<Profile />);
await waitFor(() => {
expect(screen.getByText('Owner Settings')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Owner Settings'));
await waitFor(
() => {
expect(screen.getByTestId('availability-settings')).toBeInTheDocument();
},
{ timeout: 3000 }
);
// Use getAllByText since there may be multiple matching elements
const saveButtons = screen.getAllByText('Save Availability');
fireEvent.click(saveButtons[0]);
// The mock component doesn't actually call the API, but in real usage it would
});
});
describe('Notification Preferences', () => {
it('displays notification settings', async () => {
renderWithRouter(<Profile />);
await waitFor(() => {
expect(screen.getByText('Notification Preferences')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Notification Preferences'));
await waitFor(() => {
expect(screen.getByText(/Item Request Notification/i)).toBeInTheDocument();
});
});
});
describe('Security Tab', () => {
it('displays password change form', async () => {
renderWithRouter(<Profile />);
await waitFor(() => {
expect(screen.getByText('Security')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Security'));
await waitFor(
() => {
// Use heading role to be specific about which "Change Password" we want
expect(screen.getByRole('heading', { name: /Change Password/i })).toBeInTheDocument();
expect(screen.getByLabelText(/Current Password/i)).toBeInTheDocument();
// Use exact label match to avoid matching multiple elements
expect(screen.getByLabelText('New Password')).toBeInTheDocument();
},
{ timeout: 3000 }
);
});
it('shows two-factor management section', async () => {
renderWithRouter(<Profile />);
await waitFor(() => {
expect(screen.getByText('Security')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Security'));
await waitFor(
() => {
expect(screen.getByTestId('two-factor-management')).toBeInTheDocument();
},
{ timeout: 3000 }
);
});
it('validates password requirements', async () => {
renderWithRouter(<Profile />);
await waitFor(() => {
expect(screen.getByText('Security')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Security'));
await waitFor(
() => {
// Use exact label match to avoid matching "Confirm New Password"
expect(screen.getByLabelText('New Password')).toBeInTheDocument();
},
{ timeout: 3000 }
);
const newPasswordInput = screen.getByLabelText('New Password');
fireEvent.change(newPasswordInput, { target: { value: 'weak' } });
await waitFor(() => {
expect(screen.getByTestId('password-strength')).toHaveTextContent('Weak');
});
});
it('shows strong when password meets requirements', async () => {
renderWithRouter(<Profile />);
await waitFor(() => {
expect(screen.getByText('Security')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Security'));
await waitFor(
() => {
// Use exact label match to avoid matching "Confirm New Password"
expect(screen.getByLabelText('New Password')).toBeInTheDocument();
},
{ timeout: 3000 }
);
const newPasswordInput = screen.getByLabelText('New Password');
fireEvent.change(newPasswordInput, { target: { value: 'StrongPassword123!' } });
await waitFor(() => {
expect(screen.getByTestId('password-strength')).toHaveTextContent('Strong');
});
});
});
describe('Rental History', () => {
it('displays rental history tab', async () => {
renderWithRouter(<Profile />);
await waitFor(() => {
expect(screen.getByText('Rental History')).toBeInTheDocument();
});
});
it('shows past rentals', async () => {
const rentals = [
createMockRental({
status: 'completed',
item: { id: 'item-1', name: 'Camera' },
}),
];
mockRentalAPI.getRentals.mockResolvedValue({ data: rentals });
renderWithRouter(<Profile />);
await waitFor(() => {
expect(screen.getByText('Rental History')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Rental History'));
await waitFor(() => {
expect(screen.getByText('Camera')).toBeInTheDocument();
});
});
it('shows empty state when no rental history', async () => {
mockRentalAPI.getRentals.mockResolvedValue({ data: [] });
mockRentalAPI.getListings.mockResolvedValue({ data: [] });
renderWithRouter(<Profile />);
await waitFor(() => {
expect(screen.getByText('Rental History')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Rental History'));
await waitFor(() => {
expect(screen.getByText(/No rental history/i)).toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,558 @@
/**
* PublicProfile Page Tests
*
* Tests for the PublicProfile page that displays
* a user's public profile information and items.
*/
import React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { MemoryRouter, Routes, Route } from 'react-router';
import PublicProfile from '../../pages/PublicProfile';
import { User, Item } from '../../types';
// Mock the API
vi.mock('../../services/api', () => ({
userAPI: {
getPublicProfile: vi.fn(),
adminBanUser: vi.fn(),
adminUnbanUser: vi.fn(),
},
itemAPI: {
getItems: vi.fn(),
},
}));
// Mock upload service
vi.mock('../../services/uploadService', () => ({
getImageUrl: vi.fn((filename: string) => `https://cdn.example.com/${filename}`),
}));
// Mock components
vi.mock('../../components/ChatWindow', () => ({
default: function MockChatWindow({
show,
onClose,
recipient,
}: {
show: boolean;
onClose: () => void;
recipient: User;
}) {
if (!show) return null;
return (
<div data-testid="chat-window">
<span>Chat with {recipient.firstName}</span>
<button onClick={onClose} data-testid="close-chat">
Close
</button>
</div>
);
},
}));
vi.mock('../../components/Avatar', () => ({
default: function MockAvatar({ user }: { user: User }) {
return (
<div data-testid="avatar">{user?.firstName?.[0] || '?'}</div>
);
},
}));
vi.mock('../../components/BanUserModal', () => ({
default: function MockBanUserModal({
show,
onHide,
user,
onBanComplete,
}: {
show: boolean;
onHide: () => void;
user: User;
onBanComplete: (user: User) => void;
}) {
if (!show) return null;
return (
<div data-testid="ban-modal">
<span>Ban {user.firstName}</span>
<button
data-testid="confirm-ban"
onClick={() => onBanComplete({ ...user, isBanned: true })}
>
Confirm
</button>
<button onClick={onHide}>Cancel</button>
</div>
);
},
}));
vi.mock('../../components/ConfirmationModal', () => ({
default: function MockConfirmationModal({
show,
onClose,
onConfirm,
title,
confirmText,
}: {
show: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
confirmText: string;
cancelText: string;
confirmButtonClass?: string;
loading?: boolean;
}) {
if (!show) return null;
return (
<div data-testid="confirmation-modal">
<span>{title}</span>
<button data-testid="confirm-action" onClick={onConfirm}>
{confirmText}
</button>
<button onClick={onClose}>Cancel</button>
</div>
);
},
}));
// Mock auth context
const mockCurrentUser: User = {
id: 'current-user',
firstName: 'Current',
lastName: 'User',
email: 'current@example.com',
isVerified: true,
role: 'user',
};
const mockUseAuth = vi.fn();
vi.mock('../../contexts/AuthContext', () => ({
useAuth: () => mockUseAuth(),
}));
import { userAPI, itemAPI } from '../../services/api';
const mockUserAPI = userAPI as jest.Mocked<typeof userAPI>;
const mockItemAPI = itemAPI as jest.Mocked<typeof itemAPI>;
// Helper to create mock user
const createMockUser = (overrides: Partial<User> = {}): User => ({
id: 'profile-user',
firstName: 'John',
lastName: 'Doe',
email: 'john@example.com',
isVerified: true,
role: 'user',
isBanned: false,
...overrides,
});
// Helper to create mock item
const createMockItem = (overrides: Partial<Item> = {}): Item => ({
id: 'item-1',
name: 'Test Item',
description: 'Test description',
pricePerDay: 25,
pricePerWeek: 150,
ownerId: 'profile-user',
city: 'Test City',
state: 'CA',
imageFilenames: ['test-image.jpg'],
availableQuantity: 1,
isActive: true,
...overrides,
});
// Helper to render with router
const renderWithRouter = (userId = 'profile-user') => {
return render(
<MemoryRouter initialEntries={[`/users/${userId}`]}>
<Routes>
<Route path="/users/:id" element={<PublicProfile />} />
</Routes>
</MemoryRouter>
);
};
describe('PublicProfile Page', () => {
beforeEach(() => {
vi.clearAllMocks();
mockUseAuth.mockReturnValue({ user: mockCurrentUser });
mockUserAPI.getPublicProfile.mockResolvedValue({ data: createMockUser() });
mockItemAPI.getItems.mockResolvedValue({ data: [] });
});
afterEach(() => {
vi.clearAllMocks();
});
describe('Loading State', () => {
it('shows loading spinner while fetching', () => {
mockUserAPI.getPublicProfile.mockImplementation(() => new Promise(() => {}));
renderWithRouter();
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
describe('Error Handling', () => {
it('displays error message on fetch failure', async () => {
mockUserAPI.getPublicProfile.mockRejectedValue({
response: { data: { message: 'User not found' } },
});
renderWithRouter();
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent('User not found');
});
});
it('shows generic error when no message provided', async () => {
mockUserAPI.getPublicProfile.mockRejectedValue({
response: { data: {} },
});
renderWithRouter();
await waitFor(() => {
expect(screen.getByRole('alert')).toHaveTextContent(
'Failed to fetch user profile'
);
});
});
});
describe('Profile Display', () => {
it('loads and displays user profile data', async () => {
const user = createMockUser({ firstName: 'Jane', lastName: 'Smith' });
mockUserAPI.getPublicProfile.mockResolvedValue({ data: user });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Jane Smith')).toBeInTheDocument();
});
});
it('displays user avatar', async () => {
mockUserAPI.getPublicProfile.mockResolvedValue({ data: createMockUser() });
renderWithRouter();
await waitFor(() => {
expect(screen.getByTestId('avatar')).toBeInTheDocument();
});
});
});
describe('User Items', () => {
it('displays items owned by user', async () => {
const profileUser = createMockUser({ id: 'profile-user' });
const items = [
createMockItem({ id: 'item-1', name: 'Camera', ownerId: 'profile-user' }),
createMockItem({ id: 'item-2', name: 'Tripod', ownerId: 'profile-user' }),
];
mockUserAPI.getPublicProfile.mockResolvedValue({ data: profileUser });
mockItemAPI.getItems.mockResolvedValue({ data: { items } });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Camera')).toBeInTheDocument();
expect(screen.getByText('Tripod')).toBeInTheDocument();
});
});
it('shows empty state when no items', async () => {
mockUserAPI.getPublicProfile.mockResolvedValue({ data: createMockUser() });
mockItemAPI.getItems.mockResolvedValue({ data: [] });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('No items listed yet.')).toBeInTheDocument();
});
});
it('shows item count in header', async () => {
const items = [
createMockItem({ ownerId: 'profile-user' }),
createMockItem({ id: 'item-2', ownerId: 'profile-user' }),
];
mockUserAPI.getPublicProfile.mockResolvedValue({ data: createMockUser() });
mockItemAPI.getItems.mockResolvedValue({ data: { items } });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Items Listed (2)')).toBeInTheDocument();
});
});
});
describe('Message Button', () => {
it('shows message button for authenticated users', async () => {
mockUserAPI.getPublicProfile.mockResolvedValue({ data: createMockUser() });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Message')).toBeInTheDocument();
});
});
it('opens ChatWindow when message button is clicked', async () => {
mockUserAPI.getPublicProfile.mockResolvedValue({ data: createMockUser() });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Message')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Message'));
await waitFor(() => {
expect(screen.getByTestId('chat-window')).toBeInTheDocument();
});
});
it('hides message button for banned users', async () => {
const bannedUser = createMockUser({ isBanned: true });
mockUserAPI.getPublicProfile.mockResolvedValue({ data: bannedUser });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.queryByText('Message')).not.toBeInTheDocument();
});
it('hides message button when viewing own profile', async () => {
const ownProfile = createMockUser({
id: 'current-user',
firstName: 'Current',
lastName: 'User',
});
mockUserAPI.getPublicProfile.mockResolvedValue({ data: ownProfile });
renderWithRouter('current-user');
await waitFor(() => {
expect(screen.getByText('Current User')).toBeInTheDocument();
});
expect(screen.queryByText('Message')).not.toBeInTheDocument();
});
it('hides message button for unauthenticated users', async () => {
mockUseAuth.mockReturnValue({ user: null });
mockUserAPI.getPublicProfile.mockResolvedValue({ data: createMockUser() });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.queryByText('Message')).not.toBeInTheDocument();
});
});
describe('Admin Ban Controls', () => {
const adminUser: User = { ...mockCurrentUser, role: 'admin' };
beforeEach(() => {
mockUseAuth.mockReturnValue({ user: adminUser });
});
it('shows ban button for admins', async () => {
mockUserAPI.getPublicProfile.mockResolvedValue({ data: createMockUser() });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Ban User')).toBeInTheDocument();
});
});
it('shows unban button for banned users', async () => {
const bannedUser = createMockUser({ isBanned: true });
mockUserAPI.getPublicProfile.mockResolvedValue({ data: bannedUser });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Unban User')).toBeInTheDocument();
});
});
it('shows banned badge for admins viewing banned users', async () => {
const bannedUser = createMockUser({ isBanned: true });
mockUserAPI.getPublicProfile.mockResolvedValue({ data: bannedUser });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Banned')).toBeInTheDocument();
});
});
it('shows ban reason for admins', async () => {
const bannedUser = createMockUser({
isBanned: true,
banReason: 'Violated terms of service',
});
mockUserAPI.getPublicProfile.mockResolvedValue({ data: bannedUser });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Violated terms of service')).toBeInTheDocument();
});
});
it('cannot ban other admins', async () => {
const adminProfile = createMockUser({ role: 'admin' });
mockUserAPI.getPublicProfile.mockResolvedValue({ data: adminProfile });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.queryByText('Ban User')).not.toBeInTheDocument();
});
it('cannot ban self', async () => {
const ownProfile = createMockUser({ id: 'current-user' });
mockUseAuth.mockReturnValue({ user: { ...ownProfile, role: 'admin' } });
mockUserAPI.getPublicProfile.mockResolvedValue({ data: ownProfile });
renderWithRouter('current-user');
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.queryByText('Ban User')).not.toBeInTheDocument();
});
it('opens ban modal when ban button clicked', async () => {
mockUserAPI.getPublicProfile.mockResolvedValue({ data: createMockUser() });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Ban User')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Ban User'));
await waitFor(() => {
expect(screen.getByTestId('ban-modal')).toBeInTheDocument();
});
});
it('updates user state after ban completes', async () => {
const profileUser = createMockUser();
mockUserAPI.getPublicProfile.mockResolvedValue({ data: profileUser });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Ban User')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Ban User'));
await waitFor(() => {
expect(screen.getByTestId('ban-modal')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('confirm-ban'));
await waitFor(() => {
expect(screen.getByText('Unban User')).toBeInTheDocument();
});
});
it('calls adminUnbanUser when unban confirmed', async () => {
const bannedUser = createMockUser({ isBanned: true });
mockUserAPI.getPublicProfile.mockResolvedValue({ data: bannedUser });
mockUserAPI.adminUnbanUser.mockResolvedValue({
data: { user: { ...bannedUser, isBanned: false } },
});
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Unban User')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Unban User'));
await waitFor(() => {
expect(screen.getByTestId('confirmation-modal')).toBeInTheDocument();
});
fireEvent.click(screen.getByTestId('confirm-action'));
await waitFor(() => {
expect(mockUserAPI.adminUnbanUser).toHaveBeenCalledWith(bannedUser.id);
});
});
});
describe('Non-Admin Users', () => {
it('does not show ban controls for non-admins', async () => {
mockUseAuth.mockReturnValue({ user: mockCurrentUser });
mockUserAPI.getPublicProfile.mockResolvedValue({ data: createMockUser() });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.queryByText('Ban User')).not.toBeInTheDocument();
});
it('does not show ban status for non-admins', async () => {
mockUseAuth.mockReturnValue({ user: mockCurrentUser });
const bannedUser = createMockUser({ isBanned: true });
mockUserAPI.getPublicProfile.mockResolvedValue({ data: bannedUser });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('John Doe')).toBeInTheDocument();
});
expect(screen.queryByText('Banned')).not.toBeInTheDocument();
});
});
describe('Back Button', () => {
it('shows back button', async () => {
mockUserAPI.getPublicProfile.mockResolvedValue({ data: createMockUser() });
renderWithRouter();
await waitFor(() => {
expect(screen.getByText('Back')).toBeInTheDocument();
});
});
});
});

View File

@@ -232,3 +232,214 @@ export const createMockAvailableChecks = (rentalId: string, checkTypes: string[]
checkType,
})),
});
// ============================================
// Forum Mock Data
// ============================================
// Mock Forum Post factory
export const createMockForumPost = (overrides: {
id?: string;
title?: string;
content?: string;
category?: 'item_request' | 'technical_support' | 'community_resources' | 'general_discussion';
status?: 'open' | 'answered' | 'closed';
authorId?: string;
author?: typeof mockUser;
tags?: Array<{ id: string; tagName: string }>;
comments?: any[];
commentCount?: number;
isPinned?: boolean;
isDeleted?: boolean;
deletedAt?: string;
acceptedAnswerId?: string;
zipCode?: string;
latitude?: number;
longitude?: number;
imageFilenames?: string[];
createdAt?: string;
updatedAt?: string;
} = {}) => ({
id: overrides.id || String(Math.floor(Math.random() * 1000)),
title: overrides.title || 'Test Forum Post',
content: overrides.content || 'This is a test forum post content.',
category: overrides.category || 'general_discussion',
status: overrides.status || 'open',
authorId: overrides.authorId || '1',
author: overrides.author || mockUser,
tags: overrides.tags || [],
comments: overrides.comments || [],
commentCount: overrides.commentCount ?? 0,
isPinned: overrides.isPinned ?? false,
isDeleted: overrides.isDeleted ?? false,
deletedAt: overrides.deletedAt,
acceptedAnswerId: overrides.acceptedAnswerId,
zipCode: overrides.zipCode,
latitude: overrides.latitude,
longitude: overrides.longitude,
imageFilenames: overrides.imageFilenames || [],
createdAt: overrides.createdAt || new Date().toISOString(),
updatedAt: overrides.updatedAt || new Date().toISOString(),
});
// Mock Forum Comment factory
export const createMockForumComment = (overrides: {
id?: string;
postId?: string;
authorId?: string;
author?: typeof mockUser;
content?: string;
parentId?: string;
replies?: any[];
isDeleted?: boolean;
deletedAt?: string;
imageFilenames?: string[];
createdAt?: string;
updatedAt?: string;
} = {}) => ({
id: overrides.id || String(Math.floor(Math.random() * 1000)),
postId: overrides.postId || '1',
authorId: overrides.authorId || '1',
author: overrides.author || mockUser,
content: overrides.content || 'This is a test comment.',
parentId: overrides.parentId,
replies: overrides.replies || [],
isDeleted: overrides.isDeleted ?? false,
deletedAt: overrides.deletedAt,
imageFilenames: overrides.imageFilenames || [],
createdAt: overrides.createdAt || new Date().toISOString(),
updatedAt: overrides.updatedAt || new Date().toISOString(),
});
// Pre-made forum posts
export const mockForumPosts = {
itemRequest: createMockForumPost({
id: '1',
title: 'Looking for a DSLR camera',
content: 'I need a DSLR camera for a weekend photoshoot.',
category: 'item_request',
zipCode: '94102',
tags: [{ id: '1', tagName: 'camera' }, { id: '2', tagName: 'photography' }],
}),
technicalSupport: createMockForumPost({
id: '2',
title: 'Cannot upload images',
content: 'I get an error when trying to upload images to my listing.',
category: 'technical_support',
tags: [{ id: '3', tagName: 'upload' }, { id: '4', tagName: 'error' }],
}),
generalDiscussion: createMockForumPost({
id: '3',
title: 'Best practices for rental pricing',
content: 'What are some tips for pricing items competitively?',
category: 'general_discussion',
}),
pinnedPost: createMockForumPost({
id: '4',
title: 'Community Guidelines',
content: 'Please read our community guidelines.',
isPinned: true,
author: mockAdminUser,
}),
closedPost: createMockForumPost({
id: '5',
title: 'Old discussion',
content: 'This discussion has been resolved.',
status: 'closed',
}),
deletedPost: createMockForumPost({
id: '6',
title: 'Deleted post',
content: 'This post was deleted.',
isDeleted: true,
deletedAt: new Date().toISOString(),
}),
};
// ============================================
// Message Mock Data
// ============================================
// Mock Message factory
export const createMockMessage = (overrides: {
id?: string;
senderId?: string;
receiverId?: string;
content?: string;
isRead?: boolean;
imageFilename?: string;
sender?: typeof mockUser;
receiver?: typeof mockUser;
createdAt?: string;
} = {}) => ({
id: overrides.id || String(Math.floor(Math.random() * 1000)),
senderId: overrides.senderId || '1',
receiverId: overrides.receiverId || '2',
content: overrides.content || 'Hello, this is a test message.',
isRead: overrides.isRead ?? false,
imageFilename: overrides.imageFilename,
sender: overrides.sender || mockUser,
receiver: overrides.receiver || { ...mockUser, id: '2', firstName: 'Other', lastName: 'User' },
createdAt: overrides.createdAt || new Date().toISOString(),
});
// Mock Conversation factory
export const createMockConversation = (overrides: {
partnerId?: string;
partner?: typeof mockUser;
lastMessage?: ReturnType<typeof createMockMessage>;
unreadCount?: number;
lastMessageAt?: string;
} = {}) => ({
partnerId: overrides.partnerId || '2',
partner: overrides.partner || { ...mockUser, id: '2', firstName: 'Partner', lastName: 'User' },
lastMessage: overrides.lastMessage || createMockMessage(),
unreadCount: overrides.unreadCount ?? 0,
lastMessageAt: overrides.lastMessageAt || new Date().toISOString(),
});
// Pre-made conversations
export const mockConversations = [
createMockConversation({
partnerId: '2',
partner: { ...mockUser, id: '2', firstName: 'John', lastName: 'Doe', email: 'john@example.com' },
lastMessage: createMockMessage({ content: 'Hey, is the camera still available?' }),
unreadCount: 2,
lastMessageAt: new Date().toISOString(),
}),
createMockConversation({
partnerId: '3',
partner: { ...mockUser, id: '3', firstName: 'Jane', lastName: 'Smith', email: 'jane@example.com' },
lastMessage: createMockMessage({ content: 'Thanks for the rental!', isRead: true, senderId: '1' }),
unreadCount: 0,
lastMessageAt: new Date(Date.now() - 86400000).toISOString(),
}),
];
// Pre-made messages for a conversation
export const mockMessages = [
createMockMessage({
id: '1',
senderId: '2',
receiverId: '1',
content: 'Hey, is the camera still available?',
createdAt: new Date(Date.now() - 3600000).toISOString(),
isRead: true,
}),
createMockMessage({
id: '2',
senderId: '1',
receiverId: '2',
content: 'Yes, it is! When do you need it?',
createdAt: new Date(Date.now() - 3000000).toISOString(),
isRead: true,
}),
createMockMessage({
id: '3',
senderId: '2',
receiverId: '1',
content: 'This weekend would be great. Can I pick it up Friday?',
createdAt: new Date(Date.now() - 2400000).toISOString(),
isRead: false,
}),
];