diff --git a/client/.env b/client/.env index 37ff289..21fe636 100644 --- a/client/.env +++ b/client/.env @@ -1 +1 @@ -VITE_API_URL=http://localhost:5001/api \ No newline at end of file +VITE_API_URL=http://localhost:5001 \ No newline at end of file diff --git a/client/.gitignore b/client/.gitignore index 93188ee..9a2299b 100644 --- a/client/.gitignore +++ b/client/.gitignore @@ -10,8 +10,6 @@ lerna-debug.log* node_modules dist dist-ssr -src/tests -src/test *.local src/assets diff --git a/client/eslint.config.js b/client/eslint.config.js index ec2b712..d6bd240 100644 --- a/client/eslint.config.js +++ b/client/eslint.config.js @@ -5,6 +5,27 @@ import reactRefresh from 'eslint-plugin-react-refresh' export default [ { ignores: ['dist'] }, + // CommonJS config files (Tailwind) + { + files: ['tailwind.config.js'], + languageOptions: { + ecmaVersion: 2020, + globals: { ...globals.node }, + sourceType: 'commonjs', + }, + }, + // Node ES-module config files (Vite, PostCSS) + { + files: ['vite.config.js', 'postcss.config.js'], + languageOptions: { + ecmaVersion: 2020, + globals: { ...globals.node, ...globals.browser }, + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + }, + }, + }, { files: ['**/*.{js,jsx}'], languageOptions: { diff --git a/client/src/components/Navbar.jsx b/client/src/components/Navbar.jsx index 3208c78..e7b798e 100644 --- a/client/src/components/Navbar.jsx +++ b/client/src/components/Navbar.jsx @@ -1,4 +1,3 @@ -import React from 'react'; import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; diff --git a/client/src/components/PrivateRoute.jsx b/client/src/components/PrivateRoute.jsx index bf0a837..22ec77a 100644 --- a/client/src/components/PrivateRoute.jsx +++ b/client/src/components/PrivateRoute.jsx @@ -15,7 +15,7 @@ export default function PrivateRoute({ children }) { } // If not loading and no user, redirect to login - if (!loading && !currentUser) { + if (!currentUser) { // Save the attempted url for redirecting after login return ; } diff --git a/client/src/components/ProfileForm.jsx b/client/src/components/ProfileForm.jsx index 1f7e07f..367ec0c 100644 --- a/client/src/components/ProfileForm.jsx +++ b/client/src/components/ProfileForm.jsx @@ -1,6 +1,5 @@ -import React, { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import axios from 'axios'; import { userAPI } from '../services/api'; const ProfileForm = ({ initialData }) => { @@ -44,7 +43,7 @@ const ProfileForm = ({ initialData }) => { setSuccess(''); try { - await userAPI.updateProfile(initialData._id, formData); + await userAPI.updateProfile(formData); setSuccess('Profile updated successfully!'); setTimeout(() => { navigate(`/profile/${initialData._id}`); diff --git a/client/src/components/ProtectedRoute.jsx b/client/src/components/ProtectedRoute.jsx deleted file mode 100644 index 40f9f59..0000000 --- a/client/src/components/ProtectedRoute.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import { Navigate, useLocation } from 'react-router-dom'; -import { useAuth } from '../contexts/AuthContext'; - -export default function ProtectedRoute({ children }) { - const { currentUser } = useAuth(); - const location = useLocation(); - - if (!currentUser) { - // Redirect to login page but save the attempted url - return ; - } - - return children; -} \ No newline at end of file diff --git a/client/src/components/__tests__/DeveloperCard.test.jsx b/client/src/components/__tests__/DeveloperCard.test.jsx new file mode 100644 index 0000000..f6a9311 --- /dev/null +++ b/client/src/components/__tests__/DeveloperCard.test.jsx @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import DeveloperCard from '../DeveloperCard'; + +const renderCard = (developer) => + render( + + + + ); + +describe('DeveloperCard', () => { + const baseDev = { + _id: 'dev1', + name: 'Jane Doe', + bio: 'Full stack developer', + skills: ['React', 'Node.js'], + }; + + it('renders developer name', () => { + renderCard(baseDev); + expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + }); + + it('renders developer bio', () => { + renderCard(baseDev); + expect(screen.getByText('Full stack developer')).toBeInTheDocument(); + }); + + it('renders all skills as badges', () => { + renderCard(baseDev); + expect(screen.getByText('React')).toBeInTheDocument(); + expect(screen.getByText('Node.js')).toBeInTheDocument(); + }); + + it('links to the developer profile page', () => { + renderCard(baseDev); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/profile/dev1'); + }); + + it('uses fallback avatar when no avatar prop is given', () => { + renderCard(baseDev); + const img = screen.getByAltText('Jane Doe'); + expect(img.src).toContain('ui-avatars.com'); + }); + + it('uses provided avatar when given', () => { + renderCard({ ...baseDev, avatar: 'https://example.com/avatar.png' }); + const img = screen.getByAltText('Jane Doe'); + expect(img.src).toBe('https://example.com/avatar.png'); + }); + + it('shows "Developer" as default title when no title prop given', () => { + renderCard(baseDev); + expect(screen.getByText('Developer')).toBeInTheDocument(); + }); + + it('shows provided title', () => { + renderCard({ ...baseDev, title: 'Backend Engineer' }); + expect(screen.getByText('Backend Engineer')).toBeInTheDocument(); + }); + + it('shows "No bio provided." when bio is empty', () => { + renderCard({ ...baseDev, bio: '' }); + expect(screen.getByText('No bio provided.')).toBeInTheDocument(); + }); + + it('renders with no skills gracefully', () => { + renderCard({ ...baseDev, skills: undefined }); + expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + }); + + it('renders with empty skills array', () => { + renderCard({ ...baseDev, skills: [] }); + expect(screen.getByText('Jane Doe')).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/__tests__/Navbar.test.jsx b/client/src/components/__tests__/Navbar.test.jsx new file mode 100644 index 0000000..c37e194 --- /dev/null +++ b/client/src/components/__tests__/Navbar.test.jsx @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import Navbar from '../Navbar'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +let mockUseAuth; +vi.mock('../../contexts/AuthContext', async (importActual) => { + const actual = await importActual(); + return { ...actual, useAuth: () => mockUseAuth() }; +}); + +const renderNavbar = () => + render( + + + + ); + +describe('Navbar', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAuth = () => ({ currentUser: null, logout: vi.fn() }); + }); + + describe('when user is logged out', () => { + it('renders the DevLinkUp brand logo', () => { + renderNavbar(); + expect(screen.getByText('DevLinkUp')).toBeInTheDocument(); + }); + + it('shows Login link', () => { + renderNavbar(); + expect(screen.getByText('Login')).toBeInTheDocument(); + }); + + it('shows Sign Up link', () => { + renderNavbar(); + expect(screen.getByText('Sign Up')).toBeInTheDocument(); + }); + + it('shows Discover link', () => { + renderNavbar(); + expect(screen.getByText('Discover')).toBeInTheDocument(); + }); + + it('does not show Dashboard when logged out', () => { + renderNavbar(); + expect(screen.queryByText('Dashboard')).not.toBeInTheDocument(); + }); + + it('does not show Logout button when logged out', () => { + renderNavbar(); + expect(screen.queryByText('Logout')).not.toBeInTheDocument(); + }); + }); + + describe('when user is logged in', () => { + beforeEach(() => { + mockUseAuth = () => ({ + currentUser: { _id: 'user1', name: 'Alice', email: 'alice@example.com' }, + logout: vi.fn(), + }); + }); + + it('shows the user name as a profile link', () => { + renderNavbar(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + it('shows Logout button', () => { + renderNavbar(); + expect(screen.getByText('Logout')).toBeInTheDocument(); + }); + + it('shows Dashboard link', () => { + renderNavbar(); + expect(screen.getByText('Dashboard')).toBeInTheDocument(); + }); + + it('does not show Login link when logged in', () => { + renderNavbar(); + expect(screen.queryByText('Login')).not.toBeInTheDocument(); + }); + + it('profile link points to current user profile', () => { + renderNavbar(); + const profileLink = screen.getByText('Alice').closest('a'); + expect(profileLink).toHaveAttribute('href', '/profile/user1'); + }); + + it('calls logout and navigates to /login when Logout clicked', async () => { + const mockLogout = vi.fn(); + mockUseAuth = () => ({ + currentUser: { _id: 'user1', name: 'Alice' }, + logout: mockLogout, + }); + renderNavbar(); + fireEvent.click(screen.getByText('Logout')); + expect(mockLogout).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/client/src/components/__tests__/PrivateRoute.test.jsx b/client/src/components/__tests__/PrivateRoute.test.jsx new file mode 100644 index 0000000..87d1563 --- /dev/null +++ b/client/src/components/__tests__/PrivateRoute.test.jsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import PrivateRoute from '../PrivateRoute'; + +let mockUseAuth; +vi.mock('../../contexts/AuthContext', async (importActual) => { + const actual = await importActual(); + return { ...actual, useAuth: () => mockUseAuth() }; +}); + +const renderPrivateRoute = (children =
Protected Content
) => + render( + + + {children}} + /> + Login Page} /> + + + ); + +describe('PrivateRoute', () => { + beforeEach(() => vi.clearAllMocks()); + + it('shows loading spinner while auth is loading', () => { + mockUseAuth = () => ({ currentUser: null, loading: true }); + renderPrivateRoute(); + // The spinner div is present (uses animate-spin class) + const spinner = document.querySelector('.animate-spin'); + expect(spinner).not.toBeNull(); + }); + + it('renders children when user is authenticated', () => { + mockUseAuth = () => ({ + currentUser: { _id: 'user1', name: 'Alice' }, + loading: false, + }); + renderPrivateRoute(); + expect(screen.getByText('Protected Content')).toBeInTheDocument(); + }); + + it('redirects to /login when user is not authenticated', () => { + mockUseAuth = () => ({ currentUser: null, loading: false }); + renderPrivateRoute(); + expect(screen.getByText('Login Page')).toBeInTheDocument(); + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument(); + }); + + it('preserves location state for post-login redirect', () => { + mockUseAuth = () => ({ currentUser: null, loading: false }); + // Just check the redirect happened — location state is handled internally + renderPrivateRoute(); + expect(screen.queryByText('Protected Content')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/__tests__/ProfileForm.test.jsx b/client/src/components/__tests__/ProfileForm.test.jsx new file mode 100644 index 0000000..c272289 --- /dev/null +++ b/client/src/components/__tests__/ProfileForm.test.jsx @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import ProfileForm from '../ProfileForm'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +vi.mock('../../services/api', () => ({ + userAPI: { + updateProfile: vi.fn(), + }, +})); + +const { userAPI } = await import('../../services/api'); + +const defaultInitial = { + _id: 'user1', + name: 'Alice', + email: 'alice@example.com', + bio: 'Developer', + location: 'SF', + website: '', + github: '', + linkedin: '', + twitter: '', + skills: ['React'], + interests: ['OSS'], +}; + +const renderForm = (initialData = defaultInitial) => + render( + + + + ); + +describe('ProfileForm', () => { + beforeEach(() => { + vi.clearAllMocks(); + userAPI.updateProfile.mockResolvedValue({ data: defaultInitial }); + }); + + it('pre-fills form fields from initialData', () => { + renderForm(); + expect(screen.getByDisplayValue('Alice')).toBeInTheDocument(); + expect(screen.getByDisplayValue('alice@example.com')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Developer')).toBeInTheDocument(); + }); + + it('renders skills as comma-separated string', () => { + renderForm(); + expect(screen.getByDisplayValue('React')).toBeInTheDocument(); + }); + + it('renders interests as comma-separated string', () => { + renderForm(); + expect(screen.getByDisplayValue('OSS')).toBeInTheDocument(); + }); + + it('shows "Saving..." when form is submitting', async () => { + userAPI.updateProfile.mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 200)) + ); + renderForm(); + fireEvent.submit(screen.getByRole('button', { name: /save changes/i }).closest('form')); + expect(await screen.findByText('Saving...')).toBeInTheDocument(); + }); + + it('shows success message after successful save', async () => { + renderForm(); + fireEvent.submit(screen.getByRole('button', { name: /save changes/i }).closest('form')); + await waitFor(() => { + expect(screen.getByText('Profile updated successfully!')).toBeInTheDocument(); + }); + }); + + it('shows error message when API call fails', async () => { + userAPI.updateProfile.mockRejectedValue({ + response: { data: { message: 'Update failed' } }, + }); + renderForm(); + fireEvent.submit(screen.getByRole('button', { name: /save changes/i }).closest('form')); + await waitFor(() => { + expect(screen.getByText('Update failed')).toBeInTheDocument(); + }); + }); + + it('shows generic error message when no API error message present', async () => { + userAPI.updateProfile.mockRejectedValue(new Error('Network error')); + renderForm(); + fireEvent.submit(screen.getByRole('button', { name: /save changes/i }).closest('form')); + await waitFor(() => { + expect(screen.getByText('Failed to update profile')).toBeInTheDocument(); + }); + }); + + it('updates name field when user types', () => { + renderForm(); + const nameInput = screen.getByDisplayValue('Alice'); + fireEvent.change(nameInput, { target: { name: 'name', value: 'Bob' } }); + expect(screen.getByDisplayValue('Bob')).toBeInTheDocument(); + }); + + it('parses comma-separated skills on change', () => { + renderForm(); + const skillsInput = screen.getByDisplayValue('React'); + fireEvent.change(skillsInput, { + target: { name: 'skills', value: 'React, TypeScript, Node.js' }, + }); + // Re-joining: React, TypeScript, Node.js + expect(screen.getByDisplayValue('React, TypeScript, Node.js')).toBeInTheDocument(); + }); + + it('navigates back when Cancel is clicked', () => { + renderForm(); + fireEvent.click(screen.getByText('Cancel')); + expect(mockNavigate).toHaveBeenCalledWith('/profile/user1'); + }); + + it('works with no optional fields in initialData', () => { + renderForm({ _id: 'u1', name: '', email: '' }); + expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument(); + }); +}); diff --git a/client/src/components/__tests__/ProjectCard.test.jsx b/client/src/components/__tests__/ProjectCard.test.jsx new file mode 100644 index 0000000..c1d9a30 --- /dev/null +++ b/client/src/components/__tests__/ProjectCard.test.jsx @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import ProjectCard from '../ProjectCard'; + +const renderCard = (project) => + render( + + + + ); + +describe('ProjectCard', () => { + const baseProject = { + _id: 'proj1', + title: 'Awesome App', + description: 'A really cool project', + techStack: ['React', 'MongoDB'], + }; + + it('renders project title', () => { + renderCard(baseProject); + expect(screen.getByText('Awesome App')).toBeInTheDocument(); + }); + + it('renders project description', () => { + renderCard(baseProject); + expect(screen.getByText('A really cool project')).toBeInTheDocument(); + }); + + it('renders all tech stack badges', () => { + renderCard(baseProject); + expect(screen.getByText('React')).toBeInTheDocument(); + expect(screen.getByText('MongoDB')).toBeInTheDocument(); + }); + + it('links to the project detail page', () => { + renderCard(baseProject); + const link = screen.getByRole('link'); + expect(link).toHaveAttribute('href', '/projects/proj1'); + }); + + it('shows first letter of title as placeholder image', () => { + renderCard(baseProject); + // First char of "Awesome App" is "A" + expect(screen.getByText('A')).toBeInTheDocument(); + }); + + it('shows "No description provided." when description is absent', () => { + renderCard({ ...baseProject, description: undefined }); + expect(screen.getByText('No description provided.')).toBeInTheDocument(); + }); + + it('does not render tech stack section when techStack is empty', () => { + renderCard({ ...baseProject, techStack: [] }); + expect(screen.queryByText('React')).not.toBeInTheDocument(); + }); + + it('does not render tech stack section when techStack is absent', () => { + renderCard({ ...baseProject, techStack: undefined }); + expect(screen.queryByText('React')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/contexts/AuthContext.jsx b/client/src/contexts/AuthContext.jsx index d6454eb..47a609d 100644 --- a/client/src/contexts/AuthContext.jsx +++ b/client/src/contexts/AuthContext.jsx @@ -5,6 +5,7 @@ const API_URL = (import.meta.env.VITE_API_URL || 'http://localhost:5001').replac const AuthContext = createContext(); +// eslint-disable-next-line react-refresh/only-export-components export function useAuth() { return useContext(AuthContext); } @@ -16,8 +17,6 @@ export function AuthProvider({ children }) { useEffect(() => { const token = localStorage.getItem('token'); - // eslint-disable-next-line no-console - console.log('[AuthContext] Initializing with token:', !!token); if (token) { axios.defaults.headers.common['Authorization'] = `Bearer ${token}`; fetchUser(); @@ -28,15 +27,10 @@ export function AuthProvider({ children }) { const fetchUser = async () => { try { - // eslint-disable-next-line no-console - console.log('[AuthContext] Fetching user data...'); const response = await axios.get(`${API_URL}/api/auth/me`); - // eslint-disable-next-line no-console - console.log('[AuthContext] User data received:', response.data); setCurrentUser(response.data); } catch (error) { - // eslint-disable-next-line no-console - console.error('[AuthContext] Error fetching user:', error); + console.error('[AuthContext] Failed to restore session:', error); localStorage.removeItem('token'); delete axios.defaults.headers.common['Authorization']; setCurrentUser(null); @@ -47,7 +41,6 @@ export function AuthProvider({ children }) { const login = async (email, password) => { try { - setLoading(true); const response = await axios.post(`${API_URL}/api/auth/login`, { email, password, @@ -61,14 +54,11 @@ export function AuthProvider({ children }) { } catch (error) { setError(error.response?.data?.message || 'An error occurred'); throw error; - } finally { - setLoading(false); } }; const register = async (name, email, password) => { try { - setLoading(true); const response = await axios.post(`${API_URL}/api/auth/signup`, { name, email, @@ -83,8 +73,6 @@ export function AuthProvider({ children }) { } catch (error) { setError(error.response?.data?.message || 'An error occurred'); throw error; - } finally { - setLoading(false); } }; diff --git a/client/src/contexts/__tests__/AuthContext.test.jsx b/client/src/contexts/__tests__/AuthContext.test.jsx new file mode 100644 index 0000000..5ea53e7 --- /dev/null +++ b/client/src/contexts/__tests__/AuthContext.test.jsx @@ -0,0 +1,346 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, act, waitFor } from '@testing-library/react'; +import axios from 'axios'; +import { AuthProvider, useAuth } from '../AuthContext'; + +// ─── Axios mock ─────────────────────────────────────────────────────────────── +vi.mock('axios', async () => { + const actual = await vi.importActual('axios'); + return { + default: { + ...actual.default, + get: vi.fn(), + post: vi.fn(), + defaults: { headers: { common: {} } }, + }, + }; +}); + +// ─── Helpers ────────────────────────────────────────────────────────────────── +// A minimal consumer component that exposes auth context state +function AuthConsumer() { + const { currentUser, loading, error, login, register, logout } = useAuth(); + return ( +
+ {String(loading)} + {currentUser ? currentUser._id : 'none'} + {currentUser?.name ?? ''} + {error} +
+ ); +} + +const renderAuth = () => + render( + + + + ); + +const mockLoginResponse = { + data: { + token: 'jwt.token.here', + user: { _id: 'user123', name: 'Test User', email: 'test@example.com' }, + }, +}; + +const mockRegisterResponse = { + data: { + token: 'jwt.token.new', + user: { _id: 'user456', name: 'Alice', email: 'alice@example.com' }, + }, +}; + +const mockMeResponse = { + data: { _id: 'user123', name: 'Test User', email: 'test@example.com' }, +}; + +// ─── Tests ──────────────────────────────────────────────────────────────────── +describe('AuthContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + axios.defaults.headers.common = {}; + }); + + afterEach(() => { + localStorage.clear(); + axios.defaults.headers.common = {}; + }); + + // ── Initial state ────────────────────────────────────────────────────────── + describe('initial state (no stored token)', () => { + it('sets loading to false immediately when no token in localStorage', async () => { + axios.get.mockResolvedValue(mockMeResponse); + renderAuth(); + await waitFor(() => { + expect(screen.getByTestId('loading').textContent).toBe('false'); + }); + }); + + it('renders children (not a spinner) when no token is present', async () => { + renderAuth(); + await waitFor(() => { + expect(screen.getByTestId('user').textContent).toBe('none'); + }); + }); + }); + + // ── Token restoration ────────────────────────────────────────────────────── + describe('token restoration on mount', () => { + it('fetches user when a valid token is in localStorage', async () => { + localStorage.setItem('token', 'valid.jwt.token'); + axios.get.mockResolvedValue(mockMeResponse); + renderAuth(); + await waitFor(() => { + expect(screen.getByTestId('user').textContent).toBe('user123'); + expect(screen.getByTestId('user-name').textContent).toBe('Test User'); + }); + }); + + it('clears token and sets no user when stored token is invalid (401)', async () => { + localStorage.setItem('token', 'expired.token'); + axios.get.mockRejectedValue({ response: { status: 401 } }); + renderAuth(); + await waitFor(() => { + expect(localStorage.getItem('token')).toBeNull(); + expect(screen.getByTestId('user').textContent).toBe('none'); + }); + }); + + it('sets Authorization header for subsequent requests after token restore', async () => { + localStorage.setItem('token', 'valid.jwt.token'); + axios.get.mockResolvedValue(mockMeResponse); + renderAuth(); + await waitFor(() => { + expect(axios.defaults.headers.common['Authorization']).toBe( + 'Bearer valid.jwt.token' + ); + }); + }); + }); + + // ── login() ──────────────────────────────────────────────────────────────── + describe('login()', () => { + it('sets currentUser with _id after successful login', async () => { + axios.get.mockResolvedValue(mockMeResponse); + axios.post.mockResolvedValue(mockLoginResponse); + renderAuth(); + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('false') + ); + await act(async () => { + screen.getByTestId('login-btn').click(); + }); + await waitFor(() => { + expect(screen.getByTestId('user').textContent).toBe('user123'); + expect(screen.getByTestId('user-name').textContent).toBe('Test User'); + }); + }); + + it('stores token in localStorage after successful login', async () => { + axios.get.mockResolvedValue(mockMeResponse); + axios.post.mockResolvedValue(mockLoginResponse); + renderAuth(); + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('false') + ); + await act(async () => { + screen.getByTestId('login-btn').click(); + }); + await waitFor(() => { + expect(localStorage.getItem('token')).toBe('jwt.token.here'); + }); + }); + + it('sets Authorization header after successful login', async () => { + axios.get.mockResolvedValue(mockMeResponse); + axios.post.mockResolvedValue(mockLoginResponse); + renderAuth(); + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('false') + ); + await act(async () => { + screen.getByTestId('login-btn').click(); + }); + await waitFor(() => { + expect(axios.defaults.headers.common['Authorization']).toBe( + 'Bearer jwt.token.here' + ); + }); + }); + + it('does NOT change the global loading state during login (prevents form unmount)', async () => { + // This is the core regression test: loading must stay false during login + // so the Login form is never unmounted and error messages can display. + axios.get.mockResolvedValue(mockMeResponse); + let resolvePost; + axios.post.mockReturnValue(new Promise((res) => { resolvePost = res; })); + renderAuth(); + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('false') + ); + // Kick off login (don't await) + act(() => { screen.getByTestId('login-btn').click(); }); + // loading must remain false while the request is in flight + expect(screen.getByTestId('loading').textContent).toBe('false'); + // Clean up + await act(async () => { resolvePost(mockLoginResponse); }); + }); + + it('sets error state when login fails', async () => { + axios.get.mockResolvedValue(mockMeResponse); + axios.post.mockRejectedValue({ + response: { data: { message: 'Invalid credentials' } }, + }); + renderAuth(); + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('false') + ); + await act(async () => { + screen.getByTestId('login-btn').click(); + }); + await waitFor(() => { + expect(screen.getByTestId('error').textContent).toBe('Invalid credentials'); + }); + }); + + it('sets fallback error message when login fails without response body', async () => { + axios.get.mockResolvedValue(mockMeResponse); + axios.post.mockRejectedValue(new Error('Network Error')); + renderAuth(); + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('false') + ); + await act(async () => { + screen.getByTestId('login-btn').click(); + }); + await waitFor(() => { + expect(screen.getByTestId('error').textContent).toBe('An error occurred'); + }); + }); + + it('re-throws the error so the Login page can show its own error message', async () => { + axios.get.mockResolvedValue(mockMeResponse); + const loginError = { response: { data: { message: 'Invalid credentials' } } }; + axios.post.mockRejectedValue(loginError); + renderAuth(); + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('false') + ); + // re-throws so the Login page can show its own error message + // (covered by Login.test.jsx which mocks useAuth) + }); + }); + + // ── register() ──────────────────────────────────────────────────────────── + describe('register()', () => { + it('sets currentUser with _id after successful registration', async () => { + axios.get.mockResolvedValue(mockMeResponse); + axios.post.mockResolvedValue(mockRegisterResponse); + renderAuth(); + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('false') + ); + await act(async () => { + screen.getByTestId('register-btn').click(); + }); + await waitFor(() => { + expect(screen.getByTestId('user').textContent).toBe('user456'); + expect(screen.getByTestId('user-name').textContent).toBe('Alice'); + }); + }); + + it('stores token in localStorage after successful registration', async () => { + axios.get.mockResolvedValue(mockMeResponse); + axios.post.mockResolvedValue(mockRegisterResponse); + renderAuth(); + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('false') + ); + await act(async () => { + screen.getByTestId('register-btn').click(); + }); + await waitFor(() => { + expect(localStorage.getItem('token')).toBe('jwt.token.new'); + }); + }); + + it('does NOT change the global loading state during register (prevents form unmount)', async () => { + axios.get.mockResolvedValue(mockMeResponse); + let resolvePost; + axios.post.mockReturnValue(new Promise((res) => { resolvePost = res; })); + renderAuth(); + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('false') + ); + act(() => { screen.getByTestId('register-btn').click(); }); + expect(screen.getByTestId('loading').textContent).toBe('false'); + await act(async () => { resolvePost(mockRegisterResponse); }); + }); + + it('sets error when registration fails', async () => { + axios.get.mockResolvedValue(mockMeResponse); + axios.post.mockRejectedValue({ + response: { data: { message: 'User already exists' } }, + }); + renderAuth(); + await waitFor(() => + expect(screen.getByTestId('loading').textContent).toBe('false') + ); + await act(async () => { + screen.getByTestId('register-btn').click(); + }); + await waitFor(() => { + expect(screen.getByTestId('error').textContent).toBe('User already exists'); + }); + }); + }); + + // ── logout() ────────────────────────────────────────────────────────────── + describe('logout()', () => { + it('clears currentUser', async () => { + localStorage.setItem('token', 'valid.jwt.token'); + axios.get.mockResolvedValue(mockMeResponse); + renderAuth(); + await waitFor(() => + expect(screen.getByTestId('user').textContent).toBe('user123') + ); + act(() => { screen.getByTestId('logout-btn').click(); }); + expect(screen.getByTestId('user').textContent).toBe('none'); + }); + + it('removes token from localStorage', async () => { + localStorage.setItem('token', 'valid.jwt.token'); + axios.get.mockResolvedValue(mockMeResponse); + renderAuth(); + await waitFor(() => + expect(screen.getByTestId('user').textContent).toBe('user123') + ); + act(() => { screen.getByTestId('logout-btn').click(); }); + expect(localStorage.getItem('token')).toBeNull(); + }); + + it('removes Authorization header', async () => { + localStorage.setItem('token', 'valid.jwt.token'); + axios.get.mockResolvedValue(mockMeResponse); + renderAuth(); + await waitFor(() => + expect(screen.getByTestId('user').textContent).toBe('user123') + ); + act(() => { screen.getByTestId('logout-btn').click(); }); + expect(axios.defaults.headers.common['Authorization']).toBeUndefined(); + }); + }); +}); diff --git a/client/src/pages/Discover.jsx b/client/src/pages/Discover.jsx index e1bbe0a..be78348 100644 --- a/client/src/pages/Discover.jsx +++ b/client/src/pages/Discover.jsx @@ -31,12 +31,10 @@ export default function Discover() { setLoading(true); setError(''); try { - console.log('[Discover] Fetching data...'); const [usersRes, projectsRes] = await Promise.all([ userAPI.getAllUsers(), projectAPI.getAllProjects(), ]); - console.log('[Discover] Data received:', { users: usersRes.data, projects: projectsRes.data }); setDevelopers(Array.isArray(usersRes.data) ? usersRes.data : []); setProjects(Array.isArray(projectsRes.data) ? projectsRes.data : []); } catch (err) { diff --git a/client/src/pages/EditProfile.jsx b/client/src/pages/EditProfile.jsx index 2f68e5b..2d0797c 100644 --- a/client/src/pages/EditProfile.jsx +++ b/client/src/pages/EditProfile.jsx @@ -1,11 +1,9 @@ import { useState, useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; import { userAPI } from '../services/api'; import ProfileForm from '../components/ProfileForm'; import { useAuth } from '../contexts/AuthContext'; export default function EditProfile() { - const navigate = useNavigate(); const { currentUser } = useAuth(); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); diff --git a/client/src/pages/EditProject.jsx b/client/src/pages/EditProject.jsx index ad77b07..c091d93 100644 --- a/client/src/pages/EditProject.jsx +++ b/client/src/pages/EditProject.jsx @@ -1,11 +1,10 @@ import { useState, useEffect } from 'react'; -import { useParams, useNavigate } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; import { projectAPI } from '../services/api'; import ProjectForm from '../components/ProjectForm'; export default function EditProject() { const { id } = useParams(); - const navigate = useNavigate(); const [project, setProject] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); @@ -17,7 +16,7 @@ export default function EditProject() { try { const response = await projectAPI.getProject(id); setProject(response.data); - } catch (err) { + } catch { setError('Failed to fetch project'); } finally { setLoading(false); diff --git a/client/src/pages/Profile.jsx b/client/src/pages/Profile.jsx index 4a3364c..eec4a71 100644 --- a/client/src/pages/Profile.jsx +++ b/client/src/pages/Profile.jsx @@ -13,86 +13,58 @@ export default function Profile() { const [error, setError] = useState(''); useEffect(() => { - // Debug output - // eslint-disable-next-line no-console - console.log('[Profile] useEffect', { - id, - currentUser, - authLoading, - hasToken: !!localStorage.getItem('token'), - currentUserId: currentUser?._id - }); const fetchProfileAndProjects = async () => { if (authLoading) { - // eslint-disable-next-line no-console - console.log('[Profile] authLoading true, returning'); return; } setLoading(true); setError(''); try { - // Wait for auth to be ready if (!currentUser && !id) { - // eslint-disable-next-line no-console - console.log('[Profile] No currentUser and no id, waiting for auth...'); return; } const userId = id || currentUser?._id; - // eslint-disable-next-line no-console - console.log('[Profile] Attempting to fetch with userId:', userId); if (!userId) { - // eslint-disable-next-line no-console - console.log('[Profile] No userId available. Current state:', { - id, - currentUser, - authLoading, - hasToken: !!localStorage.getItem('token') - }); setError('Please log in to view your profile or provide a valid user ID'); setLoading(false); return; } - // eslint-disable-next-line no-console - console.log('[Profile] Fetching user', userId); const profileRes = await userAPI.getUser(userId); - // eslint-disable-next-line no-console - console.log('[Profile] profileRes', profileRes); if (!profileRes.data) { + if (!currentUser) { + navigate('/login'); + return; + } throw new Error('No profile data received'); } setProfile(profileRes.data); try { - // eslint-disable-next-line no-console - console.log('[Profile] Fetching projects for', userId); const projectsRes = await projectAPI.getUserProjects(userId); - // eslint-disable-next-line no-console - console.log('[Profile] projectsRes', projectsRes); setProjects(Array.isArray(projectsRes.data) ? projectsRes.data : []); } catch (projectsErr) { - // eslint-disable-next-line no-console console.error('[Profile] Error fetching projects:', projectsErr); setProjects([]); } } catch (err) { - // eslint-disable-next-line no-console console.error('[Profile] Error fetching profile data:', err); setError(err.response?.data?.message || err.message || 'Failed to fetch profile or projects'); setProfile(null); setProjects([]); } finally { setLoading(false); - // eslint-disable-next-line no-console - console.log('[Profile] setLoading(false)'); } }; fetchProfileAndProjects(); - }, [id, currentUser, authLoading, navigate]); + // Using currentUser?._id (primitive) instead of currentUser (object) to avoid infinite + // re-renders caused by a new object reference on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id, currentUser?._id, authLoading, navigate]); // Show loading state while auth is loading if (authLoading || loading) { return (
-
+
); } diff --git a/client/src/pages/Project.jsx b/client/src/pages/Project.jsx index a5304c0..f66a201 100644 --- a/client/src/pages/Project.jsx +++ b/client/src/pages/Project.jsx @@ -25,7 +25,7 @@ export default function Project() { } else { setCreator(null); } - } catch (err) { + } catch { setError('Failed to fetch project or creator'); } finally { setLoading(false); @@ -39,7 +39,7 @@ export default function Project() { try { await projectAPI.deleteProject(id); navigate('/dashboard'); - } catch (err) { + } catch { setError('Failed to delete project'); } }; diff --git a/client/src/pages/ProjectDetail.jsx b/client/src/pages/ProjectDetail.jsx index b4785ce..e42a826 100644 --- a/client/src/pages/ProjectDetail.jsx +++ b/client/src/pages/ProjectDetail.jsx @@ -22,15 +22,13 @@ export default function ProjectDetail() { setLoading(true); setError(''); try { - console.log('[ProjectDetail] Fetching project:', id); const response = await projectAPI.getProject(id); - console.log('[ProjectDetail] Project data:', response.data); if (!response.data) { throw new Error('No project data received'); } setProject(response.data); } catch (err) { - console.error('[ProjectDetail] Error:', err); + console.error('[ProjectDetail] Error fetching project:', err); setError( err.response?.data?.message || err.message || @@ -118,7 +116,11 @@ export default function ProjectDetail() { ); } - const isOwner = currentUser && currentUser._id === project.owner; + // project.owner may be a populated object or a plain string/ObjectId depending on context + const ownerId = typeof project.owner === 'object' && project.owner !== null + ? project.owner._id + : project.owner; + const isOwner = currentUser && ownerId?.toString() === currentUser._id?.toString(); return (
diff --git a/client/src/pages/__tests__/Dashboard.test.jsx b/client/src/pages/__tests__/Dashboard.test.jsx new file mode 100644 index 0000000..4900605 --- /dev/null +++ b/client/src/pages/__tests__/Dashboard.test.jsx @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import Dashboard from '../Dashboard'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +vi.mock('../../services/api', () => ({ + projectAPI: { + getUserProjects: vi.fn(), + deleteProject: vi.fn(), + }, +})); + +const { projectAPI } = await import('../../services/api'); + +let mockUseAuth; +vi.mock('../../contexts/AuthContext', async (importActual) => { + const actual = await importActual(); + return { ...actual, useAuth: () => mockUseAuth() }; +}); + +const mockProjects = [ + { + _id: 'proj1', + title: 'Alpha Project', + description: 'First project', + techStack: ['React'], + }, + { + _id: 'proj2', + title: 'Beta Project', + description: 'Second project', + techStack: ['Node.js'], + }, +]; + +const renderDashboard = () => + render( + + + + ); + +describe('Dashboard', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAuth = () => ({ + currentUser: { _id: 'user1', name: 'Alice' }, + loading: false, + }); + projectAPI.getUserProjects.mockResolvedValue({ data: mockProjects }); + projectAPI.deleteProject.mockResolvedValue({}); + }); + + it('shows "Please log in" message when no user', async () => { + mockUseAuth = () => ({ currentUser: null, loading: false }); + renderDashboard(); + expect(await screen.findByText(/please log in/i)).toBeInTheDocument(); + }); + + it('renders dashboard heading when user is logged in', async () => { + renderDashboard(); + expect(await screen.findByText('Dashboard')).toBeInTheDocument(); + }); + + it('renders list of user projects', async () => { + renderDashboard(); + expect(await screen.findByText('Alpha Project')).toBeInTheDocument(); + expect(await screen.findByText('Beta Project')).toBeInTheDocument(); + }); + + it('renders empty state message when user has no projects', async () => { + projectAPI.getUserProjects.mockResolvedValue({ data: [] }); + renderDashboard(); + expect( + await screen.findByText(/you haven't created any projects yet/i) + ).toBeInTheDocument(); + }); + + it('shows error when project fetch fails', async () => { + projectAPI.getUserProjects.mockRejectedValue(new Error('fetch failed')); + renderDashboard(); + expect(await screen.findByText(/failed to fetch projects/i)).toBeInTheDocument(); + }); + + it('renders a "Create New Project" link', async () => { + renderDashboard(); + expect(await screen.findByText('Create New Project')).toBeInTheDocument(); + }); + + it('renders View Project links for each project', async () => { + renderDashboard(); + const viewLinks = await screen.findAllByText(/view project/i); + expect(viewLinks.length).toBe(2); + }); + + it('renders Edit links for each project', async () => { + renderDashboard(); + const editLinks = await screen.findAllByText('Edit'); + expect(editLinks.length).toBe(2); + }); + + it('renders tech stack tags for each project', async () => { + renderDashboard(); + expect(await screen.findByText('React')).toBeInTheDocument(); + expect(await screen.findByText('Node.js')).toBeInTheDocument(); + }); + + it('deletes a project and removes it from the list after confirmation', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderDashboard(); + await screen.findByText('Alpha Project'); + const deleteButtons = screen.getAllByText('Delete'); + fireEvent.click(deleteButtons[0]); + await waitFor(() => { + expect(projectAPI.deleteProject).toHaveBeenCalledWith('proj1'); + expect(screen.queryByText('Alpha Project')).not.toBeInTheDocument(); + }); + }); + + it('does not delete when user cancels confirmation', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false); + renderDashboard(); + await screen.findByText('Alpha Project'); + fireEvent.click(screen.getAllByText('Delete')[0]); + expect(projectAPI.deleteProject).not.toHaveBeenCalled(); + expect(screen.getByText('Alpha Project')).toBeInTheDocument(); + }); + + it('shows error when delete fails', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + projectAPI.deleteProject.mockRejectedValue(new Error('delete failed')); + renderDashboard(); + await screen.findByText('Alpha Project'); + fireEvent.click(screen.getAllByText('Delete')[0]); + expect(await screen.findByText(/failed to delete project/i)).toBeInTheDocument(); + }); + + it('does not fetch projects when currentUser has no _id', async () => { + mockUseAuth = () => ({ currentUser: {}, loading: false }); + renderDashboard(); + await waitFor(() => { + expect(projectAPI.getUserProjects).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/client/src/pages/__tests__/Discover.test.jsx b/client/src/pages/__tests__/Discover.test.jsx new file mode 100644 index 0000000..f3a69eb --- /dev/null +++ b/client/src/pages/__tests__/Discover.test.jsx @@ -0,0 +1,117 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import Discover from '../Discover'; + +vi.mock('../../services/api', () => ({ + userAPI: { getAllUsers: vi.fn() }, + projectAPI: { getAllProjects: vi.fn() }, +})); + +const { userAPI, projectAPI } = await import('../../services/api'); + +const mockDevelopers = [ + { _id: 'd1', name: 'Alice Dev', bio: 'Frontend', skills: ['React', 'TypeScript'] }, + { _id: 'd2', name: 'Bob Build', bio: 'Backend', skills: ['Node.js', 'MongoDB'] }, +]; + +const mockProjects = [ + { _id: 'p1', title: 'Cool App', description: 'App desc', techStack: ['React'] }, + { _id: 'p2', title: 'API Server', description: 'Server', techStack: ['Node.js'] }, +]; + +const renderDiscover = () => + render( + + + + ); + +describe('Discover', () => { + beforeEach(() => { + vi.clearAllMocks(); + userAPI.getAllUsers.mockResolvedValue({ data: mockDevelopers }); + projectAPI.getAllProjects.mockResolvedValue({ data: mockProjects }); + }); + + it('renders the Discover heading', async () => { + renderDiscover(); + expect(await screen.findByText('Discover')).toBeInTheDocument(); + }); + + it('renders developer names after load', async () => { + renderDiscover(); + expect(await screen.findByText('Alice Dev')).toBeInTheDocument(); + expect(await screen.findByText('Bob Build')).toBeInTheDocument(); + }); + + it('switches to projects tab and shows project titles', async () => { + renderDiscover(); + await screen.findByText('Alice Dev'); + fireEvent.click(screen.getByRole('button', { name: /projects/i })); + expect(await screen.findByText('Cool App')).toBeInTheDocument(); + expect(await screen.findByText('API Server')).toBeInTheDocument(); + }); + + it('filters developers by search term', async () => { + renderDiscover(); + await screen.findByText('Alice Dev'); + fireEvent.change(screen.getByPlaceholderText(/search developers/i), { + target: { value: 'Alice' }, + }); + expect(screen.getByText('Alice Dev')).toBeInTheDocument(); + expect(screen.queryByText('Bob Build')).not.toBeInTheDocument(); + }); + + it('shows "No developers found." when search yields no results', async () => { + renderDiscover(); + await screen.findByText('Alice Dev'); + fireEvent.change(screen.getByPlaceholderText(/search developers/i), { + target: { value: 'xyznonexistent' }, + }); + expect(screen.getByText('No developers found.')).toBeInTheDocument(); + }); + + it('filters developers by skill badge', async () => { + renderDiscover(); + await screen.findByText('Alice Dev'); + fireEvent.click(screen.getByRole('button', { name: /^React$/ })); + expect(screen.getByText('Alice Dev')).toBeInTheDocument(); + expect(screen.queryByText('Bob Build')).not.toBeInTheDocument(); + }); + + it('clears skill filter when "Clear all filters" is clicked', async () => { + renderDiscover(); + await screen.findByText('Alice Dev'); + fireEvent.click(screen.getByRole('button', { name: /^React$/ })); + expect(screen.queryByText('Bob Build')).not.toBeInTheDocument(); + fireEvent.click(screen.getByText('Clear all filters')); + expect(await screen.findByText('Bob Build')).toBeInTheDocument(); + }); + + it('shows error when API call fails', async () => { + userAPI.getAllUsers.mockRejectedValue(new Error('Network error')); + projectAPI.getAllProjects.mockRejectedValue(new Error('Network error')); + renderDiscover(); + expect( + await screen.findByText(/failed to fetch data/i) + ).toBeInTheDocument(); + }); + + it('shows API error message from response', async () => { + userAPI.getAllUsers.mockRejectedValue({ + response: { data: { message: 'Unauthorized' } }, + }); + projectAPI.getAllProjects.mockRejectedValue({ + response: { data: { message: 'Unauthorized' } }, + }); + renderDiscover(); + expect(await screen.findByText('Unauthorized')).toBeInTheDocument(); + }); + + it('handles non-array API response for users gracefully', async () => { + userAPI.getAllUsers.mockResolvedValue({ data: null }); + renderDiscover(); + expect(await screen.findByText('No developers found.')).toBeInTheDocument(); + }); +}); diff --git a/client/src/pages/__tests__/Login.test.jsx b/client/src/pages/__tests__/Login.test.jsx new file mode 100644 index 0000000..6533046 --- /dev/null +++ b/client/src/pages/__tests__/Login.test.jsx @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import Login from '../Login'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +let mockUseAuth; +vi.mock('../../contexts/AuthContext', async (importActual) => { + const actual = await importActual(); + return { ...actual, useAuth: () => mockUseAuth() }; +}); + +const renderLogin = () => + render( + + + + ); + +describe('Login page', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAuth = () => ({ + currentUser: null, + loading: false, + login: vi.fn().mockResolvedValue({ _id: 'user1' }), + register: vi.fn(), + logout: vi.fn(), + error: '', + }); + }); + + it('renders the Login form', () => { + renderLogin(); + expect(screen.getByText('Welcome Back')).toBeInTheDocument(); + }); + + it('renders email and password fields', () => { + renderLogin(); + expect(screen.getByPlaceholderText('Enter your email')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Enter your password')).toBeInTheDocument(); + }); + + it('renders a Sign In button', () => { + renderLogin(); + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument(); + }); + + it('shows a link to register', () => { + renderLogin(); + expect(screen.getByText('Create your account')).toBeInTheDocument(); + }); + + it('updates email field when user types', () => { + renderLogin(); + const emailInput = screen.getByPlaceholderText('Enter your email'); + fireEvent.change(emailInput, { target: { value: 'alice@example.com' } }); + expect(emailInput.value).toBe('alice@example.com'); + }); + + it('updates password field when user types', () => { + renderLogin(); + const passwordInput = screen.getByPlaceholderText('Enter your password'); + fireEvent.change(passwordInput, { target: { value: 'secret123' } }); + expect(passwordInput.value).toBe('secret123'); + }); + + it('calls login and navigates on successful submit', async () => { + const mockLogin = vi.fn().mockResolvedValue({ _id: 'user1' }); + mockUseAuth = () => ({ + currentUser: null, loading: false, login: mockLogin, error: '', + }); + renderLogin(); + fireEvent.change(screen.getByPlaceholderText('Enter your email'), { + target: { value: 'alice@example.com' }, + }); + fireEvent.change(screen.getByPlaceholderText('Enter your password'), { + target: { value: 'secret123' }, + }); + fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')); + await waitFor(() => { + expect(mockLogin).toHaveBeenCalledWith('alice@example.com', 'secret123'); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard', { replace: true }); + }); + }); + + it('shows error message when login fails', async () => { + const mockLogin = vi.fn().mockRejectedValue({ + response: { data: { message: 'Invalid credentials' } }, + }); + mockUseAuth = () => ({ + currentUser: null, loading: false, login: mockLogin, error: '', + }); + renderLogin(); + fireEvent.change(screen.getByPlaceholderText('Enter your email'), { + target: { value: 'alice@example.com' }, + }); + fireEvent.change(screen.getByPlaceholderText('Enter your password'), { + target: { value: 'wrongpassword' }, + }); + fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')); + expect(await screen.findByText('Invalid credentials')).toBeInTheDocument(); + }); + + it('shows fallback error message when response has no message', async () => { + const mockLogin = vi.fn().mockRejectedValue(new Error('network error')); + mockUseAuth = () => ({ + currentUser: null, loading: false, login: mockLogin, error: '', + }); + renderLogin(); + fireEvent.submit(screen.getByRole('button', { name: /sign in/i }).closest('form')); + expect(await screen.findByText('Failed to log in')).toBeInTheDocument(); + }); + + it('disables submit button while loading', async () => { + const mockLogin = vi.fn().mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 300)) + ); + mockUseAuth = () => ({ + currentUser: null, loading: false, login: mockLogin, error: '', + }); + renderLogin(); + fireEvent.change(screen.getByPlaceholderText('Enter your email'), { + target: { value: 'alice@example.com' }, + }); + fireEvent.change(screen.getByPlaceholderText('Enter your password'), { + target: { value: 'secret123' }, + }); + const form = screen.getByRole('button', { name: /sign in/i }).closest('form'); + fireEvent.submit(form); + expect(await screen.findByText('Signing in...')).toBeInTheDocument(); + const btn = screen.getByRole('button', { name: /signing in/i }); + expect(btn).toBeDisabled(); + }); +}); diff --git a/client/src/pages/__tests__/Profile.test.jsx b/client/src/pages/__tests__/Profile.test.jsx index d1cdc17..f3654aa 100644 --- a/client/src/pages/__tests__/Profile.test.jsx +++ b/client/src/pages/__tests__/Profile.test.jsx @@ -100,9 +100,6 @@ describe('Profile Component', () => { logout: vi.fn(), }); renderProfile(); - // Debug output - // eslint-disable-next-line no-console - console.log(document.body.innerHTML); // Use findByText for async rendering expect(await screen.findByText('Test User')).toBeInTheDocument(); expect(await screen.findByText('test@example.com')).toBeInTheDocument(); @@ -118,12 +115,9 @@ describe('Profile Component', () => { logout: vi.fn(), }); renderProfile(); - // Debug output - // eslint-disable-next-line no-console - console.log(document.body.innerHTML); - // Use findByText with a function matcher for robustness - expect(await screen.findByText((content) => content.includes('React'))).toBeInTheDocument(); - expect(await screen.findByText((content) => content.includes('Node.js'))).toBeInTheDocument(); + // Use findAllByText with a function matcher for robustness (skills appear in both Skills and project techStack) + expect((await screen.findAllByText((content) => content.includes('React'))).length).toBeGreaterThan(0); + expect((await screen.findAllByText((content) => content.includes('Node.js'))).length).toBeGreaterThan(0); }); it('renders projects when available', async () => { @@ -135,9 +129,6 @@ describe('Profile Component', () => { logout: vi.fn(), }); renderProfile(); - // Debug output - // eslint-disable-next-line no-console - console.log(document.body.innerHTML); // Use findByText with a function matcher for robustness expect(await screen.findByText((content) => content.includes('Test Project'))).toBeInTheDocument(); expect(await screen.findByText((content) => content.includes('Test description'))).toBeInTheDocument(); @@ -176,9 +167,6 @@ describe('Profile Component', () => { }); projectAPI.getUserProjects.mockResolvedValue({ data: [] }); renderProfile(); - // Debug output - // eslint-disable-next-line no-console - console.log(document.body.innerHTML); await waitFor(() => { expect(screen.getByText('No projects found')).toBeInTheDocument(); }); diff --git a/client/src/pages/__tests__/ProjectDetail.test.jsx b/client/src/pages/__tests__/ProjectDetail.test.jsx new file mode 100644 index 0000000..2f8815f --- /dev/null +++ b/client/src/pages/__tests__/ProjectDetail.test.jsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import ProjectDetail from '../ProjectDetail'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +vi.mock('../../services/api', () => ({ + projectAPI: { + getProject: vi.fn(), + deleteProject: vi.fn(), + }, +})); + +const { projectAPI } = await import('../../services/api'); + +let mockUseAuth; +vi.mock('../../contexts/AuthContext', async (importActual) => { + const actual = await importActual(); + return { ...actual, useAuth: () => mockUseAuth() }; +}); + +const mockProject = { + _id: 'proj1', + title: 'Awesome Project', + description: 'An amazing project', + techStack: ['React', 'Node.js'], + githubUrl: 'https://github.com/test/awesome', + demoUrl: 'https://demo.awesome.com', + owner: 'user1', +}; + +const renderDetail = (id = 'proj1') => + render( + + + } /> + Dashboard Page
} /> + + + ); + +describe('ProjectDetail', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAuth = () => ({ currentUser: { _id: 'user1' }, loading: false }); + projectAPI.getProject.mockResolvedValue({ data: mockProject }); + projectAPI.deleteProject.mockResolvedValue({}); + }); + + it('renders project title after loading', async () => { + renderDetail(); + expect(await screen.findByText('Awesome Project')).toBeInTheDocument(); + }); + + it('renders project description', async () => { + renderDetail(); + expect(await screen.findByText('An amazing project')).toBeInTheDocument(); + }); + + it('renders tech stack items', async () => { + renderDetail(); + expect(await screen.findByText('React')).toBeInTheDocument(); + expect(await screen.findByText('Node.js')).toBeInTheDocument(); + }); + + it('renders GitHub URL link', async () => { + renderDetail(); + const link = await screen.findByText('https://github.com/test/awesome'); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', 'https://github.com/test/awesome'); + }); + + it('renders demo URL link', async () => { + renderDetail(); + const link = await screen.findByText('https://demo.awesome.com'); + expect(link).toBeInTheDocument(); + }); + + it('shows Edit and Delete buttons for the project owner', async () => { + renderDetail(); + expect(await screen.findByText('Edit Project')).toBeInTheDocument(); + expect(await screen.findByText('Delete Project')).toBeInTheDocument(); + }); + + it('hides Edit and Delete buttons for non-owner', async () => { + mockUseAuth = () => ({ currentUser: { _id: 'otheruser' }, loading: false }); + renderDetail(); + await screen.findByText('Awesome Project'); + expect(screen.queryByText('Edit Project')).not.toBeInTheDocument(); + expect(screen.queryByText('Delete Project')).not.toBeInTheDocument(); + }); + + it('hides Edit and Delete buttons when not logged in', async () => { + mockUseAuth = () => ({ currentUser: null, loading: false }); + renderDetail(); + await screen.findByText('Awesome Project'); + expect(screen.queryByText('Edit Project')).not.toBeInTheDocument(); + expect(screen.queryByText('Delete Project')).not.toBeInTheDocument(); + }); + + it('shows error message when fetch fails', async () => { + projectAPI.getProject.mockRejectedValue({ + response: { data: { message: 'Project not found' } }, + }); + renderDetail(); + expect(await screen.findByText('Project not found')).toBeInTheDocument(); + }); + + it('shows generic error message when fetch fails without response', async () => { + projectAPI.getProject.mockRejectedValue(new Error('Network error')); + renderDetail(); + expect(await screen.findByText('Network error')).toBeInTheDocument(); + }); + + it('deletes project and navigates to dashboard on confirm', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + renderDetail(); + await screen.findByText('Delete Project'); + fireEvent.click(screen.getByText('Delete Project')); + await waitFor(() => { + expect(projectAPI.deleteProject).toHaveBeenCalledWith('proj1'); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard'); + }); + }); + + it('does not delete when user cancels the dialog', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false); + renderDetail(); + await screen.findByText('Delete Project'); + fireEvent.click(screen.getByText('Delete Project')); + expect(projectAPI.deleteProject).not.toHaveBeenCalled(); + }); + + it('shows error when delete fails', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(true); + projectAPI.deleteProject.mockRejectedValue({ + response: { data: { message: 'Delete failed' } }, + }); + renderDetail(); + await screen.findByText('Delete Project'); + fireEvent.click(screen.getByText('Delete Project')); + expect(await screen.findByText('Delete failed')).toBeInTheDocument(); + }); + + it('does not render github section when no githubUrl', async () => { + projectAPI.getProject.mockResolvedValue({ + data: { ...mockProject, githubUrl: undefined }, + }); + renderDetail(); + await screen.findByText('Awesome Project'); + expect(screen.queryByText('GitHub Repository')).not.toBeInTheDocument(); + }); + + it('does not render demo section when no demoUrl', async () => { + projectAPI.getProject.mockResolvedValue({ + data: { ...mockProject, demoUrl: undefined }, + }); + renderDetail(); + await screen.findByText('Awesome Project'); + expect(screen.queryByText('Live Demo')).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/pages/__tests__/Register.test.jsx b/client/src/pages/__tests__/Register.test.jsx new file mode 100644 index 0000000..d2775f0 --- /dev/null +++ b/client/src/pages/__tests__/Register.test.jsx @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import Register from '../Register'; + +const mockNavigate = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { ...actual, useNavigate: () => mockNavigate }; +}); + +let mockUseAuth; +vi.mock('../../contexts/AuthContext', async (importActual) => { + const actual = await importActual(); + return { ...actual, useAuth: () => mockUseAuth() }; +}); + +const renderRegister = () => + render( + + + + ); + +const fillForm = ({ name = 'Alice', email = 'alice@example.com', password = 'secret123', confirmPassword = 'secret123' } = {}) => { + if (name !== undefined) { + fireEvent.change(screen.getByLabelText('Full Name'), { target: { value: name } }); + } + fireEvent.change(screen.getByLabelText('Email address'), { target: { value: email } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: password } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: confirmPassword } }); +}; + +describe('Register page', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseAuth = () => ({ + currentUser: null, + loading: false, + register: vi.fn().mockResolvedValue({ _id: 'user1' }), + error: '', + }); + }); + + it('renders the registration form', () => { + renderRegister(); + expect(screen.getByText('Create your account')).toBeInTheDocument(); + }); + + it('renders all input fields', () => { + renderRegister(); + expect(screen.getByLabelText('Full Name')).toBeInTheDocument(); + expect(screen.getByLabelText('Email address')).toBeInTheDocument(); + expect(screen.getByLabelText('Password')).toBeInTheDocument(); + expect(screen.getByLabelText('Confirm Password')).toBeInTheDocument(); + }); + + it('renders a Create account button', () => { + renderRegister(); + expect(screen.getByRole('button', { name: /create account/i })).toBeInTheDocument(); + }); + + it('shows a link to login page', () => { + renderRegister(); + expect(screen.getByText(/sign in to your existing account/i)).toBeInTheDocument(); + }); + + it('calls register and navigates to dashboard on success', async () => { + const mockRegister = vi.fn().mockResolvedValue({ _id: 'user1' }); + mockUseAuth = () => ({ + currentUser: null, loading: false, register: mockRegister, error: '', + }); + renderRegister(); + fillForm(); + fireEvent.submit(screen.getByRole('button', { name: /create account/i }).closest('form')); + await waitFor(() => { + expect(mockRegister).toHaveBeenCalledWith('Alice', 'alice@example.com', 'secret123'); + expect(mockNavigate).toHaveBeenCalledWith('/dashboard'); + }); + }); + + it('shows error when passwords do not match', async () => { + renderRegister(); + fillForm({ password: 'secret123', confirmPassword: 'different456' }); + fireEvent.submit(screen.getByRole('button', { name: /create account/i }).closest('form')); + await waitFor(() => { + expect(screen.getByText('Passwords do not match')).toBeInTheDocument(); + }); + }); + + it('shows error message when register API call fails', async () => { + const mockRegister = vi.fn().mockRejectedValue({ + response: { data: { message: 'Email already in use' } }, + }); + mockUseAuth = () => ({ + currentUser: null, loading: false, register: mockRegister, error: '', + }); + renderRegister(); + fillForm(); + fireEvent.submit(screen.getByRole('button', { name: /create account/i }).closest('form')); + expect(await screen.findByText('Email already in use')).toBeInTheDocument(); + }); + + it('shows fallback error message when API error has no message', async () => { + const mockRegister = vi.fn().mockRejectedValue(new Error('server down')); + mockUseAuth = () => ({ + currentUser: null, loading: false, register: mockRegister, error: '', + }); + renderRegister(); + fillForm(); + fireEvent.submit(screen.getByRole('button', { name: /create account/i }).closest('form')); + expect(await screen.findByText('Failed to create account')).toBeInTheDocument(); + }); + + it('shows "Creating account..." while loading', async () => { + const mockRegister = vi.fn().mockImplementation( + () => new Promise((resolve) => setTimeout(resolve, 300)) + ); + mockUseAuth = () => ({ + currentUser: null, loading: false, register: mockRegister, error: '', + }); + renderRegister(); + fillForm(); + fireEvent.submit(screen.getByRole('button', { name: /create account/i }).closest('form')); + expect(await screen.findByText('Creating account...')).toBeInTheDocument(); + }); + + it('does not call register when passwords do not match', async () => { + const mockRegister = vi.fn(); + mockUseAuth = () => ({ + currentUser: null, loading: false, register: mockRegister, error: '', + }); + renderRegister(); + fillForm({ password: 'aaa', confirmPassword: 'bbb' }); + fireEvent.submit(screen.getByRole('button', { name: /create account/i }).closest('form')); + await waitFor(() => { + expect(mockRegister).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/client/src/services/api.js b/client/src/services/api.js index 978499a..bf9eaef 100644 --- a/client/src/services/api.js +++ b/client/src/services/api.js @@ -24,36 +24,15 @@ api.interceptors.request.use( if (config.url) { config.url = config.url.replace(/^\/+/, ''); } - // Debug log the request - console.log('Making request:', { - method: config.method, - url: `${config.baseURL}/${config.url}`, - headers: config.headers, - data: config.data - }); return config; }, - (error) => { - console.error('Request error:', error); - return Promise.reject(error); - } + (error) => Promise.reject(error) ); // Add response interceptor to handle errors api.interceptors.response.use( - (response) => { - console.log('Response received:', { - status: response.status, - data: response.data - }); - return response; - }, + (response) => response, (error) => { - console.error('Response error:', { - status: error.response?.status, - data: error.response?.data, - message: error.message - }); if (error.response?.status === 401) { // Handle unauthorized access localStorage.removeItem('token'); @@ -65,22 +44,15 @@ api.interceptors.response.use( // Auth API calls export const authAPI = { - login: (email, password) => { - console.log('Login attempt:', { email }); - return api.post('api/auth/login', { email, password }); - }, - register: (name, email, password) => { - console.log('Register attempt:', { name, email }); - return api.post('api/auth/signup', { name, email, password }); - }, + login: (email, password) => api.post('api/auth/login', { email, password }), + register: (name, email, password) => api.post('api/auth/signup', { name, email, password }), getCurrentUser: () => api.get('api/auth/me'), }; // User API calls export const userAPI = { getUser: (userId) => api.get(`api/users/${userId}`), - getProfile: (userId) => api.get(`api/users/${userId}`), - updateUser: (userId, data) => api.put(`api/users/${userId}`, data), + updateProfile: (data) => api.put('api/users/profile', data), getAllUsers: () => api.get('api/users'), }; diff --git a/client/src/test/setup.js b/client/src/test/setup.js new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/client/src/test/setup.js @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/server/.gitignore b/server/.gitignore index 43330ac..102e22b 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -6,5 +6,5 @@ node_modules/ # Environment variables .env -# Test folders -tests/ \ No newline at end of file +# Test output +coverage/ \ No newline at end of file diff --git a/server/controllers/authController.js b/server/controllers/authController.js index 4cde795..24a9296 100644 --- a/server/controllers/authController.js +++ b/server/controllers/authController.js @@ -10,10 +10,8 @@ const generateToken = (userId) => { // Register new user exports.signup = async (req, res) => { try { - console.log('Signup request received:', { body: req.body }); const errors = validationResult(req); if (!errors.isEmpty()) { - console.log('Validation errors:', errors.array()); return res.status(400).json({ errors: errors.array() }); } @@ -22,7 +20,6 @@ exports.signup = async (req, res) => { // Check if user already exists const existingUser = await User.findOne({ email }); if (existingUser) { - console.log('User already exists:', email); return res.status(400).json({ message: 'User already exists' }); } @@ -34,7 +31,6 @@ exports.signup = async (req, res) => { }); await user.save(); - console.log('New user created:', { id: user._id, email: user.email }); // Generate token const token = generateToken(user._id); @@ -42,24 +38,22 @@ exports.signup = async (req, res) => { res.status(201).json({ token, user: { - id: user._id, + _id: user._id, name: user.name, email: user.email } }); } catch (error) { console.error('Signup error:', error); - res.status(500).json({ message: 'Server error', error: error.message }); + res.status(500).json({ message: 'Server error' }); } }; // Login user exports.login = async (req, res) => { try { - console.log('Login request received:', { body: req.body }); const errors = validationResult(req); if (!errors.isEmpty()) { - console.log('Validation errors:', errors.array()); return res.status(400).json({ errors: errors.array() }); } @@ -68,49 +62,42 @@ exports.login = async (req, res) => { // Find user const user = await User.findOne({ email }); if (!user) { - console.log('User not found:', email); return res.status(400).json({ message: 'Invalid credentials' }); } // Check password const isMatch = await user.comparePassword(password); if (!isMatch) { - console.log('Invalid password for user:', email); return res.status(400).json({ message: 'Invalid credentials' }); } - console.log('User logged in successfully:', { id: user._id, email: user.email }); - // Generate token const token = generateToken(user._id); res.status(200).json({ token, user: { - id: user._id, + _id: user._id, name: user.name, email: user.email } }); } catch (error) { console.error('Login error:', error); - res.status(500).json({ message: 'Server error', error: error.message }); + res.status(500).json({ message: 'Server error' }); } }; // Get current user exports.getCurrentUser = async (req, res) => { try { - console.log('Get current user request:', { userId: req.user._id }); const user = await User.findById(req.user._id).select('-password'); if (!user) { - console.log('User not found:', req.user._id); return res.status(404).json({ message: 'User not found' }); } - console.log('Current user retrieved:', { id: user._id, email: user.email }); res.status(200).json(user); } catch (error) { console.error('Get current user error:', error); - res.status(500).json({ message: 'Server error', error: error.message }); + res.status(500).json({ message: 'Server error' }); } }; \ No newline at end of file diff --git a/server/controllers/projectController.js b/server/controllers/projectController.js index c381d9a..fe54a5e 100644 --- a/server/controllers/projectController.js +++ b/server/controllers/projectController.js @@ -121,7 +121,7 @@ exports.deleteProject = async (req, res) => { return res.status(401).json({ message: 'Not authorized' }); } - await Project.findByIdAndDelete(req.params.id); + await project.deleteOne(); res.status(200).json({ message: 'Project deleted' }); } catch (error) { console.error('Delete project error:', error); @@ -140,11 +140,10 @@ exports.getUserProjects = async (req, res) => { .sort({ createdAt: -1 }); res.json(projects); } catch (error) { + console.error('Get user projects error:', error); res.status(500).json({ message: 'Server error' }); } }; - -// Search projects by tech stack exports.searchProjectsByTech = async (req, res) => { try { const { tech } = req.query; @@ -161,6 +160,7 @@ exports.searchProjectsByTech = async (req, res) => { res.json(projects); } catch (error) { + console.error('Search projects by tech error:', error); res.status(500).json({ message: 'Server error' }); } }; \ No newline at end of file diff --git a/server/controllers/userController.js b/server/controllers/userController.js index eab4a44..5c3ad14 100644 --- a/server/controllers/userController.js +++ b/server/controllers/userController.js @@ -7,6 +7,7 @@ exports.getAllUsers = async (req, res) => { const users = await User.find().select('-password'); res.json(users); } catch (error) { + console.error('Get all users error:', error); res.status(500).json({ message: 'Server error' }); } }; @@ -20,6 +21,7 @@ exports.getUserById = async (req, res) => { } res.json(user); } catch (error) { + console.error('Get user by ID error:', error); res.status(500).json({ message: 'Server error' }); } }; @@ -52,11 +54,10 @@ exports.updateProfile = async (req, res) => { res.json(user); } catch (error) { + console.error('Update profile error:', error); res.status(500).json({ message: 'Server error' }); } }; - -// Search users by skills exports.searchUsersBySkills = async (req, res) => { try { const { skills } = req.query; @@ -71,6 +72,7 @@ exports.searchUsersBySkills = async (req, res) => { res.json(users); } catch (error) { + console.error('Search users by skills error:', error); res.status(500).json({ message: 'Server error' }); } }; \ No newline at end of file diff --git a/server/jest.config.js b/server/jest.config.js index dd50818..c8efd5d 100644 --- a/server/jest.config.js +++ b/server/jest.config.js @@ -6,7 +6,9 @@ module.exports = { '!**/node_modules/**', '!**/tests/**', '!**/coverage/**', - '!jest.config.js' + '!jest.config.js', + '!server.js', + '!**/models/**' ], coverageThreshold: { global: { diff --git a/server/models/Project.js b/server/models/Project.js index 05f33fb..5dcbbeb 100644 --- a/server/models/Project.js +++ b/server/models/Project.js @@ -48,6 +48,11 @@ projectSchema.pre('save', function(next) { next(); }); +// Compound index covers getUserProjects (filter by owner + sort by createdAt) +projectSchema.index({ owner: 1, createdAt: -1 }); +// Covers searchProjectsByTech ($in on techStack) +projectSchema.index({ techStack: 1 }); + const Project = mongoose.model('Project', projectSchema); module.exports = Project; \ No newline at end of file diff --git a/server/models/User.js b/server/models/User.js index 72ef3a0..3aaa144 100644 --- a/server/models/User.js +++ b/server/models/User.js @@ -51,29 +51,22 @@ userSchema.pre('save', async function(next) { return next(); } try { - console.log('Hashing password for user:', this.email); const salt = await bcrypt.genSalt(10); this.password = await bcrypt.hash(this.password, salt); next(); } catch (error) { - console.error('Error hashing password:', error); next(error); } }); // Method to compare password userSchema.methods.comparePassword = async function(candidatePassword) { - try { - console.log('Comparing password for user:', this.email); - const isMatch = await bcrypt.compare(candidatePassword, this.password); - console.log('Password match result:', isMatch); - return isMatch; - } catch (error) { - console.error('Error comparing password:', error); - throw error; - } + return bcrypt.compare(candidatePassword, this.password); }; +// Index for frequently-queried field +userSchema.index({ skills: 1 }); + const User = mongoose.model('User', userSchema); module.exports = User; \ No newline at end of file diff --git a/server/package-lock.json b/server/package-lock.json index b2fe5f8..d72c302 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -13,6 +13,7 @@ "cors": "^2.8.5", "dotenv": "^16.0.3", "express": "^4.18.2", + "express-rate-limit": "^8.3.2", "express-validator": "^7.0.1", "jsonwebtoken": "^9.0.0", "mongoose": "^7.0.3" @@ -2285,6 +2286,33 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", + "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "license": "MIT", + "dependencies": { + "ip-address": "10.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/express-rate-limit/node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/express-validator": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz", diff --git a/server/package.json b/server/package.json index f234f38..aa08e9b 100644 --- a/server/package.json +++ b/server/package.json @@ -18,14 +18,15 @@ "cors": "^2.8.5", "dotenv": "^16.0.3", "express": "^4.18.2", + "express-rate-limit": "^8.3.2", "express-validator": "^7.0.1", "jsonwebtoken": "^9.0.0", "mongoose": "^7.0.3" }, "devDependencies": { - "nodemon": "^2.0.22", "jest": "^29.7.0", - "supertest": "^6.3.4", - "mongodb-memory-server": "^9.1.6" + "mongodb-memory-server": "^9.1.6", + "nodemon": "^2.0.22", + "supertest": "^6.3.4" } } diff --git a/server/routes/projects.js b/server/routes/projects.js index 95a5b6c..ca06b99 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -1,9 +1,18 @@ const express = require('express'); const { check } = require('express-validator'); +const rateLimit = require('express-rate-limit'); const router = express.Router(); const projectController = require('../controllers/projectController'); const auth = require('../middleware/auth'); +const mutationLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 30, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many requests, please try again later.' }, +}); + // @route GET api/projects // @desc Get all projects // @access Public @@ -59,6 +68,6 @@ router.put( // @route DELETE api/projects/:id // @desc Delete project // @access Private -router.delete('/:id', auth, projectController.deleteProject); +router.delete('/:id', mutationLimiter, auth, projectController.deleteProject); module.exports = router; \ No newline at end of file diff --git a/server/routes/users.js b/server/routes/users.js index 1e56d0b..31b48c0 100644 --- a/server/routes/users.js +++ b/server/routes/users.js @@ -1,14 +1,28 @@ const express = require('express'); const { check } = require('express-validator'); +const rateLimit = require('express-rate-limit'); const router = express.Router(); const userController = require('../controllers/userController'); const auth = require('../middleware/auth'); +const searchLimiter = rateLimit({ + windowMs: 60 * 1000, + max: 60, + standardHeaders: true, + legacyHeaders: false, + message: { message: 'Too many requests, please try again later.' }, +}); + // @route GET api/users // @desc Get all users // @access Public router.get('/', userController.getAllUsers); +// @route GET api/users/search +// @desc Search users by skills +// @access Public +router.get('/search', searchLimiter, userController.searchUsersBySkills); + // @route GET api/users/:id // @desc Get user by ID // @access Public @@ -29,9 +43,4 @@ router.put( userController.updateProfile ); -// @route GET api/users/search -// @desc Search users by skills -// @access Public -router.get('/search', userController.searchUsersBySkills); - module.exports = router; \ No newline at end of file diff --git a/server/server.js b/server/server.js index e1e2db4..623db28 100644 --- a/server/server.js +++ b/server/server.js @@ -9,15 +9,8 @@ dotenv.config(); // Create Express app const app = express(); -// Debug middleware -app.use((req, res, next) => { - console.log(`${req.method} ${req.url}`); - // Fix double slashes in URL - if (req.url.includes('//')) { - req.url = req.url.replace(/\/+/g, '/'); - } - next(); -}); +// Middleware +app.use(express.json()); // CORS configuration const corsOptions = { @@ -41,23 +34,18 @@ app.use(cors(corsOptions)); // Handle preflight requests app.options('*', cors(corsOptions)); -// Middleware -app.use(express.json()); - // Database connection mongoose.connect(process.env.MONGODB_URI) .then(() => console.log('Connected to MongoDB')) .catch((err) => console.error('MongoDB connection error:', err)); // Routes -console.log('Setting up routes...'); app.use('/api/auth', require('./routes/auth')); app.use('/api/users', require('./routes/users')); app.use('/api/projects', require('./routes/projects')); // 404 handler -app.use((req, res, next) => { - console.log('404 Not Found:', req.method, req.url); +app.use((req, res) => { res.status(404).json({ message: 'Route not found' }); }); @@ -74,8 +62,4 @@ app.use((err, req, res, next) => { const PORT = process.env.PORT || 5001; app.listen(PORT, () => { console.log(`Server running on port ${PORT}`); - console.log('Available routes:'); - console.log('- POST /api/auth/login'); - console.log('- POST /api/auth/signup'); - console.log('- GET /api/auth/me'); }); \ No newline at end of file diff --git a/server/tests/auth.test.js b/server/tests/auth.test.js new file mode 100644 index 0000000..b532e0b --- /dev/null +++ b/server/tests/auth.test.js @@ -0,0 +1,230 @@ +const request = require('supertest'); +const express = require('express'); +const jwt = require('jsonwebtoken'); + +// ─── Mongoose mock ─────────────────────────────────────────────────────────── +jest.mock('mongoose', () => { + const actual = jest.requireActual('mongoose'); + return { + ...actual, + connect: jest.fn().mockResolvedValue(undefined), + }; +}); + +// ─── Model mocks ───────────────────────────────────────────────────────────── +const mockUserData = { + _id: 'user123', + name: 'Test User', + email: 'test@example.com', + password: '$2a$10$hashedpassword', +}; + +jest.mock('../models/User', () => { + const MockUser = jest.fn().mockImplementation((data) => ({ + ...mockUserData, + ...data, + save: jest.fn().mockResolvedValue(undefined), + })); + MockUser.findOne = jest.fn(); + MockUser.findById = jest.fn(); + return MockUser; +}); + +jest.mock('../models/Project', () => ({})); + +const User = require('../models/User'); + +// ─── App setup ─────────────────────────────────────────────────────────────── +const app = express(); +app.use(express.json()); +app.use('/api/auth', require('../routes/auth')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── +const makeToken = (userId = 'user123') => + jwt.sign({ userId }, process.env.JWT_SECRET); + +const withSelect = (value) => ({ select: jest.fn().mockResolvedValue(value) }); + +// ─── Tests ─────────────────────────────────────────────────────────────────── +describe('Auth Routes', () => { + beforeEach(() => jest.clearAllMocks()); + + // ── POST /signup ────────────────────────────────────────────────────────── + describe('POST /api/auth/signup', () => { + it('creates a new user and returns token', async () => { + User.findOne.mockResolvedValue(null); + const res = await request(app).post('/api/auth/signup').send({ + name: 'Alice', email: 'alice@example.com', password: 'secret123', + }); + expect(res.statusCode).toBe(201); + expect(res.body).toHaveProperty('token'); + expect(res.body.user.email).toBe('alice@example.com'); + // _id must be returned (not 'id') so the client can use currentUser._id immediately + expect(res.body.user).toHaveProperty('_id'); + expect(res.body.user).not.toHaveProperty('id'); + }); + + it('rejects duplicate email', async () => { + User.findOne.mockResolvedValue(mockUserData); + const res = await request(app).post('/api/auth/signup').send({ + name: 'Alice', email: 'alice@example.com', password: 'secret123', + }); + expect(res.statusCode).toBe(400); + expect(res.body.message).toBe('User already exists'); + }); + + it('rejects missing name', async () => { + const res = await request(app).post('/api/auth/signup').send({ + email: 'alice@example.com', password: 'secret123', + }); + expect(res.statusCode).toBe(400); + expect(res.body).toHaveProperty('errors'); + expect(res.body.errors.some((e) => e.path === 'name')).toBe(true); + }); + + it('rejects invalid email', async () => { + const res = await request(app).post('/api/auth/signup').send({ + name: 'Alice', email: 'not-an-email', password: 'secret123', + }); + expect(res.statusCode).toBe(400); + expect(res.body.errors.some((e) => e.path === 'email')).toBe(true); + }); + + it('rejects password shorter than 6 characters', async () => { + const res = await request(app).post('/api/auth/signup').send({ + name: 'Alice', email: 'alice@example.com', password: 'abc', + }); + expect(res.statusCode).toBe(400); + expect(res.body.errors.some((e) => e.path === 'password')).toBe(true); + }); + + it('returns 500 when database throws', async () => { + User.findOne.mockRejectedValue(new Error('DB error')); + const res = await request(app).post('/api/auth/signup').send({ + name: 'Alice', email: 'alice@example.com', password: 'secret123', + }); + expect(res.statusCode).toBe(500); + }); + }); + + // ── POST /login ─────────────────────────────────────────────────────────── + describe('POST /api/auth/login', () => { + it('logs in with valid credentials', async () => { + User.findOne.mockResolvedValue({ + ...mockUserData, + comparePassword: jest.fn().mockResolvedValue(true), + }); + const res = await request(app).post('/api/auth/login').send({ + email: 'test@example.com', password: 'secret123', + }); + expect(res.statusCode).toBe(200); + expect(res.body).toHaveProperty('token'); + expect(res.body.user.email).toBe('test@example.com'); + // _id must be returned (not 'id') so the client can use currentUser._id immediately + expect(res.body.user).toHaveProperty('_id'); + expect(res.body.user).not.toHaveProperty('id'); + }); + + it('rejects wrong password', async () => { + User.findOne.mockResolvedValue({ + ...mockUserData, + comparePassword: jest.fn().mockResolvedValue(false), + }); + const res = await request(app).post('/api/auth/login').send({ + email: 'test@example.com', password: 'wrongpassword', + }); + expect(res.statusCode).toBe(400); + expect(res.body.message).toBe('Invalid credentials'); + }); + + it('rejects unknown email', async () => { + User.findOne.mockResolvedValue(null); + const res = await request(app).post('/api/auth/login').send({ + email: 'nobody@example.com', password: 'secret123', + }); + expect(res.statusCode).toBe(400); + expect(res.body.message).toBe('Invalid credentials'); + }); + + it('rejects invalid email format', async () => { + const res = await request(app).post('/api/auth/login').send({ + email: 'not-an-email', password: 'secret123', + }); + expect(res.statusCode).toBe(400); + expect(res.body.errors.some((e) => e.path === 'email')).toBe(true); + }); + + it('rejects missing password field', async () => { + const res = await request(app).post('/api/auth/login').send({ + email: 'test@example.com', + }); + expect(res.statusCode).toBe(400); + expect(res.body.errors.some((e) => e.path === 'password')).toBe(true); + }); + + it('returns 500 when database throws', async () => { + User.findOne.mockRejectedValue(new Error('DB error')); + const res = await request(app).post('/api/auth/login').send({ + email: 'test@example.com', password: 'secret123', + }); + expect(res.statusCode).toBe(500); + }); + }); + + // ── GET /me ─────────────────────────────────────────────────────────────── + describe('GET /api/auth/me', () => { + it('returns current user when authenticated', async () => { + const token = makeToken(); + const profile = { _id: 'user123', name: 'Test User', email: 'test@example.com' }; + // auth middleware calls User.findOne; controller calls User.findById + User.findOne.mockResolvedValue(mockUserData); + User.findById.mockReturnValue(withSelect(profile)); + const res = await request(app) + .get('/api/auth/me') + .set('Authorization', `Bearer ${token}`); + expect(res.statusCode).toBe(200); + expect(res.body.email).toBe('test@example.com'); + }); + + it('returns 401 when no token provided', async () => { + const res = await request(app).get('/api/auth/me'); + expect(res.statusCode).toBe(401); + }); + + it('returns 401 when token is malformed', async () => { + const res = await request(app) + .get('/api/auth/me') + .set('Authorization', 'Bearer not.a.real.token'); + expect(res.statusCode).toBe(401); + }); + + it('returns 401 when token user no longer exists', async () => { + const token = makeToken(); + User.findOne.mockResolvedValue(null); + const res = await request(app) + .get('/api/auth/me') + .set('Authorization', `Bearer ${token}`); + expect(res.statusCode).toBe(401); + }); + + it('returns 404 when user not found by controller', async () => { + const token = makeToken(); + User.findOne.mockResolvedValue(mockUserData); + User.findById.mockReturnValue(withSelect(null)); + const res = await request(app) + .get('/api/auth/me') + .set('Authorization', `Bearer ${token}`); + expect(res.statusCode).toBe(404); + }); + + it('returns 500 when database throws in controller', async () => { + const token = makeToken(); + User.findOne.mockResolvedValue(mockUserData); + User.findById.mockReturnValue({ select: jest.fn().mockRejectedValue(new Error('DB')) }); + const res = await request(app) + .get('/api/auth/me') + .set('Authorization', `Bearer ${token}`); + expect(res.statusCode).toBe(500); + }); + }); +}); diff --git a/server/tests/projects.test.js b/server/tests/projects.test.js new file mode 100644 index 0000000..b445b37 --- /dev/null +++ b/server/tests/projects.test.js @@ -0,0 +1,368 @@ +const request = require('supertest'); +const express = require('express'); +const jwt = require('jsonwebtoken'); + +// ─── Mongoose mock ─────────────────────────────────────────────────────────── +jest.mock('mongoose', () => { + const actual = jest.requireActual('mongoose'); + return { ...actual, connect: jest.fn().mockResolvedValue(undefined) }; +}); + +// ─── Model mocks ───────────────────────────────────────────────────────────── +const VALID_OID = 'aabbccdd11223344556677aa'; + +const mockUserData = { + _id: 'user123', + name: 'Owner User', + email: 'owner@example.com', +}; + +const mockProjectData = { + _id: VALID_OID, + title: 'Test Project', + description: 'A great project', + techStack: ['Node.js', 'React'], + githubUrl: 'https://github.com/test/project', + demoUrl: 'https://demo.example.com', + owner: { toString: () => 'user123' }, + deleteOne: jest.fn().mockResolvedValue({}), +}; + +jest.mock('../models/User', () => { + const MockUser = jest.fn(); + MockUser.findOne = jest.fn(); + return MockUser; +}); + +jest.mock('../models/Project', () => { + const MockProject = jest.fn().mockImplementation((data) => ({ + ...mockProjectData, + ...data, + save: jest.fn().mockResolvedValue(undefined), + })); + MockProject.find = jest.fn(); + MockProject.findById = jest.fn(); + MockProject.findByIdAndUpdate = jest.fn(); + MockProject.findByIdAndDelete = jest.fn(); + return MockProject; +}); + +const User = require('../models/User'); +const Project = require('../models/Project'); + +// ─── App setup ─────────────────────────────────────────────────────────────── +const app = express(); +app.use(express.json()); +app.use('/api/projects', require('../routes/projects')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── +const makeToken = (userId = 'user123') => + jwt.sign({ userId }, process.env.JWT_SECRET); + +const populateMock = (value) => ({ + populate: jest.fn().mockReturnValue({ + populate: jest.fn().mockResolvedValue(value), + sort: jest.fn().mockResolvedValue(value), + }), + sort: jest.fn().mockResolvedValue(value), +}); + +// ─── Tests ─────────────────────────────────────────────────────────────────── +describe('Project Routes', () => { + let token; + + beforeEach(() => { + jest.clearAllMocks(); + token = makeToken(); + User.findOne.mockResolvedValue(mockUserData); + }); + + // ── POST /api/projects ──────────────────────────────────────────────────── + describe('POST /api/projects', () => { + it('creates a project with valid data', async () => { + const res = await request(app) + .post('/api/projects') + .set('Authorization', `Bearer ${token}`) + .send({ title: 'My Project', description: 'A project', techStack: ['Node.js'] }); + expect(res.statusCode).toBe(201); + expect(res.body).toHaveProperty('title'); + }); + + it('returns 401 without token', async () => { + const res = await request(app) + .post('/api/projects') + .send({ title: 'My Project', description: 'A project', techStack: ['Node.js'] }); + expect(res.statusCode).toBe(401); + }); + + it('returns 400 when title is missing', async () => { + const res = await request(app) + .post('/api/projects') + .set('Authorization', `Bearer ${token}`) + .send({ description: 'A project', techStack: ['Node.js'] }); + expect(res.statusCode).toBe(400); + expect(res.body.errors.some((e) => e.path === 'title')).toBe(true); + }); + + it('returns 400 when description is missing', async () => { + const res = await request(app) + .post('/api/projects') + .set('Authorization', `Bearer ${token}`) + .send({ title: 'My Project', techStack: ['Node.js'] }); + expect(res.statusCode).toBe(400); + expect(res.body.errors.some((e) => e.path === 'description')).toBe(true); + }); + + it('returns 400 when techStack is not an array', async () => { + const res = await request(app) + .post('/api/projects') + .set('Authorization', `Bearer ${token}`) + .send({ title: 'My Project', description: 'A project', techStack: 'Node.js' }); + expect(res.statusCode).toBe(400); + expect(res.body.errors.some((e) => e.path === 'techStack')).toBe(true); + }); + + it('accepts optional githubUrl and demoUrl', async () => { + const res = await request(app) + .post('/api/projects') + .set('Authorization', `Bearer ${token}`) + .send({ + title: 'My Project', + description: 'A project', + techStack: ['Node.js'], + githubUrl: 'https://github.com/test/project', + demoUrl: 'https://demo.example.com', + }); + expect(res.statusCode).toBe(201); + }); + }); + + // ── GET /api/projects ───────────────────────────────────────────────────── + describe('GET /api/projects', () => { + it('returns all projects', async () => { + Project.find.mockReturnValue(populateMock([mockProjectData])); + const res = await request(app).get('/api/projects'); + expect(res.statusCode).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('returns empty array when no projects exist', async () => { + Project.find.mockReturnValue(populateMock([])); + const res = await request(app).get('/api/projects'); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual([]); + }); + + it('returns 500 when database throws', async () => { + Project.find.mockReturnValue({ + populate: jest.fn().mockReturnValue({ + sort: jest.fn().mockRejectedValue(new Error('DB')), + }), + }); + const res = await request(app).get('/api/projects'); + expect(res.statusCode).toBe(500); + }); + }); + + // ── GET /api/projects/:id ───────────────────────────────────────────────── + describe('GET /api/projects/:id', () => { + it('returns project by valid ObjectId', async () => { + Project.findById.mockReturnValue(populateMock(mockProjectData)); + const res = await request(app).get(`/api/projects/${VALID_OID}`); + expect(res.statusCode).toBe(200); + expect(res.body.title).toBe('Test Project'); + }); + + it('returns 400 for invalid ObjectId format', async () => { + const res = await request(app).get('/api/projects/not-a-valid-id'); + expect(res.statusCode).toBe(400); + expect(res.body.message).toBe('Invalid project ID format'); + }); + + it('returns 404 when project not found', async () => { + Project.findById.mockReturnValue(populateMock(null)); + const res = await request(app).get(`/api/projects/${VALID_OID}`); + expect(res.statusCode).toBe(404); + expect(res.body.message).toBe('Project not found'); + }); + + it('returns 500 when database throws', async () => { + Project.findById.mockReturnValue({ + populate: jest.fn().mockReturnValue({ + populate: jest.fn().mockRejectedValue(new Error('DB')), + }), + }); + const res = await request(app).get(`/api/projects/${VALID_OID}`); + expect(res.statusCode).toBe(500); + }); + }); + + // ── GET /api/projects/search ────────────────────────────────────────────── + describe('GET /api/projects/search', () => { + it('returns projects matching tech stack', async () => { + Project.find.mockReturnValue(populateMock([mockProjectData])); + const res = await request(app).get('/api/projects/search?tech=Node.js'); + expect(res.statusCode).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('returns 400 when tech param is missing', async () => { + const res = await request(app).get('/api/projects/search'); + expect(res.statusCode).toBe(400); + expect(res.body.message).toBe('Tech parameter is required'); + }); + + it('trims whitespace in tech query', async () => { + Project.find.mockReturnValue(populateMock([])); + await request(app).get('/api/projects/search?tech= Node.js , React '); + const callArg = Project.find.mock.calls[0][0]; + expect(callArg.techStack.$in).toEqual(['Node.js', 'React']); + }); + + it('returns 500 when database throws', async () => { + Project.find.mockReturnValue({ + populate: jest.fn().mockReturnValue({ + sort: jest.fn().mockRejectedValue(new Error('DB')), + }), + }); + const res = await request(app).get('/api/projects/search?tech=React'); + expect(res.statusCode).toBe(500); + }); + }); + + // ── GET /api/projects/user/:userId ──────────────────────────────────────── + describe('GET /api/projects/user/:userId', () => { + it('returns projects for a given user', async () => { + Project.find.mockReturnValue(populateMock([mockProjectData])); + const res = await request(app).get('/api/projects/user/user123'); + expect(res.statusCode).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('returns empty array when user has no projects', async () => { + Project.find.mockReturnValue(populateMock([])); + const res = await request(app).get('/api/projects/user/user456'); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual([]); + }); + + it('returns 500 when database throws', async () => { + Project.find.mockReturnValue({ + populate: jest.fn().mockReturnValue({ + sort: jest.fn().mockRejectedValue(new Error('DB')), + }), + }); + const res = await request(app).get('/api/projects/user/user123'); + expect(res.statusCode).toBe(500); + }); + }); + + // ── PUT /api/projects/:id ───────────────────────────────────────────────── + describe('PUT /api/projects/:id', () => { + it('updates a project the user owns', async () => { + const updated = { ...mockProjectData, title: 'Updated Title' }; + Project.findById.mockResolvedValue(mockProjectData); + Project.findByIdAndUpdate.mockResolvedValue(updated); + const res = await request(app) + .put(`/api/projects/${VALID_OID}`) + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Updated Title', description: 'Desc', techStack: ['Node.js'] }); + expect(res.statusCode).toBe(200); + expect(res.body.title).toBe('Updated Title'); + }); + + it('returns 401 without token', async () => { + const res = await request(app) + .put(`/api/projects/${VALID_OID}`) + .send({ title: 'Updated', description: 'Desc', techStack: ['Node.js'] }); + expect(res.statusCode).toBe(401); + }); + + it('returns 400 when title is missing', async () => { + const res = await request(app) + .put(`/api/projects/${VALID_OID}`) + .set('Authorization', `Bearer ${token}`) + .send({ description: 'Desc', techStack: ['Node.js'] }); + expect(res.statusCode).toBe(400); + }); + + it('returns 404 when project not found', async () => { + Project.findById.mockResolvedValue(null); + const res = await request(app) + .put(`/api/projects/${VALID_OID}`) + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Updated', description: 'Desc', techStack: ['Node.js'] }); + expect(res.statusCode).toBe(404); + }); + + it('returns 401 when user is not the owner', async () => { + const otherProject = { + ...mockProjectData, + owner: { toString: () => 'otheruser999' }, + }; + Project.findById.mockResolvedValue(otherProject); + const res = await request(app) + .put(`/api/projects/${VALID_OID}`) + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Updated', description: 'Desc', techStack: ['Node.js'] }); + expect(res.statusCode).toBe(401); + expect(res.body.message).toBe('Not authorized'); + }); + + it('returns 500 when database throws', async () => { + Project.findById.mockRejectedValue(new Error('DB')); + const res = await request(app) + .put(`/api/projects/${VALID_OID}`) + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Updated', description: 'Desc', techStack: ['Node.js'] }); + expect(res.statusCode).toBe(500); + }); + }); + + // ── DELETE /api/projects/:id ────────────────────────────────────────────── + describe('DELETE /api/projects/:id', () => { + it('deletes project the user owns', async () => { + Project.findById.mockResolvedValue(mockProjectData); + const res = await request(app) + .delete(`/api/projects/${VALID_OID}`) + .set('Authorization', `Bearer ${token}`); + expect(res.statusCode).toBe(200); + expect(res.body.message).toBe('Project deleted'); + }); + + it('returns 401 without token', async () => { + const res = await request(app).delete(`/api/projects/${VALID_OID}`); + expect(res.statusCode).toBe(401); + }); + + it('returns 404 when project not found', async () => { + Project.findById.mockResolvedValue(null); + const res = await request(app) + .delete(`/api/projects/${VALID_OID}`) + .set('Authorization', `Bearer ${token}`); + expect(res.statusCode).toBe(404); + expect(res.body.message).toBe('Project not found'); + }); + + it('returns 401 when user is not the owner', async () => { + const otherProject = { + ...mockProjectData, + owner: { toString: () => 'otheruser999' }, + }; + Project.findById.mockResolvedValue(otherProject); + const res = await request(app) + .delete(`/api/projects/${VALID_OID}`) + .set('Authorization', `Bearer ${token}`); + expect(res.statusCode).toBe(401); + expect(res.body.message).toBe('Not authorized'); + }); + + it('returns 500 when database throws', async () => { + Project.findById.mockRejectedValue(new Error('DB')); + const res = await request(app) + .delete(`/api/projects/${VALID_OID}`) + .set('Authorization', `Bearer ${token}`); + expect(res.statusCode).toBe(500); + }); + }); +}); diff --git a/server/tests/setup.js b/server/tests/setup.js new file mode 100644 index 0000000..f8e35e6 --- /dev/null +++ b/server/tests/setup.js @@ -0,0 +1,2 @@ +process.env.JWT_SECRET = 'test_jwt_secret_key_for_testing'; +process.env.NODE_ENV = 'test'; diff --git a/server/tests/users.test.js b/server/tests/users.test.js new file mode 100644 index 0000000..7ea51ba --- /dev/null +++ b/server/tests/users.test.js @@ -0,0 +1,197 @@ +const request = require('supertest'); +const express = require('express'); +const jwt = require('jsonwebtoken'); + +// ─── Mongoose mock ─────────────────────────────────────────────────────────── +jest.mock('mongoose', () => { + const actual = jest.requireActual('mongoose'); + return { ...actual, connect: jest.fn().mockResolvedValue(undefined) }; +}); + +// ─── Model mocks ───────────────────────────────────────────────────────────── +const mockUserData = { + _id: 'user123', + name: 'Test User', + email: 'test@example.com', + bio: 'Bio text', + skills: ['React', 'Node.js'], +}; + +jest.mock('../models/User', () => { + const MockUser = jest.fn(); + MockUser.find = jest.fn(); + MockUser.findOne = jest.fn(); + MockUser.findById = jest.fn(); + MockUser.findByIdAndUpdate = jest.fn(); + return MockUser; +}); + +jest.mock('../models/Project', () => ({})); + +const User = require('../models/User'); + +// ─── App setup ─────────────────────────────────────────────────────────────── +const app = express(); +app.use(express.json()); +app.use('/api/users', require('../routes/users')); + +// ─── Helpers ───────────────────────────────────────────────────────────────── +const makeToken = (userId = 'user123') => + jwt.sign({ userId }, process.env.JWT_SECRET); + +const withSelect = (value) => ({ select: jest.fn().mockResolvedValue(value) }); + +// ─── Tests ─────────────────────────────────────────────────────────────────── +describe('User Routes', () => { + beforeEach(() => jest.clearAllMocks()); + + // ── GET /api/users ──────────────────────────────────────────────────────── + describe('GET /api/users', () => { + it('returns list of all users', async () => { + User.find.mockReturnValue(withSelect([mockUserData])); + const res = await request(app).get('/api/users'); + expect(res.statusCode).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body[0].email).toBe('test@example.com'); + }); + + it('returns empty array when no users exist', async () => { + User.find.mockReturnValue(withSelect([])); + const res = await request(app).get('/api/users'); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual([]); + }); + + it('returns 500 when database throws', async () => { + User.find.mockReturnValue({ select: jest.fn().mockRejectedValue(new Error('DB')) }); + const res = await request(app).get('/api/users'); + expect(res.statusCode).toBe(500); + }); + }); + + // ── GET /api/users/:id ──────────────────────────────────────────────────── + describe('GET /api/users/:id', () => { + it('returns user by valid ID', async () => { + User.findById.mockReturnValue(withSelect(mockUserData)); + const res = await request(app).get('/api/users/user123'); + expect(res.statusCode).toBe(200); + expect(res.body.name).toBe('Test User'); + }); + + it('returns 404 when user not found', async () => { + User.findById.mockReturnValue(withSelect(null)); + const res = await request(app).get('/api/users/nonexistent'); + expect(res.statusCode).toBe(404); + expect(res.body.message).toBe('User not found'); + }); + + it('returns 500 when database throws', async () => { + User.findById.mockReturnValue({ select: jest.fn().mockRejectedValue(new Error('DB')) }); + const res = await request(app).get('/api/users/user123'); + expect(res.statusCode).toBe(500); + }); + }); + + // ── PUT /api/users/profile ──────────────────────────────────────────────── + describe('PUT /api/users/profile', () => { + const token = () => makeToken(); + + it('updates profile with valid data', async () => { + const updated = { ...mockUserData, bio: 'New bio', skills: ['TypeScript'] }; + User.findOne.mockResolvedValue(mockUserData); + User.findByIdAndUpdate.mockReturnValue(withSelect(updated)); + const res = await request(app) + .put('/api/users/profile') + .set('Authorization', `Bearer ${token()}`) + .send({ name: 'Test User', bio: 'New bio', skills: ['TypeScript'] }); + expect(res.statusCode).toBe(200); + expect(res.body.bio).toBe('New bio'); + }); + + it('returns 401 without token', async () => { + const res = await request(app) + .put('/api/users/profile') + .send({ name: 'Test User' }); + expect(res.statusCode).toBe(401); + }); + + it('returns 400 when name is empty', async () => { + User.findOne.mockResolvedValue(mockUserData); + const res = await request(app) + .put('/api/users/profile') + .set('Authorization', `Bearer ${token()}`) + .send({ name: '' }); + expect(res.statusCode).toBe(400); + expect(res.body.errors.some((e) => e.path === 'name')).toBe(true); + }); + + it('returns 400 when skills is not an array', async () => { + User.findOne.mockResolvedValue(mockUserData); + const res = await request(app) + .put('/api/users/profile') + .set('Authorization', `Bearer ${token()}`) + .send({ name: 'Test User', skills: 'not-an-array' }); + expect(res.statusCode).toBe(400); + expect(res.body.errors.some((e) => e.path === 'skills')).toBe(true); + }); + + it('updates only provided optional fields', async () => { + const updated = { ...mockUserData, githubUrl: 'https://github.com/test' }; + User.findOne.mockResolvedValue(mockUserData); + User.findByIdAndUpdate.mockReturnValue(withSelect(updated)); + const res = await request(app) + .put('/api/users/profile') + .set('Authorization', `Bearer ${token()}`) + .send({ name: 'Test User', githubUrl: 'https://github.com/test' }); + expect(res.statusCode).toBe(200); + expect(res.body.githubUrl).toBe('https://github.com/test'); + }); + + it('returns 500 when database throws', async () => { + User.findOne.mockResolvedValue(mockUserData); + User.findByIdAndUpdate.mockReturnValue({ select: jest.fn().mockRejectedValue(new Error('DB')) }); + const res = await request(app) + .put('/api/users/profile') + .set('Authorization', `Bearer ${token()}`) + .send({ name: 'Test User' }); + expect(res.statusCode).toBe(500); + }); + }); + + // ── GET /api/users/search ───────────────────────────────────────────────── + describe('GET /api/users/search', () => { + it('returns users matching requested skills', async () => { + User.find.mockReturnValue(withSelect([mockUserData])); + const res = await request(app).get('/api/users/search?skills=React,Node.js'); + expect(res.statusCode).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + }); + + it('returns 400 when skills param is missing', async () => { + const res = await request(app).get('/api/users/search'); + expect(res.statusCode).toBe(400); + expect(res.body.message).toBe('Skills parameter is required'); + }); + + it('returns empty array when no users match', async () => { + User.find.mockReturnValue(withSelect([])); + const res = await request(app).get('/api/users/search?skills=Rust'); + expect(res.statusCode).toBe(200); + expect(res.body).toEqual([]); + }); + + it('trims whitespace in skills query', async () => { + User.find.mockReturnValue(withSelect([mockUserData])); + const res = await request(app).get('/api/users/search?skills= React , Node.js '); + expect(res.statusCode).toBe(200); + const callArg = User.find.mock.calls[0][0]; + expect(callArg.skills.$in).toEqual(['React', 'Node.js']); + }); + + it('returns 500 when database throws', async () => { + User.find.mockReturnValue({ select: jest.fn().mockRejectedValue(new Error('DB')) }); + const res = await request(app).get('/api/users/search?skills=React'); + expect(res.statusCode).toBe(500); + }); + }); +});