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/dom": "^10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^14.0.0",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
@@ -1752,15 +1752,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@testing-library/user-event": {
|
"node_modules/@testing-library/user-event": {
|
||||||
"version": "13.5.0",
|
"version": "14.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
|
||||||
"integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
|
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.12.5"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10",
|
"node": ">=12",
|
||||||
"npm": ">=6"
|
"npm": ">=6"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"@testing-library/dom": "^10.4.0",
|
"@testing-library/dom": "^10.4.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^14.0.0",
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/react": "^19.1.8",
|
"@types/react": "^19.1.8",
|
||||||
"@types/react-dom": "^19.1.6",
|
"@types/react-dom": "^19.1.6",
|
||||||
|
|||||||
@@ -209,13 +209,14 @@ describe('AuthModal', () => {
|
|||||||
|
|
||||||
describe('Login Form Submission', () => {
|
describe('Login Form Submission', () => {
|
||||||
it('should call login with email and password', async () => {
|
it('should call login with email and password', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
mockLogin.mockResolvedValue({});
|
mockLogin.mockResolvedValue({});
|
||||||
const { container } = render(<AuthModal {...defaultProps} />);
|
const { container } = render(<AuthModal {...defaultProps} />);
|
||||||
|
|
||||||
// Fill in the form
|
// Fill in the form
|
||||||
const emailInput = getInputByLabelText(container, 'Email');
|
const emailInput = getInputByLabelText(container, 'Email');
|
||||||
await userEvent.type(emailInput, 'test@example.com');
|
await user.type(emailInput, 'test@example.com');
|
||||||
await userEvent.type(screen.getByTestId('password-input'), 'password123');
|
await user.type(screen.getByTestId('password-input'), 'password123');
|
||||||
|
|
||||||
// Submit the form
|
// Submit the form
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||||
@@ -226,12 +227,13 @@ describe('AuthModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should call onHide after successful login', async () => {
|
it('should call onHide after successful login', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
mockLogin.mockResolvedValue({});
|
mockLogin.mockResolvedValue({});
|
||||||
const { container } = render(<AuthModal {...defaultProps} />);
|
const { container } = render(<AuthModal {...defaultProps} />);
|
||||||
|
|
||||||
const emailInput = getInputByLabelText(container, 'Email');
|
const emailInput = getInputByLabelText(container, 'Email');
|
||||||
await userEvent.type(emailInput, 'test@example.com');
|
await user.type(emailInput, 'test@example.com');
|
||||||
await userEvent.type(screen.getByTestId('password-input'), 'password123');
|
await user.type(screen.getByTestId('password-input'), 'password123');
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||||
|
|
||||||
@@ -241,6 +243,7 @@ describe('AuthModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should display error message on login failure', async () => {
|
it('should display error message on login failure', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
mockLogin.mockRejectedValue({
|
mockLogin.mockRejectedValue({
|
||||||
response: { data: { error: 'Invalid credentials' } },
|
response: { data: { error: 'Invalid credentials' } },
|
||||||
});
|
});
|
||||||
@@ -248,8 +251,8 @@ describe('AuthModal', () => {
|
|||||||
const { container } = render(<AuthModal {...defaultProps} />);
|
const { container } = render(<AuthModal {...defaultProps} />);
|
||||||
|
|
||||||
const emailInput = getInputByLabelText(container, 'Email');
|
const emailInput = getInputByLabelText(container, 'Email');
|
||||||
await userEvent.type(emailInput, 'test@example.com');
|
await user.type(emailInput, 'test@example.com');
|
||||||
await userEvent.type(screen.getByTestId('password-input'), 'wrongpassword');
|
await user.type(screen.getByTestId('password-input'), 'wrongpassword');
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||||
|
|
||||||
@@ -259,14 +262,15 @@ describe('AuthModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show loading state during login', async () => {
|
it('should show loading state during login', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
// Make login take some time
|
// Make login take some time
|
||||||
mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||||
|
|
||||||
const { container } = render(<AuthModal {...defaultProps} />);
|
const { container } = render(<AuthModal {...defaultProps} />);
|
||||||
|
|
||||||
const emailInput = getInputByLabelText(container, 'Email');
|
const emailInput = getInputByLabelText(container, 'Email');
|
||||||
await userEvent.type(emailInput, 'test@example.com');
|
await user.type(emailInput, 'test@example.com');
|
||||||
await userEvent.type(screen.getByTestId('password-input'), 'password123');
|
await user.type(screen.getByTestId('password-input'), 'password123');
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||||
|
|
||||||
@@ -276,13 +280,14 @@ describe('AuthModal', () => {
|
|||||||
|
|
||||||
describe('Signup Form Submission', () => {
|
describe('Signup Form Submission', () => {
|
||||||
it('should call register with user data', async () => {
|
it('should call register with user data', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
mockRegister.mockResolvedValue({});
|
mockRegister.mockResolvedValue({});
|
||||||
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
|
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
|
||||||
|
|
||||||
await userEvent.type(getInputByLabelText(container, 'First Name'), 'John');
|
await user.type(getInputByLabelText(container, 'First Name'), 'John');
|
||||||
await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe');
|
await user.type(getInputByLabelText(container, 'Last Name'), 'Doe');
|
||||||
await userEvent.type(getInputByLabelText(container, 'Email'), 'john@example.com');
|
await user.type(getInputByLabelText(container, 'Email'), 'john@example.com');
|
||||||
await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!');
|
await user.type(screen.getByTestId('password-input'), 'StrongPass123!');
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
|
||||||
|
|
||||||
@@ -298,13 +303,14 @@ describe('AuthModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show verification modal after successful signup', async () => {
|
it('should show verification modal after successful signup', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
mockRegister.mockResolvedValue({});
|
mockRegister.mockResolvedValue({});
|
||||||
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
|
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
|
||||||
|
|
||||||
await userEvent.type(getInputByLabelText(container, 'First Name'), 'John');
|
await user.type(getInputByLabelText(container, 'First Name'), 'John');
|
||||||
await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe');
|
await user.type(getInputByLabelText(container, 'Last Name'), 'Doe');
|
||||||
await userEvent.type(getInputByLabelText(container, 'Email'), 'john@example.com');
|
await user.type(getInputByLabelText(container, 'Email'), 'john@example.com');
|
||||||
await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!');
|
await user.type(screen.getByTestId('password-input'), 'StrongPass123!');
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
|
||||||
|
|
||||||
@@ -315,16 +321,17 @@ describe('AuthModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should display error message on signup failure', async () => {
|
it('should display error message on signup failure', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
mockRegister.mockRejectedValue({
|
mockRegister.mockRejectedValue({
|
||||||
response: { data: { error: 'Email already exists' } },
|
response: { data: { error: 'Email already exists' } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
|
const { container } = render(<AuthModal {...defaultProps} initialMode="signup" />);
|
||||||
|
|
||||||
await userEvent.type(getInputByLabelText(container, 'First Name'), 'John');
|
await user.type(getInputByLabelText(container, 'First Name'), 'John');
|
||||||
await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe');
|
await user.type(getInputByLabelText(container, 'Last Name'), 'Doe');
|
||||||
await userEvent.type(getInputByLabelText(container, 'Email'), 'existing@example.com');
|
await user.type(getInputByLabelText(container, 'Email'), 'existing@example.com');
|
||||||
await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!');
|
await user.type(screen.getByTestId('password-input'), 'StrongPass123!');
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
|
||||||
|
|
||||||
@@ -414,6 +421,7 @@ describe('AuthModal', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should display error in an alert role', async () => {
|
it('should display error in an alert role', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
mockLogin.mockRejectedValue({
|
mockLogin.mockRejectedValue({
|
||||||
response: { data: { error: 'Test error' } },
|
response: { data: { error: 'Test error' } },
|
||||||
});
|
});
|
||||||
@@ -421,8 +429,8 @@ describe('AuthModal', () => {
|
|||||||
const { container } = render(<AuthModal {...defaultProps} />);
|
const { container } = render(<AuthModal {...defaultProps} />);
|
||||||
|
|
||||||
const emailInput = getInputByLabelText(container, 'Email');
|
const emailInput = getInputByLabelText(container, 'Email');
|
||||||
await userEvent.type(emailInput, 'test@example.com');
|
await user.type(emailInput, 'test@example.com');
|
||||||
await userEvent.type(screen.getByTestId('password-input'), 'password');
|
await user.type(screen.getByTestId('password-input'), 'password');
|
||||||
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
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
|
it('does not auto-save address when user has existing addresses', async () => {
|
||||||
// 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 () => {
|
|
||||||
mockedGetAddresses.mockResolvedValue({ data: [mockAddress] });
|
mockedGetAddresses.mockResolvedValue({ data: [mockAddress] });
|
||||||
|
|
||||||
renderWithRouter(<CreateItem />);
|
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(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByTestId('address1')).toHaveValue('123 Main St');
|
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,
|
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