From cae9e7e4731c9a96e8da121b1e5e5629e7d2f195 Mon Sep 17 00:00:00 2001
From: jackiettran <41605212+jackiettran@users.noreply.github.com>
Date: Tue, 20 Jan 2026 22:31:57 -0500
Subject: [PATCH] more frontend tests
---
frontend/package-lock.json | 13 +-
frontend/package.json | 2 +-
.../__tests__/components/AuthModal.test.tsx | 52 +-
.../__tests__/components/ChatWindow.test.tsx | 786 ++++++++++++++++++
.../components/ForumPostListItem.test.tsx | 308 +++++++
.../components/TypingIndicator.test.tsx | 90 ++
.../__tests__/contexts/SocketContext.test.tsx | 302 +++++++
frontend/src/__tests__/mocks/socket.ts | 103 +++
.../__tests__/pages/CreateForumPost.test.tsx | 712 ++++++++++++++++
.../src/__tests__/pages/CreateItem.test.tsx | 12 +-
.../pages/EarningsDashboard.test.tsx | 500 +++++++++++
.../__tests__/pages/ForumPostDetail.test.tsx | 641 ++++++++++++++
.../src/__tests__/pages/ForumPosts.test.tsx | 677 +++++++++++++++
.../src/__tests__/pages/Messages.test.tsx | 572 +++++++++++++
frontend/src/__tests__/pages/Profile.test.tsx | 723 ++++++++++++++++
.../__tests__/pages/PublicProfile.test.tsx | 558 +++++++++++++
frontend/src/mocks/handlers.ts | 211 +++++
17 files changed, 6226 insertions(+), 36 deletions(-)
create mode 100644 frontend/src/__tests__/components/ChatWindow.test.tsx
create mode 100644 frontend/src/__tests__/components/ForumPostListItem.test.tsx
create mode 100644 frontend/src/__tests__/components/TypingIndicator.test.tsx
create mode 100644 frontend/src/__tests__/contexts/SocketContext.test.tsx
create mode 100644 frontend/src/__tests__/mocks/socket.ts
create mode 100644 frontend/src/__tests__/pages/CreateForumPost.test.tsx
create mode 100644 frontend/src/__tests__/pages/EarningsDashboard.test.tsx
create mode 100644 frontend/src/__tests__/pages/ForumPostDetail.test.tsx
create mode 100644 frontend/src/__tests__/pages/ForumPosts.test.tsx
create mode 100644 frontend/src/__tests__/pages/Messages.test.tsx
create mode 100644 frontend/src/__tests__/pages/Profile.test.tsx
create mode 100644 frontend/src/__tests__/pages/PublicProfile.test.tsx
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index e3d554a..81fa227 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -16,7 +16,7 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
- "@testing-library/user-event": "^13.5.0",
+ "@testing-library/user-event": "^14.0.0",
"@types/node": "^20.0.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
@@ -1752,15 +1752,12 @@
}
},
"node_modules/@testing-library/user-event": {
- "version": "13.5.0",
- "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
- "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
"license": "MIT",
- "dependencies": {
- "@babel/runtime": "^7.12.5"
- },
"engines": {
- "node": ">=10",
+ "node": ">=12",
"npm": ">=6"
},
"peerDependencies": {
diff --git a/frontend/package.json b/frontend/package.json
index 519e3c2..fb10ca4 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -12,7 +12,7 @@
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
- "@testing-library/user-event": "^13.5.0",
+ "@testing-library/user-event": "^14.0.0",
"@types/node": "^20.0.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
diff --git a/frontend/src/__tests__/components/AuthModal.test.tsx b/frontend/src/__tests__/components/AuthModal.test.tsx
index d3b6736..806eb4a 100644
--- a/frontend/src/__tests__/components/AuthModal.test.tsx
+++ b/frontend/src/__tests__/components/AuthModal.test.tsx
@@ -209,13 +209,14 @@ describe('AuthModal', () => {
describe('Login Form Submission', () => {
it('should call login with email and password', async () => {
+ const user = userEvent.setup();
mockLogin.mockResolvedValue({});
const { container } = render();
// Fill in the form
const emailInput = getInputByLabelText(container, 'Email');
- await userEvent.type(emailInput, 'test@example.com');
- await userEvent.type(screen.getByTestId('password-input'), 'password123');
+ await user.type(emailInput, 'test@example.com');
+ await user.type(screen.getByTestId('password-input'), 'password123');
// Submit the form
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
@@ -226,12 +227,13 @@ describe('AuthModal', () => {
});
it('should call onHide after successful login', async () => {
+ const user = userEvent.setup();
mockLogin.mockResolvedValue({});
const { container } = render();
const emailInput = getInputByLabelText(container, 'Email');
- await userEvent.type(emailInput, 'test@example.com');
- await userEvent.type(screen.getByTestId('password-input'), 'password123');
+ await user.type(emailInput, 'test@example.com');
+ await user.type(screen.getByTestId('password-input'), 'password123');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
@@ -241,6 +243,7 @@ describe('AuthModal', () => {
});
it('should display error message on login failure', async () => {
+ const user = userEvent.setup();
mockLogin.mockRejectedValue({
response: { data: { error: 'Invalid credentials' } },
});
@@ -248,8 +251,8 @@ describe('AuthModal', () => {
const { container } = render();
const emailInput = getInputByLabelText(container, 'Email');
- await userEvent.type(emailInput, 'test@example.com');
- await userEvent.type(screen.getByTestId('password-input'), 'wrongpassword');
+ await user.type(emailInput, 'test@example.com');
+ await user.type(screen.getByTestId('password-input'), 'wrongpassword');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
@@ -259,14 +262,15 @@ describe('AuthModal', () => {
});
it('should show loading state during login', async () => {
+ const user = userEvent.setup();
// Make login take some time
mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
const { container } = render();
const emailInput = getInputByLabelText(container, 'Email');
- await userEvent.type(emailInput, 'test@example.com');
- await userEvent.type(screen.getByTestId('password-input'), 'password123');
+ await user.type(emailInput, 'test@example.com');
+ await user.type(screen.getByTestId('password-input'), 'password123');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
@@ -276,13 +280,14 @@ describe('AuthModal', () => {
describe('Signup Form Submission', () => {
it('should call register with user data', async () => {
+ const user = userEvent.setup();
mockRegister.mockResolvedValue({});
const { container } = render();
- await userEvent.type(getInputByLabelText(container, 'First Name'), 'John');
- await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe');
- await userEvent.type(getInputByLabelText(container, 'Email'), 'john@example.com');
- await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!');
+ await user.type(getInputByLabelText(container, 'First Name'), 'John');
+ await user.type(getInputByLabelText(container, 'Last Name'), 'Doe');
+ await user.type(getInputByLabelText(container, 'Email'), 'john@example.com');
+ await user.type(screen.getByTestId('password-input'), 'StrongPass123!');
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
@@ -298,13 +303,14 @@ describe('AuthModal', () => {
});
it('should show verification modal after successful signup', async () => {
+ const user = userEvent.setup();
mockRegister.mockResolvedValue({});
const { container } = render();
- await userEvent.type(getInputByLabelText(container, 'First Name'), 'John');
- await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe');
- await userEvent.type(getInputByLabelText(container, 'Email'), 'john@example.com');
- await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!');
+ await user.type(getInputByLabelText(container, 'First Name'), 'John');
+ await user.type(getInputByLabelText(container, 'Last Name'), 'Doe');
+ await user.type(getInputByLabelText(container, 'Email'), 'john@example.com');
+ await user.type(screen.getByTestId('password-input'), 'StrongPass123!');
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
@@ -315,16 +321,17 @@ describe('AuthModal', () => {
});
it('should display error message on signup failure', async () => {
+ const user = userEvent.setup();
mockRegister.mockRejectedValue({
response: { data: { error: 'Email already exists' } },
});
const { container } = render();
- await userEvent.type(getInputByLabelText(container, 'First Name'), 'John');
- await userEvent.type(getInputByLabelText(container, 'Last Name'), 'Doe');
- await userEvent.type(getInputByLabelText(container, 'Email'), 'existing@example.com');
- await userEvent.type(screen.getByTestId('password-input'), 'StrongPass123!');
+ await user.type(getInputByLabelText(container, 'First Name'), 'John');
+ await user.type(getInputByLabelText(container, 'Last Name'), 'Doe');
+ await user.type(getInputByLabelText(container, 'Email'), 'existing@example.com');
+ await user.type(screen.getByTestId('password-input'), 'StrongPass123!');
fireEvent.click(screen.getByRole('button', { name: 'Sign up' }));
@@ -414,6 +421,7 @@ describe('AuthModal', () => {
});
it('should display error in an alert role', async () => {
+ const user = userEvent.setup();
mockLogin.mockRejectedValue({
response: { data: { error: 'Test error' } },
});
@@ -421,8 +429,8 @@ describe('AuthModal', () => {
const { container } = render();
const emailInput = getInputByLabelText(container, 'Email');
- await userEvent.type(emailInput, 'test@example.com');
- await userEvent.type(screen.getByTestId('password-input'), 'password');
+ await user.type(emailInput, 'test@example.com');
+ await user.type(screen.getByTestId('password-input'), 'password');
fireEvent.click(screen.getByRole('button', { name: 'Log in' }));
await waitFor(() => {
diff --git a/frontend/src/__tests__/components/ChatWindow.test.tsx b/frontend/src/__tests__/components/ChatWindow.test.tsx
new file mode 100644
index 0000000..b017f17
--- /dev/null
+++ b/frontend/src/__tests__/components/ChatWindow.test.tsx
@@ -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
{firstName} is typing...
;
+ },
+}));
+
+// Mock Avatar
+vi.mock('../../components/Avatar', () => ({
+ default: function MockAvatar({ user }: { user: User }) {
+ return {user?.firstName?.[0] || '?'}
;
+ },
+}));
+
+import { messageAPI } from '../../services/api';
+import { uploadImageWithVariants, getSignedImageUrl } from '../../services/uploadService';
+
+const mockMessageAPI = messageAPI as jest.Mocked;
+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 => ({
+ 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(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders when show is true', async () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ });
+
+ it('fetches sent and received messages when show is true', async () => {
+ render(
+
+ );
+
+ 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(
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText('First message')).toBeInTheDocument();
+ expect(screen.getByText('Second message')).toBeInTheDocument();
+ });
+ });
+
+ it('shows empty state when no messages', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(`Start a conversation with ${mockRecipient.firstName}`)
+ ).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Conversation Room Management', () => {
+ it('joins conversation room on mount', async () => {
+ render(
+
+ );
+
+ await waitFor(() => {
+ expect(mockJoinConversation).toHaveBeenCalledWith(mockRecipient.id);
+ });
+ });
+
+ it('leaves conversation room on unmount', async () => {
+ const { unmount } = render(
+
+ );
+
+ await waitFor(() => {
+ expect(mockJoinConversation).toHaveBeenCalled();
+ });
+
+ unmount();
+ expect(mockLeaveConversation).toHaveBeenCalledWith(mockRecipient.id);
+ });
+
+ it('leaves conversation room on close', async () => {
+ const { rerender } = render(
+
+ );
+
+ await waitFor(() => {
+ expect(mockJoinConversation).toHaveBeenCalled();
+ });
+
+ rerender(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ 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(
+
+ );
+
+ await waitFor(() => {
+ expect(container.querySelector('.btn-close')).toBeInTheDocument();
+ });
+
+ const closeButton = container.querySelector('.btn-close');
+ fireEvent.click(closeButton!);
+ expect(mockOnClose).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/frontend/src/__tests__/components/ForumPostListItem.test.tsx b/frontend/src/__tests__/components/ForumPostListItem.test.tsx
new file mode 100644
index 0000000..d9b9071
--- /dev/null
+++ b/frontend/src/__tests__/components/ForumPostListItem.test.tsx
@@ -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 => ({
+ id: '1',
+ title: 'Test Post Title',
+ content: 'This is the test post content that might be quite long and need truncation.
',
+ 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({ui});
+};
+
+describe('ForumPostListItem', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockUseAuth.mockReturnValue({ user: null });
+ });
+
+ describe('Basic Rendering', () => {
+ it('renders post title', () => {
+ const post = createMockPost({ title: 'My Test Post' });
+ renderWithRouter();
+
+ expect(screen.getByText('My Test Post')).toBeInTheDocument();
+ });
+
+ it('renders content preview truncated to 100 characters', () => {
+ const longContent = '' + 'A'.repeat(150) + '
';
+ const post = createMockPost({ content: longContent });
+ renderWithRouter();
+
+ // 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: 'Bold and italic text
',
+ });
+ renderWithRouter();
+
+ 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();
+
+ // Avatar should show first letter of first name
+ expect(screen.getByText('J')).toBeInTheDocument();
+ });
+
+ it('renders author name', () => {
+ const post = createMockPost();
+ renderWithRouter();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ // 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();
+
+ // 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();
+
+ expect(screen.getByText('Item Request')).toBeInTheDocument();
+ });
+
+ it('shows status badge', () => {
+ const post = createMockPost({ status: 'answered' });
+ renderWithRouter();
+
+ 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();
+
+ expect(screen.queryByText('Deleted')).not.toBeInTheDocument();
+ });
+
+ it('shows deleted badge for admin users', () => {
+ mockUseAuth.mockReturnValue({ user: { role: 'admin' } });
+ const post = createMockPost({ isDeleted: true });
+ renderWithRouter();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByText('1 reply')).toBeInTheDocument();
+ });
+
+ it('shows plural "replies" for multiple comments', () => {
+ const post = createMockPost({ commentCount: 5 });
+ renderWithRouter();
+
+ expect(screen.getByText('5 replies')).toBeInTheDocument();
+ });
+
+ it('shows "0 replies" when no comments', () => {
+ const post = createMockPost({ commentCount: 0 });
+ renderWithRouter();
+
+ expect(screen.getByText('0 replies')).toBeInTheDocument();
+ });
+ });
+
+ describe('Link Generation', () => {
+ it('links to forum post detail page', () => {
+ const post = createMockPost({ id: 'post-123' });
+ renderWithRouter();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ expect(screen.getByText('Unknown')).toBeInTheDocument();
+ expect(screen.getByText('?')).toBeInTheDocument(); // Avatar fallback
+ });
+
+ it('handles empty tags array', () => {
+ const post = createMockPost({ tags: [] });
+ renderWithRouter();
+
+ expect(screen.queryByText(/^#/)).not.toBeInTheDocument();
+ });
+
+ it('handles undefined commentCount', () => {
+ const post = createMockPost({ commentCount: undefined as any });
+ renderWithRouter();
+
+ expect(screen.getByText('0 replies')).toBeInTheDocument();
+ });
+
+ it('handles short content that does not need truncation', () => {
+ const post = createMockPost({ content: 'Short content
' });
+ renderWithRouter();
+
+ expect(screen.getByText('Short content')).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/__tests__/components/TypingIndicator.test.tsx b/frontend/src/__tests__/components/TypingIndicator.test.tsx
new file mode 100644
index 0000000..32aed69
--- /dev/null
+++ b/frontend/src/__tests__/components/TypingIndicator.test.tsx
@@ -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(
+
+ );
+
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('renders the indicator when isVisible is true', () => {
+ render();
+
+ expect(screen.getByText(/John is typing/)).toBeInTheDocument();
+ });
+ });
+
+ describe('Display Content', () => {
+ it('displays the correct user name', () => {
+ render();
+
+ expect(screen.getByText(/Alice is typing/)).toBeInTheDocument();
+ });
+
+ it('displays different user names correctly', () => {
+ const { rerender } = render(
+
+ );
+
+ expect(screen.getByText(/Bob is typing/)).toBeInTheDocument();
+
+ rerender();
+
+ expect(screen.getByText(/Charlie is typing/)).toBeInTheDocument();
+ });
+ });
+
+ describe('Animated Dots', () => {
+ it('renders typing dots when visible', () => {
+ const { container } = render(
+
+ );
+
+ const dots = container.querySelectorAll('.dot');
+ expect(dots.length).toBe(3);
+ });
+
+ it('has the correct structure', () => {
+ const { container } = render(
+
+ );
+
+ 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();
+
+ expect(screen.getByText(/is typing/)).toBeInTheDocument();
+ });
+
+ it('handles firstName with special characters', () => {
+ render();
+
+ expect(screen.getByText(/John-Paul is typing/)).toBeInTheDocument();
+ });
+
+ it('handles firstName with spaces', () => {
+ render();
+
+ expect(screen.getByText(/Mary Jane is typing/)).toBeInTheDocument();
+ });
+ });
+});
diff --git a/frontend/src/__tests__/contexts/SocketContext.test.tsx b/frontend/src/__tests__/contexts/SocketContext.test.tsx
new file mode 100644
index 0000000..1bdb340
--- /dev/null
+++ b/frontend/src/__tests__/contexts/SocketContext.test.tsx
@@ -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;
+
+// Test component that uses the socket context
+const TestComponent: React.FC = () => {
+ const socket = useSocket();
+
+ return (
+
+
{socket.isConnected ? 'connected' : 'disconnected'}
+
+
+
+
+
+
+ );
+};
+
+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()).toThrow(
+ 'useSocket must be used within a SocketProvider'
+ );
+
+ consoleError.mockRestore();
+ });
+
+ it('provides socket context when inside SocketProvider', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId('is-connected')).toBeInTheDocument();
+ });
+ });
+
+ describe('Connection Management', () => {
+ it('connects socket when isAuthenticated is true', async () => {
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(mockSocketService.connect).toHaveBeenCalled();
+ });
+ });
+
+ it('does not connect socket when isAuthenticated is false', async () => {
+ render(
+
+
+
+ );
+
+ // 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(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(mockSocketService.connect).toHaveBeenCalled();
+ });
+
+ // Change authentication status
+ rerender(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(mockSocketService.disconnect).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('Conversation Management', () => {
+ it('joinConversation calls socketService.joinConversation', async () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText('Join'));
+
+ expect(mockSocketService.joinConversation).toHaveBeenCalledWith('user-123');
+ });
+
+ it('leaveConversation calls socketService.leaveConversation', async () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText('Leave'));
+
+ expect(mockSocketService.leaveConversation).toHaveBeenCalledWith('user-123');
+ });
+ });
+
+ describe('Typing Events', () => {
+ it('emitTypingStart calls socketService.emitTypingStart', async () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText('Start Typing'));
+
+ expect(mockSocketService.emitTypingStart).toHaveBeenCalledWith('user-123');
+ });
+
+ it('emitTypingStop calls socketService.emitTypingStop', async () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByText('Stop Typing'));
+
+ expect(mockSocketService.emitTypingStop).toHaveBeenCalledWith('user-123');
+ });
+ });
+
+ describe('Message Read Events', () => {
+ it('emitMarkMessageRead calls socketService.emitMarkMessageRead', async () => {
+ render(
+
+
+
+ );
+
+ 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 Test
;
+ };
+
+ render(
+
+
+
+ );
+
+ 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 Test
;
+ };
+
+ render(
+
+
+
+ );
+
+ 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 Test
;
+ };
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(mockSocketService.onUserTyping).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('Connection Listener', () => {
+ it('registers connection listener on mount', async () => {
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(mockSocketService.addConnectionListener).toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/frontend/src/__tests__/mocks/socket.ts b/frontend/src/__tests__/mocks/socket.ts
new file mode 100644
index 0000000..4573784
--- /dev/null
+++ b/frontend/src/__tests__/mocks/socket.ts
@@ -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 = {};
+
+ 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,
+});
diff --git a/frontend/src/__tests__/pages/CreateForumPost.test.tsx b/frontend/src/__tests__/pages/CreateForumPost.test.tsx
new file mode 100644
index 0000000..586929c
--- /dev/null
+++ b/frontend/src/__tests__/pages/CreateForumPost.test.tsx
@@ -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 (
+
+ {selectedTags.map((tag, i) => (
+ {tag}
+ ))}
+ {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ const target = e.target as HTMLInputElement;
+ if (target.value && selectedTags.length < 5) {
+ onChange([...selectedTags, target.value]);
+ target.value = '';
+ }
+ }
+ }}
+ />
+
+ );
+ },
+}));
+
+vi.mock('../../components/ForumImageUpload', () => ({
+ default: function MockForumImageUpload({
+ imagePreviews,
+ onImageChange,
+ onRemoveImage,
+ }: {
+ imageFiles: File[];
+ imagePreviews: string[];
+ onImageChange: (e: React.ChangeEvent) => void;
+ onRemoveImage: (index: number) => void;
+ }) {
+ return (
+
+ {imagePreviews.map((preview, i) => (
+
+

+
+
+ ))}
+
+
+ );
+ },
+}));
+
+vi.mock('../../components/VerificationCodeModal', () => ({
+ default: function MockVerificationModal({
+ show,
+ onHide,
+ onVerified,
+ }: {
+ show: boolean;
+ onHide: () => void;
+ email: string;
+ onVerified: () => void;
+ }) {
+ if (!show) return null;
+ return (
+
+
+
+
+ );
+ },
+}));
+
+// 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;
+const mockAddressAPI = addressAPI as jest.Mocked;
+const mockUploadImages = uploadImagesWithVariants as jest.MockedFunction<
+ typeof uploadImagesWithVariants
+>;
+
+// Helper to render component with router
+const renderWithRouter = (
+ ui: React.ReactElement,
+ { route = '/forum/create' } = {}
+) => {
+ return render(
+
+
+
+
+
+
+ );
+};
+
+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();
+
+ await waitFor(() => {
+ expect(screen.getByLabelText(/Title/)).toHaveValue('');
+ expect(screen.getByLabelText(/Content/)).toHaveValue('');
+ });
+ });
+
+ it('displays guidelines card', async () => {
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.getByText('Community Guidelines')).toBeInTheDocument();
+ });
+ });
+
+ it('shows page title for create mode', async () => {
+ renderWithRouter();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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(, { 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(, { 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(, { 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(, { 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(, { 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();
+
+ 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(, { 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();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText(/You must be logged in to create a post/)
+ ).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/frontend/src/__tests__/pages/CreateItem.test.tsx b/frontend/src/__tests__/pages/CreateItem.test.tsx
index aa34f5b..903eeb8 100644
--- a/frontend/src/__tests__/pages/CreateItem.test.tsx
+++ b/frontend/src/__tests__/pages/CreateItem.test.tsx
@@ -750,15 +750,17 @@ describe('CreateItem', () => {
});
});
- // Note: This test is skipped because the mock timing makes it unreliable in the test
- // environment. The actual behavior (not auto-saving when user has addresses) works
- // correctly in the production code. The component's userAddresses state is properly
- // set but the mock setup has timing issues with the useEffect callback.
- it.skip('does not auto-save address when user has existing addresses', async () => {
+ it('does not auto-save address when user has existing addresses', async () => {
mockedGetAddresses.mockResolvedValue({ data: [mockAddress] });
renderWithRouter();
+ // Wait for address dropdown to appear (confirms userAddresses.length > 0)
+ await waitFor(() => {
+ expect(screen.getByTestId('address-select')).toBeInTheDocument();
+ });
+
+ // Wait for auto-selection to populate the form fields
await waitFor(() => {
expect(screen.getByTestId('address1')).toHaveValue('123 Main St');
});
diff --git a/frontend/src/__tests__/pages/EarningsDashboard.test.tsx b/frontend/src/__tests__/pages/EarningsDashboard.test.tsx
new file mode 100644
index 0000000..21097b3
--- /dev/null
+++ b/frontend/src/__tests__/pages/EarningsDashboard.test.tsx
@@ -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 (
+
+ Has existing: {hasExistingAccount ? 'yes' : 'no'}
+
+
+
+ );
+ },
+}));
+
+vi.mock('../../components/EarningsStatus', () => ({
+ default: function MockEarningsStatus({
+ hasStripeAccount,
+ isOnboardingComplete,
+ payoutsEnabled,
+ onSetupClick,
+ }: {
+ hasStripeAccount: boolean;
+ isOnboardingComplete: boolean;
+ payoutsEnabled: boolean;
+ onSetupClick: () => void;
+ }) {
+ return (
+
+ {hasStripeAccount ? 'yes' : 'no'}
+
+ {isOnboardingComplete ? 'yes' : 'no'}
+
+ {payoutsEnabled ? 'yes' : 'no'}
+
+
+ );
+ },
+}));
+
+import { userAPI, stripeAPI, rentalAPI } from '../../services/api';
+
+const mockUserAPI = userAPI as jest.Mocked;
+const mockStripeAPI = stripeAPI as jest.Mocked;
+const mockRentalAPI = rentalAPI as jest.Mocked;
+
+// Helper to create mock user
+const createMockUser = (overrides: Partial = {}): 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 => ({
+ 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({ui});
+};
+
+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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ // Dates should be displayed in locale format
+ expect(screen.getByText(/2024/)).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('FAQ Links', () => {
+ it('shows FAQ links', async () => {
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText('Calculate what you can earn here')
+ ).toBeInTheDocument();
+ expect(screen.getByText('learn how payouts work')).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/frontend/src/__tests__/pages/ForumPostDetail.test.tsx b/frontend/src/__tests__/pages/ForumPostDetail.test.tsx
new file mode 100644
index 0000000..8de07e1
--- /dev/null
+++ b/frontend/src/__tests__/pages/ForumPostDetail.test.tsx
@@ -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 {category};
+ },
+}));
+
+vi.mock('../../components/PostStatusBadge', () => ({
+ default: function MockPostStatusBadge({ status }: { status: string }) {
+ return {status};
+ },
+}));
+
+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 (
+
+
+
{comment.content}
+
+
+
+ {onAdminDelete && (
+
+ )}
+ {onAdminRestore && (
+
+ )}
+
+
+ );
+ },
+}));
+
+vi.mock('../../components/CommentForm', () => ({
+ default: function MockCommentForm({
+ onSubmit,
+ disabled,
+ }: {
+ onSubmit: (content: string, images: File[]) => Promise | void;
+ disabled?: boolean;
+ }) {
+ return (
+
+ );
+ },
+}));
+
+vi.mock('../../components/AuthButton', () => ({
+ default: function MockAuthButton({ children }: { children: React.ReactNode }) {
+ return ;
+ },
+}));
+
+vi.mock('../../components/VerificationCodeModal', () => ({
+ default: function MockVerificationModal({
+ show,
+ onHide,
+ onVerified,
+ }: {
+ show: boolean;
+ onHide: () => void;
+ email: string;
+ onVerified: () => void;
+ }) {
+ if (!show) return null;
+ return (
+
+
+
+
+ );
+ },
+}));
+
+// 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;
+
+// Helper to create mock post data
+const createMockPost = (overrides: Partial = {}): 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 => ({
+ 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(
+
+
+ } />
+ Forum List} />
+
+
+ );
+};
+
+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');
+ });
+ });
+ });
+});
diff --git a/frontend/src/__tests__/pages/ForumPosts.test.tsx b/frontend/src/__tests__/pages/ForumPosts.test.tsx
new file mode 100644
index 0000000..b03a547
--- /dev/null
+++ b/frontend/src/__tests__/pages/ForumPosts.test.tsx
@@ -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 (
+
+ {post.title}
+ {post.isDeleted && Deleted}
+
+ );
+ },
+}));
+
+// Mock AuthButton
+vi.mock('../../components/AuthButton', () => ({
+ default: function MockAuthButton({
+ mode,
+ children,
+ }: {
+ mode: string;
+ children: React.ReactNode;
+ className?: string;
+ asLink?: boolean;
+ }) {
+ return ;
+ },
+}));
+
+// Mock auth context
+const mockUseAuth = vi.fn();
+vi.mock('../../contexts/AuthContext', () => ({
+ useAuth: () => mockUseAuth(),
+}));
+
+import { forumAPI } from '../../services/api';
+
+const mockForumAPI = forumAPI as jest.Mocked;
+
+// Helper to create mock post data
+const createMockPost = (overrides: Partial = {}): ForumPost => ({
+ id: Math.random().toString(36).substr(2, 9),
+ title: 'Test Post Title',
+ content: 'Test content
',
+ 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(
+ {ui}
+ );
+};
+
+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();
+
+ 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();
+
+ // 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();
+
+ // 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(screen.getByText(/Showing 1 of 15 posts/)).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Category Filtering', () => {
+ it('shows all category tabs', async () => {
+ renderWithRouter();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ // 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(screen.getByText('Create Post')).toBeInTheDocument();
+ });
+ });
+
+ it('hides Create Post button for unauthenticated users', async () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ renderWithRouter();
+
+ await waitFor(() => {
+ expect(screen.queryByText('Create Post')).not.toBeInTheDocument();
+ });
+ });
+
+ it('shows login prompt for unauthenticated users', async () => {
+ mockUseAuth.mockReturnValue({ user: null });
+
+ renderWithRouter();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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(, ['/forum?filter=deleted']);
+
+ await waitFor(() => {
+ expect(screen.getByDisplayValue('Deleted')).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/frontend/src/__tests__/pages/Messages.test.tsx b/frontend/src/__tests__/pages/Messages.test.tsx
new file mode 100644
index 0000000..92dc068
--- /dev/null
+++ b/frontend/src/__tests__/pages/Messages.test.tsx
@@ -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 (
+
+
+ {recipient.firstName} {recipient.lastName}
+
+
+
+ );
+ },
+}));
+
+// Mock Avatar component
+vi.mock('../../components/Avatar', () => ({
+ default: function MockAvatar({ user }: { user: any }) {
+ return {user?.firstName?.[0] || '?'}
;
+ },
+}));
+
+// Import mocked module
+import { messageAPI } from '../../services/api';
+
+const mockMessageAPI = messageAPI as jest.Mocked;
+
+// Helper to create mock conversations
+const createMockConversation = (
+ overrides: Partial = {}
+): 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({ui});
+};
+
+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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(screen.getByText('3')).toBeInTheDocument();
+ });
+ });
+
+ it('highlights unread conversations', async () => {
+ const conversation = createMockConversation({ unreadCount: 1 });
+
+ mockMessageAPI.getConversations.mockResolvedValue({ data: [conversation] });
+
+ renderWithRouter();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(mockOnNewMessage).toHaveBeenCalled();
+ });
+ });
+
+ it('registers onMessageRead listener on mount', async () => {
+ mockMessageAPI.getConversations.mockResolvedValue({ data: [] });
+
+ renderWithRouter();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ // Newer conversation should appear in the list
+ expect(screen.getByText(/Newer/)).toBeInTheDocument();
+ expect(screen.getByText(/Older/)).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/frontend/src/__tests__/pages/Profile.test.tsx b/frontend/src/__tests__/pages/Profile.test.tsx
new file mode 100644
index 0000000..b0a989e
--- /dev/null
+++ b/frontend/src/__tests__/pages/Profile.test.tsx
@@ -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 (
+
+
+
+ );
+ },
+}));
+
+vi.mock('../../components/Avatar', () => ({
+ default: function MockAvatar({ user }: { user: User }) {
+ return {user?.firstName?.[0] || '?'}
;
+ },
+}));
+
+vi.mock('../../components/PasswordStrengthMeter', () => ({
+ default: function MockPasswordStrengthMeter({ password }: { password: string }) {
+ return {password.length >= 8 ? 'Strong' : 'Weak'}
;
+ },
+}));
+
+vi.mock('../../components/AddressAutocomplete', () => ({
+ default: function MockAddressAutocomplete({
+ value,
+ onChange,
+ onSelect,
+ }: {
+ value: string;
+ onChange: (value: string) => void;
+ onSelect: (place: any) => void;
+ }) {
+ return (
+ onChange(e.target.value)}
+ />
+ );
+ },
+}));
+
+vi.mock('../../components/TwoFactor', () => ({
+ TwoFactorManagement: function MockTwoFactorManagement() {
+ return Two Factor Management
;
+ },
+}));
+
+vi.mock('../../components/ReviewModal', () => ({
+ default: function MockReviewModal() {
+ return Review Modal
;
+ },
+}));
+
+vi.mock('../../components/ReviewRenterModal', () => ({
+ default: function MockReviewRenterModal() {
+ return Review Renter Modal
;
+ },
+}));
+
+vi.mock('../../components/ReviewDetailsModal', () => ({
+ default: function MockReviewDetailsModal() {
+ return Review Details Modal
;
+ },
+}));
+
+vi.mock('../../components/ConditionCheckViewerModal', () => ({
+ default: function MockConditionCheckViewerModal() {
+ return Condition Check Viewer
;
+ },
+}));
+
+// 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;
+const mockItemAPI = itemAPI as jest.Mocked;
+const mockRentalAPI = rentalAPI as jest.Mocked;
+const mockAddressAPI = addressAPI as jest.Mocked;
+const mockConditionCheckAPI = conditionCheckAPI as jest.Mocked;
+
+// Helper to create mock profile data
+const createMockProfile = (overrides: Partial = {}): 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 => ({
+ 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 => ({
+ 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({ui});
+};
+
+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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(screen.getByText('John Doe')).toBeInTheDocument();
+ });
+ });
+
+ it('displays user avatar', async () => {
+ renderWithRouter();
+
+ 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();
+
+ await waitFor(() => {
+ expect(screen.getByText(/Items Listed/i)).toBeInTheDocument();
+ });
+ });
+ });
+
+ describe('Tab Navigation', () => {
+ it('displays all profile tabs', async () => {
+ renderWithRouter();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ // 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();
+
+ // 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();
+
+ // 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await waitFor(() => {
+ expect(screen.getByText('Rental History')).toBeInTheDocument();
+ });
+
+ fireEvent.click(screen.getByText('Rental History'));
+
+ await waitFor(() => {
+ expect(screen.getByText(/No rental history/i)).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/frontend/src/__tests__/pages/PublicProfile.test.tsx b/frontend/src/__tests__/pages/PublicProfile.test.tsx
new file mode 100644
index 0000000..978831c
--- /dev/null
+++ b/frontend/src/__tests__/pages/PublicProfile.test.tsx
@@ -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 (
+
+ Chat with {recipient.firstName}
+
+
+ );
+ },
+}));
+
+vi.mock('../../components/Avatar', () => ({
+ default: function MockAvatar({ user }: { user: User }) {
+ return (
+ {user?.firstName?.[0] || '?'}
+ );
+ },
+}));
+
+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 (
+
+ Ban {user.firstName}
+
+
+
+ );
+ },
+}));
+
+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 (
+
+ {title}
+
+
+
+ );
+ },
+}));
+
+// 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;
+const mockItemAPI = itemAPI as jest.Mocked;
+
+// Helper to create mock user
+const createMockUser = (overrides: Partial = {}): 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 => ({
+ 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(
+
+
+ } />
+
+
+ );
+};
+
+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();
+ });
+ });
+ });
+});
diff --git a/frontend/src/mocks/handlers.ts b/frontend/src/mocks/handlers.ts
index 2141ea4..0bc63da 100644
--- a/frontend/src/mocks/handlers.ts
+++ b/frontend/src/mocks/handlers.ts
@@ -232,3 +232,214 @@ export const createMockAvailableChecks = (rentalId: string, checkTypes: string[]
checkType,
})),
});
+
+// ============================================
+// Forum Mock Data
+// ============================================
+
+// Mock Forum Post factory
+export const createMockForumPost = (overrides: {
+ id?: string;
+ title?: string;
+ content?: string;
+ category?: 'item_request' | 'technical_support' | 'community_resources' | 'general_discussion';
+ status?: 'open' | 'answered' | 'closed';
+ authorId?: string;
+ author?: typeof mockUser;
+ tags?: Array<{ id: string; tagName: string }>;
+ comments?: any[];
+ commentCount?: number;
+ isPinned?: boolean;
+ isDeleted?: boolean;
+ deletedAt?: string;
+ acceptedAnswerId?: string;
+ zipCode?: string;
+ latitude?: number;
+ longitude?: number;
+ imageFilenames?: string[];
+ createdAt?: string;
+ updatedAt?: string;
+} = {}) => ({
+ id: overrides.id || String(Math.floor(Math.random() * 1000)),
+ title: overrides.title || 'Test Forum Post',
+ content: overrides.content || 'This is a test forum post content.',
+ category: overrides.category || 'general_discussion',
+ status: overrides.status || 'open',
+ authorId: overrides.authorId || '1',
+ author: overrides.author || mockUser,
+ tags: overrides.tags || [],
+ comments: overrides.comments || [],
+ commentCount: overrides.commentCount ?? 0,
+ isPinned: overrides.isPinned ?? false,
+ isDeleted: overrides.isDeleted ?? false,
+ deletedAt: overrides.deletedAt,
+ acceptedAnswerId: overrides.acceptedAnswerId,
+ zipCode: overrides.zipCode,
+ latitude: overrides.latitude,
+ longitude: overrides.longitude,
+ imageFilenames: overrides.imageFilenames || [],
+ createdAt: overrides.createdAt || new Date().toISOString(),
+ updatedAt: overrides.updatedAt || new Date().toISOString(),
+});
+
+// Mock Forum Comment factory
+export const createMockForumComment = (overrides: {
+ id?: string;
+ postId?: string;
+ authorId?: string;
+ author?: typeof mockUser;
+ content?: string;
+ parentId?: string;
+ replies?: any[];
+ isDeleted?: boolean;
+ deletedAt?: string;
+ imageFilenames?: string[];
+ createdAt?: string;
+ updatedAt?: string;
+} = {}) => ({
+ id: overrides.id || String(Math.floor(Math.random() * 1000)),
+ postId: overrides.postId || '1',
+ authorId: overrides.authorId || '1',
+ author: overrides.author || mockUser,
+ content: overrides.content || 'This is a test comment.',
+ parentId: overrides.parentId,
+ replies: overrides.replies || [],
+ isDeleted: overrides.isDeleted ?? false,
+ deletedAt: overrides.deletedAt,
+ imageFilenames: overrides.imageFilenames || [],
+ createdAt: overrides.createdAt || new Date().toISOString(),
+ updatedAt: overrides.updatedAt || new Date().toISOString(),
+});
+
+// Pre-made forum posts
+export const mockForumPosts = {
+ itemRequest: createMockForumPost({
+ id: '1',
+ title: 'Looking for a DSLR camera',
+ content: 'I need a DSLR camera for a weekend photoshoot.',
+ category: 'item_request',
+ zipCode: '94102',
+ tags: [{ id: '1', tagName: 'camera' }, { id: '2', tagName: 'photography' }],
+ }),
+ technicalSupport: createMockForumPost({
+ id: '2',
+ title: 'Cannot upload images',
+ content: 'I get an error when trying to upload images to my listing.',
+ category: 'technical_support',
+ tags: [{ id: '3', tagName: 'upload' }, { id: '4', tagName: 'error' }],
+ }),
+ generalDiscussion: createMockForumPost({
+ id: '3',
+ title: 'Best practices for rental pricing',
+ content: 'What are some tips for pricing items competitively?',
+ category: 'general_discussion',
+ }),
+ pinnedPost: createMockForumPost({
+ id: '4',
+ title: 'Community Guidelines',
+ content: 'Please read our community guidelines.',
+ isPinned: true,
+ author: mockAdminUser,
+ }),
+ closedPost: createMockForumPost({
+ id: '5',
+ title: 'Old discussion',
+ content: 'This discussion has been resolved.',
+ status: 'closed',
+ }),
+ deletedPost: createMockForumPost({
+ id: '6',
+ title: 'Deleted post',
+ content: 'This post was deleted.',
+ isDeleted: true,
+ deletedAt: new Date().toISOString(),
+ }),
+};
+
+// ============================================
+// Message Mock Data
+// ============================================
+
+// Mock Message factory
+export const createMockMessage = (overrides: {
+ id?: string;
+ senderId?: string;
+ receiverId?: string;
+ content?: string;
+ isRead?: boolean;
+ imageFilename?: string;
+ sender?: typeof mockUser;
+ receiver?: typeof mockUser;
+ createdAt?: string;
+} = {}) => ({
+ id: overrides.id || String(Math.floor(Math.random() * 1000)),
+ senderId: overrides.senderId || '1',
+ receiverId: overrides.receiverId || '2',
+ content: overrides.content || 'Hello, this is a test message.',
+ isRead: overrides.isRead ?? false,
+ imageFilename: overrides.imageFilename,
+ sender: overrides.sender || mockUser,
+ receiver: overrides.receiver || { ...mockUser, id: '2', firstName: 'Other', lastName: 'User' },
+ createdAt: overrides.createdAt || new Date().toISOString(),
+});
+
+// Mock Conversation factory
+export const createMockConversation = (overrides: {
+ partnerId?: string;
+ partner?: typeof mockUser;
+ lastMessage?: ReturnType;
+ 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,
+ }),
+];