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);
+ });
+ });
+});