diff --git a/.circleci/config.yml b/.circleci/config.yml index 1e578fc..a575b02 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -42,7 +42,13 @@ jobs: - run: name: Lint command: | - npm run lint + npm run lint + - run: + name: Run Tests + command: | + npm run test-server + npm run test-client + echo "Tests completed successfully." - run: name: Front-End Build command: | diff --git a/.gitignore b/.gitignore index 85e149c..6e8b84f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,27 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - # dependencies /node_modules -server/node_modules -frontend/node_modules +/server/node_modules +/frontend/node_modules /.pnp .pnp.js # testing -/coverage +**/coverage/ # production -frontend/build -server/dist -server/dist.zip +/frontend/build +/server/dist +/server/dist.zip -# misc -.DS_Store -.env -.env.development -.env.test -.env.production +# environment variables +.env* cypress.env.json .secrets +# misc +.DS_Store + +# logs npm-debug.log* yarn-debug.log* -yarn-error.log* +yarn-error.log* \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json index 12f542d..daf0733 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "start": "react-scripts start --project ./frontend", "build": "react-scripts build --project ./frontend", "build-production": "REACT_APP_API_URL=https://employee-polling.us-east-2.elasticbeanstalk.com react-scripts build --project ./frontend", - "test": "react-scripts test --transformIgnorePatterns \"node_modules/(?!@toolz/allow-react)/\" --env=jsdom", + "test": "react-scripts test --coverage --watchAll=false --transformIgnorePatterns \"node_modules/(?!@toolz/allow-react)/\"", "eject": "react-scripts eject", "cypress:open": "cypress open", "cypress:run": "cypress run" diff --git a/frontend/src/features/pollSlice.ts b/frontend/src/features/pollSlice.ts index e95cb8d..6951bca 100644 --- a/frontend/src/features/pollSlice.ts +++ b/frontend/src/features/pollSlice.ts @@ -28,7 +28,7 @@ export interface PollsState { } // Initial state for polls -const initialState: PollsState = { +export const initialState: PollsState = { polls: {}, status: 'idle', error: undefined, diff --git a/frontend/src/tests/Home.test.tsx b/frontend/src/tests/Home.test.tsx index ca5a3cb..ff20a19 100644 --- a/frontend/src/tests/Home.test.tsx +++ b/frontend/src/tests/Home.test.tsx @@ -80,4 +80,102 @@ describe('Home Page', () => { expect(screen.queryByText('Show Answered Polls')).not.toBeInTheDocument(); expect(screen.getByText('Show Unanswered Polls')).toBeInTheDocument(); }); + + it('displays loading spinner when polls are loading', () => { + const store = mockStore({ + users: { + currentUser: { + id: '123', + name: 'User', + username: 'User', + pollsCreated: 1, + pollsVotedOn: 0, + }, + users: [], + status: 'succeeded', + error: null, + }, + poll: { + polls: {}, + status: 'loading', + error: null, + }, + }); + + render( + + + + + , + ); + + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('displays error message when fetching polls fails', () => { + const store = mockStore({ + users: { + currentUser: { + id: '123', + name: 'User', + username: 'User', + pollsCreated: 1, + pollsVotedOn: 0, + }, + users: [], + status: 'succeeded', + error: null, + }, + poll: { + polls: {}, + status: 'failed', + error: 'Failed to fetch polls', + }, + }); + + render( + + + + + , + ); + + expect( + screen.getByText('Error: Failed to fetch polls'), + ).toBeInTheDocument(); + }); + + it('displays no polls available message when there are no polls', () => { + const store = mockStore({ + users: { + currentUser: { + id: '123', + name: 'User', + username: 'User', + pollsCreated: 1, + pollsVotedOn: 0, + }, + users: [], + status: 'succeeded', + error: null, + }, + poll: { + polls: {}, + status: 'succeeded', + error: null, + }, + }); + + render( + + + + + , + ); + + expect(screen.getByText('No polls available.')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/tests/Navbar.test.tsx b/frontend/src/tests/Navbar.test.tsx deleted file mode 100644 index a19ac8c..0000000 --- a/frontend/src/tests/Navbar.test.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import { Provider } from 'react-redux'; -import { BrowserRouter as Router } from 'react-router-dom'; -import Navbar from '../components/Navbar'; -import configureStore from 'redux-mock-store'; -import { initialState } from '../features/usersSlice'; - -const mockStore = configureStore(); - -describe('Navbar Component', () => { - it('should display logout link when user is authenticated', () => { - const store = mockStore({ - users: { ...initialState, currentUser: { id: '123', name: 'User' } }, - }); - render( - - - - - , - ); - - const createPollLinks = screen.getAllByText('Create Poll'); - expect(createPollLinks.length).toBeGreaterThan(0); // Check if there's at least one - createPollLinks.forEach(link => { - expect(link).toBeInTheDocument(); - }); - const createLeaderboardLinks = screen.getAllByText('Leaderboard'); - expect(createLeaderboardLinks.length).toBeGreaterThan(0); // Check if there's at least one - createPollLinks.forEach(link => { - expect(link).toBeInTheDocument(); - }); - expect(screen.getByText('Logout')).toBeInTheDocument(); - expect(screen.queryByText('Login')).toBeNull(); - }); - it('should display login and signup links when user is not authenticated', () => { - const store = mockStore({ users: { ...initialState, currentUser: null } }); - render( - - - - - , - ); - - expect(screen.getByText('Login/Signup')).toBeInTheDocument(); - expect(screen.queryByText('Logout')).toBeNull(); - }); -}); diff --git a/frontend/src/tests/PollList.test.tsx b/frontend/src/tests/PollList.test.tsx new file mode 100644 index 0000000..8936978 --- /dev/null +++ b/frontend/src/tests/PollList.test.tsx @@ -0,0 +1,74 @@ +import { configureStore, Store } from '@reduxjs/toolkit'; +import pollReducer, { + fetchPolls, + addNewPoll, + PollsState, + initialState, +} from '../features/pollSlice'; +import * as api from '../server/api'; +import { ThunkDispatch } from 'redux-thunk'; +import { RootState } from '../store/store'; +import { Action } from 'redux'; + +jest.mock('../server/api'); + +type AppThunkDispatch = ThunkDispatch; + +const mockStore = (state: PollsState) => + configureStore({ + reducer: { + poll: pollReducer, + }, + preloadedState: { + poll: state, + }, + }); + +describe('pollSlice', () => { + let store: Store & { dispatch: AppThunkDispatch }; + + beforeEach(() => { + store = mockStore(initialState) as Store & { + dispatch: AppThunkDispatch; + }; + jest.clearAllMocks(); + }); + + it('should handle initial state', () => { + expect(store.getState().poll).toEqual(initialState); + }); + + it('should handle fetchPolls', async () => { + const polls = [ + { id: '1', optionOne: 'Option 1', optionTwo: 'Option 2', votes: [] }, + ]; + (api.fetchPolls as jest.Mock).mockResolvedValue({ polls }); + + await store.dispatch(fetchPolls()); + + const state = store.getState().poll; + expect(state.polls['1']).toEqual(polls[0]); + expect(state.status).toEqual('succeeded'); + expect(state.error).toBeUndefined(); + }); + + it('should handle addNewPoll', async () => { + const newPoll = { + id: '2', + optionOne: 'Option 1', + optionTwo: 'Option 2', + userId: '1', + votes: [], + }; + (api.createPoll as jest.Mock).mockResolvedValue(newPoll); + + await store.dispatch(addNewPoll(newPoll)); + + const state = store.getState().poll; + expect(state.polls['2']).toEqual(newPoll); + expect(state.status).toEqual('succeeded'); + expect(state.error).toBeUndefined(); + }); + + // Add more tests for voteOnPoll +}); diff --git a/frontend/src/tests/createPollForm.test.tsx b/frontend/src/tests/createPollForm.test.tsx new file mode 100644 index 0000000..f1e5327 --- /dev/null +++ b/frontend/src/tests/createPollForm.test.tsx @@ -0,0 +1,48 @@ +import { render, fireEvent, screen, waitFor } from '@testing-library/react'; +import CreatePollForm from '../components/CreatePollForm'; +import { Provider } from 'react-redux'; +import configureStore from 'redux-mock-store'; +import { BrowserRouter as Router } from 'react-router-dom'; + +const mockStore = configureStore(); + +describe('CreatePollForm', () => { + let store: ReturnType; + + beforeEach(() => { + store = mockStore({ + users: { + currentUser: { + id: '123', + name: 'User', + username: 'User', + pollCount: 1, + voteCount: 0, + }, + users: [], + status: 'succeeded', + error: undefined, + }, + poll: { + polls: {}, + status: 'idle', + error: undefined, + }, + }); + store.dispatch = jest.fn(); + }); + + it('renders form inputs', async () => { + render( + + + + + , + ); + + expect(screen.getByLabelText(/Option One/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/Option Two/i)).toBeInTheDocument(); + expect(screen.getByText(/Create Poll/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/tests/docs/testing.md b/frontend/src/tests/docs/testing.md deleted file mode 100644 index c293315..0000000 --- a/frontend/src/tests/docs/testing.md +++ /dev/null @@ -1,87 +0,0 @@ -# Testing Plan - -## Components - -### 1. Navbar - -- Verifies responsive behavior and the display of correct navigation links based on the user's authentication status. -- Checks the mobile hamburger menu functionality. - -### 2. Home Page - -- Tests display of polls and conditional content for authenticated vs. unauthenticated users. -- Ensures correct filtering and display of answered and unanswered polls. - -### 3. Login Form - -- Validates form submission handling and user feedback on errors. (!TODO!) -- Tests navigation between Login and Signup forms. (!TODO!) - -### 4. Signup Form - -- Checks form validation, submission handling, and error feedback. (!TODO!) -- Validates navigation between Signup and Login forms. (!TODO!) - -### 5. Poll List - -- Ensures correct fetching and rendering of polls. (!TODO!) - -### 6. Poll Details - -- Tests display of poll details, voting functionality, and results presentation. (!TODO!) - -### 7. Create Poll Form - -- Validates form submissions for creating new polls. (!TODO!) - -### 8. Leaderboard - -- Checks correct fetching and display of user statistics, sorted by activity. (!TODO!) - -## API Calls - -### 1. Fetch Users - -- Ensures users are retrieved correctly and handles errors. - -### 2. Login User - -- Tests successful user authentication and error handling. - -### 3. Register User - -- Validates user registration and error responses. - -### 4. Fetch Polls - -- Ensures polls are fetched correctly and handles possible errors. (!TODO!) - -### 5. Create Poll - -- Tests API response on creating a new poll and handles errors. (!TODO!) - -### 6. Vote on Poll - -- Ensures the voting mechanism works correctly and error conditions are handled. (!TODO!) - -## Testing Strategy - -### MVP Unit Testing with Jest - -#### Unit Tests - -- Test individual functions and components in isolation. (!TODO!) (In Progress) -- Mock API calls using jest mocks to simulate server responses. -- Use React Testing Library to render components and interact with them as users would. (!TODO!) (In Progress) - -### Comprehensive Testing with Playwright - -#### Integration Tests - -- Verify the interaction between components and the API. (!TODO!) -- Test scenarios such as user registration, login, poll creation, and voting workflows. (!TODO!) - -#### End-to-End Tests - -- Simulate user journeys from start to finish. (!TODO!) -- Use Playwright to automate browser interactions to test the complete flow of the application, including responsive behavior on different devices. (!TODO!) diff --git a/frontend/src/tests/usersSlice.test.ts b/frontend/src/tests/usersSlice.test.tsx similarity index 95% rename from frontend/src/tests/usersSlice.test.ts rename to frontend/src/tests/usersSlice.test.tsx index dccc9cf..3bb2702 100644 --- a/frontend/src/tests/usersSlice.test.ts +++ b/frontend/src/tests/usersSlice.test.tsx @@ -1,6 +1,6 @@ import { fetchUsers } from '../features/usersSlice'; import { store } from '../store/store'; -import * as mockApi from '../tests/mocks/api'; +import * as mockApi from './mocks/api'; const testUsers = [ { diff --git a/server/package.json b/server/package.json index 2415bce..9a96207 100644 --- a/server/package.json +++ b/server/package.json @@ -10,7 +10,7 @@ "migrate": "sequelize-cli db:migrate", "migrate:undo": "sequelize db:migrate:undo", "migrate:all": "npm run build && sequelize db:migrate", - "test": "cross-env NODE_ENV=test node -r dotenv/config ./node_modules/.bin/jest" + "test": "jest --coverage" }, "dependencies": { "bcrypt": "^5.1.1", diff --git a/server/src/controllers/pollController.ts b/server/src/controllers/pollController.ts index 0486728..84b8000 100644 --- a/server/src/controllers/pollController.ts +++ b/server/src/controllers/pollController.ts @@ -34,7 +34,7 @@ export const getPolls = async (req: Request, res: Response): Promise => { res.json({ polls: polls.map(poll => poll.toJSON()) }); } catch (error) { - console.error('Failed to fetch polls:', error); + console.log('Failed to fetch polls: return 500 server error'); res.status(500).json({ error: 'Error fetching polls' }); } }; @@ -98,7 +98,7 @@ export const createPoll = async ( res.status(201).json(poll); } catch (error) { - console.error('Failed to create poll:', error); + console.log('Failed to create poll: return 500 server error'); res.status(500).json({ error: 'Error creating poll' }); } }; @@ -199,7 +199,7 @@ export const voteOnPoll = async ( res.json({ message: 'Vote recorded successfully', vote }); } catch (error) { - console.error('Failed to vote:', error); + console.log('Failed to vote: return 500 server error'); res.status(500).json({ error: 'Error voting' }); } }; diff --git a/server/src/controllers/userController.ts b/server/src/controllers/userController.ts index 1017e52..c25b26d 100644 --- a/server/src/controllers/userController.ts +++ b/server/src/controllers/userController.ts @@ -85,7 +85,7 @@ export const register = async (req: Request, res: Response) => { res.status(201).json({ message: 'User registered successfully', token }); } catch (error) { // Handle errors and send a 500 Internal Server Error response - console.error('Registration failed:', error); + console.log('Failed to register user: Server Error 500'); res.status(500).json({ error: 'User registration failed' }); } }; @@ -147,6 +147,7 @@ export const login = async (req: Request, res: Response) => { res.json({ message: 'User logged in successfully', token }); } catch (error) { // Handle errors and send a 500 Internal Server Error response + console.log('Failed to login: Server Error 500'); res.status(500).json({ error: 'Login failed' }); } }; @@ -206,7 +207,7 @@ export const getAllUsers = async ( res.status(200).json(usersWithCounts); } catch (error) { - console.error('Failed to fetch users:', error); + console.log('Failed to get users: Server Error 500'); res.status(500).json({ error: 'Error fetching users' }); } }; @@ -286,7 +287,7 @@ export const updateUser = async (req: Request, res: Response) => { // Respond with success message and the user token res.status(201).json({ message: 'User updated successfully', token }); } catch (error) { - console.error('Failed to update user:', error); + console.log('Failed to update user: Server Error 500'); res.status(500).json({ error: 'Failed to update user' }); } }; @@ -328,7 +329,7 @@ export const deleteUser = async (req: Request, res: Response) => { console.log('User deleted successfully'); res.status(204).send(); } catch (error) { - console.error('Failed to delete user:', error); + console.log('Failed to delete user: Server Error 500'); res.status(500).json({ error: 'Failed to delete user' }); } }; diff --git a/server/src/middleware/authenticate.ts b/server/src/middleware/authenticate.ts index 5360cb9..ba9d1ab 100644 --- a/server/src/middleware/authenticate.ts +++ b/server/src/middleware/authenticate.ts @@ -39,7 +39,7 @@ export const authenticate = ( } } catch (error) { // Handle any error related to token verification by sending a 401 Unauthorized status - console.error('Invalid token', error); + console.log('Invalid token: Return 401 response'); res.status(401).json({ error: 'Invalid token' }); } }; diff --git a/server/src/tests/authentication.test.ts b/server/src/tests/authentication.test.ts new file mode 100644 index 0000000..38e479f --- /dev/null +++ b/server/src/tests/authentication.test.ts @@ -0,0 +1,50 @@ +import { authenticate } from '../../src/middleware/authenticate'; +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; + +jest.mock('jsonwebtoken'); + +describe('Authentication Middleware', () => { + let req: Partial; + let res: Partial; + let next: NextFunction; + + beforeEach(() => { + req = { + headers: { + authorization: 'Bearer token', + }, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + next = jest.fn(); + }); + + it('should call next if token is valid', () => { + const userPayload = { id: '1', username: 'testuser' }; + (jwt.verify as jest.Mock).mockReturnValue({ user: userPayload }); + + authenticate(req as Request, res as Response, next); + expect(next).toHaveBeenCalled(); + expect(req.user).toEqual(userPayload); + }); + + it('should return 403 if no token is provided', () => { + req.headers = {}; + authenticate(req as Request, res as Response, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'No token provided' }); + }); + + it('should return 401 if token is invalid', () => { + (jwt.verify as jest.Mock).mockReturnValue({}); + + authenticate(req as Request, res as Response, next); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid token' }); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/tests/poll.test.ts b/server/src/tests/poll.test.ts index de00740..04c286f 100644 --- a/server/src/tests/poll.test.ts +++ b/server/src/tests/poll.test.ts @@ -1,108 +1,218 @@ +import { Request, Response, NextFunction } from 'express'; import request from 'supertest'; -import { startRandomServer } from '../server'; -import sequelize from '../config/sequelize'; +import app from '../app'; import { Poll } from '../database/models/poll'; import { Vote } from '../database/models/vote'; -import { User } from '../database/models/user'; - -describe('Poll API', () => { - let serverInstance: { server: import('http').Server; port: number }; - let app: string; - let userToken: string; - let testUser: User; - - beforeAll(async () => { - serverInstance = await startRandomServer(); - app = `http://localhost:${serverInstance.port}`; - - // Create a test user - testUser = await User.create({ - username: `testuser_${Date.now()}`, - password: '$2b$10$Tmh5BMmRudQ/zs4OsK5DluEkPuuoFtxglMKUY8/ug3mE6atADF3y2', - name: 'Test User', - }); - - // Login to get a token - const loginResponse = await request(app).post('/user/login').send({ - username: testUser.username, - password: 'password123', - }); - if (loginResponse.status !== 200) { - console.error('Failed to login:', loginResponse.body); - throw new Error('Failed to login test user'); - } +// Mocking the models +jest.mock('../../src/database/models/user'); +jest.mock('../../src/database/models/poll'); +jest.mock('../../src/database/models/vote'); - userToken = loginResponse.body.token; +// Mocking middleware +jest.mock('../../src/middleware/authenticate', () => ({ + authenticate: (req: Request, res: Response, next: NextFunction) => next(), +})); - // Clear existing data - await Poll.destroy({ where: {} }); - await Vote.destroy({ where: {} }); +describe('Poll Controller', () => { + beforeEach(() => { + jest.clearAllMocks(); }); - afterAll(async () => { - // Cleanup users to avoid affecting other tests - await User.destroy({ where: { id: testUser.id } }); - await sequelize.close(); - await serverInstance.server.close(); + describe('GET /polls', () => { + it('should fetch all polls', async () => { + const polls = [ + { + id: '1', + optionOne: 'Option One', + optionTwo: 'Option Two', + votes: [{ userId: '1', chosenOption: 1 }], + toJSON: jest.fn().mockReturnValue({ + id: '1', + optionOne: 'Option One', + optionTwo: 'Option Two', + votes: [{ userId: '1', chosenOption: 1 }], + }), + }, + ]; + /* + Poll.findAll returns plain JavaScript objects, not instances of Sequelize models. + In Sequelize, toJSON() is a method available on model instances but not on plain objects. + To resolve this, we structure polls to return model-like objects. + */ + (Poll.findAll as jest.Mock).mockResolvedValue(polls); + + const response = await request(app).get('/polls'); + expect(response.status).toBe(200); + expect(response.body).toEqual({ + polls: polls.map(poll => poll.toJSON()), + }); + expect(Poll.findAll).toHaveBeenCalledTimes(1); + }); + + it('should return 500 if fetching polls fails', async () => { + (Poll.findAll as jest.Mock).mockRejectedValue( + new Error('Failed to fetch polls'), + ); + + const response = await request(app).get('/polls'); + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: 'Error fetching polls' }); + expect(Poll.findAll).toHaveBeenCalledTimes(1); + }); }); describe('POST /polls', () => { it('should create a new poll', async () => { - const res = await request(app) - .post('/polls') - .set('Authorization', `Bearer ${userToken}`) - .send({ - optionOne: 'Option 1', - optionTwo: 'Option 2', - userId: testUser.id, - }) - .expect(201); - - expect(res.body).toEqual( - expect.objectContaining({ - optionOne: 'Option 1', - optionTwo: 'Option 2', - userId: testUser.id, + const poll = { + id: '1', + optionOne: 'Option One', + optionTwo: 'Option Two', + userId: '1', + toJSON: jest.fn().mockReturnValue({ + id: '1', + optionOne: 'Option One', + optionTwo: 'Option Two', + userId: '1', }), - ); + }; + (Poll.create as jest.Mock).mockResolvedValue(poll); + + const response = await request(app).post('/polls').send({ + optionOne: 'Option One', + optionTwo: 'Option Two', + userId: '1', + }); + + expect(response.status).toBe(201); + expect(response.body).toEqual(poll.toJSON()); + expect(Poll.create).toHaveBeenCalledTimes(1); + expect(Poll.create).toHaveBeenCalledWith({ + optionOne: 'Option One', + optionTwo: 'Option Two', + userId: '1', + }); }); - it('should return 400 for missing poll details', async () => { - const res = await request(app) + it('should return 400 if missing required poll details', async () => { + const response = await request(app) .post('/polls') - .set('Authorization', `Bearer ${userToken}`) - .send({}) - .expect(400); + .send({ optionOne: 'Option One' }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'Missing required poll details' }); + expect(Poll.create).not.toHaveBeenCalled(); + }); + + it('should return 500 if creating poll fails', async () => { + (Poll.create as jest.Mock).mockRejectedValue( + new Error('Failed to create poll'), + ); + + const response = await request(app).post('/polls').send({ + optionOne: 'Option One', + optionTwo: 'Option Two', + userId: '1', + }); - expect(res.body).toHaveProperty('error'); + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: 'Error creating poll' }); + expect(Poll.create).toHaveBeenCalledTimes(1); }); }); describe('POST /polls/:id/vote', () => { - let pollId: string; + it('should record a vote on a poll', async () => { + const pollId = '1'; + const userId = '1'; + const vote = { pollId, userId, chosenOption: 1 }; + const poll = { id: pollId }; - beforeAll(async () => { - const poll = await Poll.create({ - userId: testUser.id, - optionOne: 'Option 1', - optionTwo: 'Option 2', + (Poll.findByPk as jest.Mock).mockResolvedValue(poll); + (Vote.findOne as jest.Mock).mockResolvedValue(null); + (Vote.create as jest.Mock).mockResolvedValue(vote); + + const response = await request(app) + .post(`/polls/${pollId}/vote`) + .send({ userId, chosenOption: 1 }); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + message: 'Vote recorded successfully', + vote, + }); + expect(Poll.findByPk).toHaveBeenCalledTimes(1); + expect(Poll.findByPk).toHaveBeenCalledWith(pollId); + expect(Vote.findOne).toHaveBeenCalledTimes(1); + expect(Vote.findOne).toHaveBeenCalledWith({ where: { pollId, userId } }); + expect(Vote.create).toHaveBeenCalledTimes(1); + expect(Vote.create).toHaveBeenCalledWith(vote); + }); + + it('should return 400 if missing vote information', async () => { + const response = await request(app) + .post('/polls/1/vote') + .send({ userId: '1' }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'Missing information' }); + expect(Poll.findByPk).not.toHaveBeenCalled(); + expect(Vote.findOne).not.toHaveBeenCalled(); + expect(Vote.create).not.toHaveBeenCalled(); + }); + + it('should return 404 if poll is not found', async () => { + (Poll.findByPk as jest.Mock).mockResolvedValue(null); + + const response = await request(app) + .post('/polls/1/vote') + .send({ userId: '1', chosenOption: 1 }); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: 'Poll not found' }); + expect(Poll.findByPk).toHaveBeenCalledTimes(1); + expect(Vote.findOne).not.toHaveBeenCalled(); + expect(Vote.create).not.toHaveBeenCalled(); + }); + + it('should return 409 if user has already voted on this poll', async () => { + const pollId = '1'; + const userId = '1'; + const existingVote = { pollId, userId, chosenOption: 1 }; + + (Poll.findByPk as jest.Mock).mockResolvedValue({ id: pollId }); + (Vote.findOne as jest.Mock).mockResolvedValue(existingVote); + + const response = await request(app) + .post(`/polls/${pollId}/vote`) + .send({ userId, chosenOption: 1 }); + + expect(response.status).toBe(409); + expect(response.body).toEqual({ + error: 'User has already voted on this poll', }); - pollId = poll.id; + expect(Poll.findByPk).toHaveBeenCalledTimes(1); + expect(Vote.findOne).toHaveBeenCalledTimes(1); + expect(Vote.create).not.toHaveBeenCalled(); }); - it('should allow a user to vote on a poll', async () => { - const res = await request(app) + it('should return 500 if voting fails', async () => { + const pollId = '1'; + const userId = '1'; + + (Poll.findByPk as jest.Mock).mockResolvedValue({ id: pollId }); + (Vote.findOne as jest.Mock).mockResolvedValue(null); + (Vote.create as jest.Mock).mockRejectedValue(new Error('Failed to vote')); + + const response = await request(app) .post(`/polls/${pollId}/vote`) - .set('Authorization', `Bearer ${userToken}`) - .send({ - pollId: pollId, - userId: testUser.id, - chosenOption: 1, - }) - .expect(200); - - expect(res.body).toHaveProperty('message', 'Vote recorded successfully'); + .send({ userId, chosenOption: 1 }); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: 'Error voting' }); + expect(Poll.findByPk).toHaveBeenCalledTimes(1); + expect(Vote.findOne).toHaveBeenCalledTimes(1); + expect(Vote.create).toHaveBeenCalledTimes(1); }); }); }); diff --git a/server/src/tests/user.test.ts b/server/src/tests/user.test.ts index 3f23cd0..45a34be 100644 --- a/server/src/tests/user.test.ts +++ b/server/src/tests/user.test.ts @@ -1,152 +1,232 @@ +import { Request, Response, NextFunction } from 'express'; import request from 'supertest'; -import { startRandomServer } from '../server'; -import User from '../database/models/user'; -import Poll from '../database/models/poll'; -import Vote from '../database/models/vote'; -import type { UserDTO } from '../controllers/userController'; - -describe('User API', () => { - let serverInstance: { server: import('http').Server; port: number }; - let app: string; - const hashedPass = - '$2b$10$Tmh5BMmRudQ/zs4OsK5DluEkPuuoFtxglMKUY8/ug3mE6atADF3y2'; - - beforeAll(async () => { - try { - serverInstance = await startRandomServer(); - app = `http://localhost:${serverInstance.port}`; - - await User.bulkCreate([ - { - username: 'user1', - password: hashedPass, - name: 'User One', - avatar_url: null, - }, - { - username: 'user2', - password: hashedPass, - name: 'User Two', - avatar_url: null, - }, - ]); - const user1 = await User.findOne({ where: { username: 'user1' } }); - const user2 = await User.findOne({ where: { username: 'user2' } }); +import app from '../../src/app'; +import { User } from '../../src/database/models/user'; +import { Poll } from '../../src/database/models/poll'; +import { Vote } from '../../src/database/models/vote'; +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; + +// Mocking the models +jest.mock('../../src/database/models/user'); +jest.mock('../../src/database/models/poll'); +jest.mock('../../src/database/models/vote'); + +// Mocking jwt and bcrypt +jest.mock('jsonwebtoken'); +jest.mock('bcrypt'); + +// Mocking middleware +jest.mock('../../src/middleware/authenticate', () => ({ + authenticate: (req: Request, res: Response, next: NextFunction) => next(), +})); + +describe('User Controller', () => { + const userPayload = { + id: '1', + username: 'testuser', + password: 'testpass', + name: 'Test User', + }; + + afterEach(() => { + jest.clearAllMocks(); + }); - if (!user1 || !user2) { - throw new Error('Test setup failed, required users not found'); - } + describe('POST /user/register', () => { + it('should register a new user', async () => { + (User.create as jest.Mock).mockResolvedValue(userPayload); + (jwt.sign as jest.Mock).mockReturnValue('token'); + + const response = await request(app).post('/user/register').send({ + username: 'testuser', + password: 'testpass', + name: 'Test User', + }); - await Poll.create({ - userId: user1.id, - optionOne: 'Option One', - optionTwo: 'Option Two', + expect(response.status).toBe(201); + expect(response.body).toEqual({ + message: 'User registered successfully', + token: 'token', }); - const poll1 = await Poll.findOne({ where: { userId: user1.id } }); + }); - if (!poll1) { - throw new Error('Test setup failed, required poll not found'); - } + it('should return 500 if registration fails', async () => { + (User.create as jest.Mock).mockRejectedValue( + new Error('Registration failed'), + ); - await Vote.create({ - pollId: poll1.id, - userId: user2.id, - chosenOption: 1, + const response = await request(app).post('/user/register').send({ + username: 'testuser', + password: 'testpass', + name: 'Test User', }); - } catch (error) { - console.error('Error inserting test user:', error); - } + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: 'User registration failed' }); + }); }); - afterAll(async () => { - try { - await User.destroy({ where: {} }); - await Poll.destroy({ where: {} }); - await Vote.destroy({ where: {} }); + describe('POST /user/login', () => { + it('should login a user', async () => { + (User.findOne as jest.Mock).mockResolvedValue({ + ...userPayload, + password: 'hashedpass', + }); + (jwt.sign as jest.Mock).mockReturnValue('token'); + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + (Poll.count as jest.Mock).mockResolvedValue(2); + (Vote.count as jest.Mock).mockResolvedValue(3); + + const response = await request(app) + .post('/user/login') + .send({ username: 'testuser', password: 'testpass' }); - await serverInstance.server.close(); - } catch (error) { - console.error('Error cleaning up test user:', error); - } + expect(response.status).toBe(200); + expect(response.body).toEqual({ + message: 'User logged in successfully', + token: 'token', + }); + }); + + it('should return 401 if login fails due to credentials', async () => { + (User.findOne as jest.Mock).mockResolvedValue(null); + + const response = await request(app) + .post('/user/login') + .send({ username: 'testuser', password: 'testpass' }); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'Invalid credentials' }); + }); + + it('should return 500 if login fails due to server', async () => { + (User.findOne as jest.Mock).mockRejectedValue(new Error('Login failed')); + + const response = await request(app).post('/user/login').send({ + username: 'testuser', + password: 'password123', + }); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: 'Login failed' }); + }); }); - describe('POST /register', () => { - it('should register a new user and return a token', async () => { - const res = await request(app) - .post('/user/register') - .send({ - username: 'user3', - password: 'password123', - name: 'New User', - avatar_url: null, - }) - .expect(201); - - expect(res.body).toEqual( - expect.objectContaining({ - message: expect.any(String), - token: expect.any(String), - }), - ); + describe('GET /user/all', () => { + it('should fetch all users', async () => { + const users = [{ id: '1', username: 'testuser', name: 'Test User' }]; + (User.findAll as jest.Mock).mockResolvedValue(users); + (Poll.count as jest.Mock).mockResolvedValue(2); + (Vote.count as jest.Mock).mockResolvedValue(3); + + const response = await request(app).get('/user/all'); + + expect(response.status).toBe(200); + expect(response.body).toEqual([ + { + id: '1', + username: 'testuser', + name: 'Test User', + pollCount: 2, + voteCount: 3, + }, + ]); }); - it('should handle missing username and return a 400 status', async () => { - const res = await request(app) - .post('/user/register') - .send({ - password: 'password123', - name: 'New User', - }) - .expect(400); - - expect(res.body).toHaveProperty('errors'); - expect(res.body.errors).toBeInstanceOf(Array); + it('should return 500 if fetching users fails', async () => { + (User.findAll as jest.Mock).mockRejectedValue( + new Error('Error fetching users'), + ); + + const response = await request(app).get('/user/all'); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: 'Error fetching users' }); }); }); - describe('POST /login', () => { - it('should authenticate existing user and return a token', async () => { - const res = await request(app) - .post('/user/login') - .send({ - username: 'user1', - password: 'password123', - }) - .expect(200); + describe('PUT /user/:id', () => { + it('should update a user', async () => { + (User.findByPk as jest.Mock).mockResolvedValue({ + ...userPayload, + save: jest.fn().mockResolvedValue(true), + }); + (jwt.sign as jest.Mock).mockReturnValue('token'); - expect(res.body).toEqual({ - message: 'User logged in successfully', - token: expect.any(String), + const response = await request(app).put('/user/1').send({ + username: 'updateduser', + name: 'Updated User', + password: 'new_pass', + }); + + expect(response.status).toBe(201); + expect(response.body).toEqual({ + message: 'User updated successfully', + token: 'token', }); }); - it('should return error for invalid password', async () => { - const res = await request(app) - .post('/user/login') - .send({ - username: 'user1', - password: 'wrongpassword', - }) - .expect(401); - - expect(res.body).toEqual({ - error: 'Invalid credentials', + it('should return 404 if user is not found', async () => { + (User.findByPk as jest.Mock).mockResolvedValue(null); + + const response = await request(app) + .put('/user/1') + .send({ username: 'updateduser', name: 'Updated User' }); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: 'User not found' }); + }); + + it('should return 500 if updating user fails', async () => { + (User.findByPk as jest.Mock).mockResolvedValue({ + ...userPayload, + save: jest.fn().mockRejectedValue(new Error('Failed to update user')), }); + + const response = await request(app) + .put('/user/1') + .send({ username: 'updateduser', name: 'Updated User' }); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: 'Failed to update user' }); }); }); - describe('GET /user/all', () => { - test('GET /users/all should fetch all users with their details', async () => { - const res = await request(app).get('/user/all').expect(200); - expect(res.body).toBeInstanceOf(Array); - expect(res.body.length).toBeGreaterThan(0); - res.body.forEach((user: UserDTO) => { - expect(user).toHaveProperty('id'); - expect(user).toHaveProperty('username'); - expect(user).toHaveProperty('name'); - expect(user).toHaveProperty('pollCount'); - expect(user).toHaveProperty('voteCount'); + describe('DELETE /user/:id', () => { + it('should delete a user', async () => { + (User.findByPk as jest.Mock).mockResolvedValue({ + ...userPayload, + destroy: jest.fn().mockResolvedValue(true), }); + (Poll.update as jest.Mock).mockResolvedValue(true); + + const response = await request(app).delete('/user/1'); + + expect(response.status).toBe(204); + }); + + it('should return 404 if user is not found', async () => { + (User.findByPk as jest.Mock).mockResolvedValue(null); + + const response = await request(app).delete('/user/1'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: 'User not found' }); + }); + + it('should return 500 if deleting user fails', async () => { + (User.findByPk as jest.Mock).mockResolvedValue({ + ...userPayload, + destroy: jest + .fn() + .mockRejectedValue(new Error('Failed to delete user')), + }); + + const response = await request(app).delete('/user/1'); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ error: 'Failed to delete user' }); }); }); }); diff --git a/server/tsconfig.json b/server/tsconfig.json index a670cab..45d5703 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -11,13 +11,13 @@ "paths": { "*": ["./node_modules/*", "./src/types/*"] }, - "typeRoots": ["./node_modules/@types"], - "types": ["jest", "node"], + "typeRoots": ["./node_modules/@types", "./types"], + "types": ["jest", "node", "express"], "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, - "include": ["src/**/*"], + "include": ["src/**/*", "types/**/*"], "exclude": ["node_modules", "dist"] } diff --git a/server/types/index.d.ts b/server/types/index.d.ts new file mode 100644 index 0000000..d311fa0 --- /dev/null +++ b/server/types/index.d.ts @@ -0,0 +1,7 @@ +import { User } from '../src/database/models/user'; + +declare module 'express-serve-static-core' { + interface Request { + user?: User; + } +}