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) => ( +
+ {`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 ( +
{ + e.preventDefault(); + const form = e.target as HTMLFormElement; + const input = form.elements.namedItem('comment') as HTMLInputElement; + try { + await onSubmit(input.value, []); + } catch { + // Silently catch errors - component handles them via state updates + } + }} + > + + +
+ ); + }, +})); + +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, + }), +];