more frontend tests
This commit is contained in:
13
frontend/package-lock.json
generated
13
frontend/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
786
frontend/src/__tests__/components/ChatWindow.test.tsx
Normal file
786
frontend/src/__tests__/components/ChatWindow.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
308
frontend/src/__tests__/components/ForumPostListItem.test.tsx
Normal file
308
frontend/src/__tests__/components/ForumPostListItem.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
90
frontend/src/__tests__/components/TypingIndicator.test.tsx
Normal file
90
frontend/src/__tests__/components/TypingIndicator.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
302
frontend/src/__tests__/contexts/SocketContext.test.tsx
Normal file
302
frontend/src/__tests__/contexts/SocketContext.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
103
frontend/src/__tests__/mocks/socket.ts
Normal file
103
frontend/src/__tests__/mocks/socket.ts
Normal 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,
|
||||
});
|
||||
712
frontend/src/__tests__/pages/CreateForumPost.test.tsx
Normal file
712
frontend/src/__tests__/pages/CreateForumPost.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
500
frontend/src/__tests__/pages/EarningsDashboard.test.tsx
Normal file
500
frontend/src/__tests__/pages/EarningsDashboard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
641
frontend/src/__tests__/pages/ForumPostDetail.test.tsx
Normal file
641
frontend/src/__tests__/pages/ForumPostDetail.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
677
frontend/src/__tests__/pages/ForumPosts.test.tsx
Normal file
677
frontend/src/__tests__/pages/ForumPosts.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
572
frontend/src/__tests__/pages/Messages.test.tsx
Normal file
572
frontend/src/__tests__/pages/Messages.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
723
frontend/src/__tests__/pages/Profile.test.tsx
Normal file
723
frontend/src/__tests__/pages/Profile.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
558
frontend/src/__tests__/pages/PublicProfile.test.tsx
Normal file
558
frontend/src/__tests__/pages/PublicProfile.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user