diff --git a/backend/.eslintrc.js b/backend/.eslintrc.js index 3859645..3034142 100644 --- a/backend/.eslintrc.js +++ b/backend/.eslintrc.js @@ -21,6 +21,7 @@ module.exports = { 'no-console': 0, 'no-underscore-dangle': ['error', { 'allow': [ '_id', '__v'] }], 'no-param-reassign': ['error', { 'props': false }], + 'curly': 'error', }, ignorePatterns: [ '.eslintrc.js', diff --git a/backend/package-lock.json b/backend/package-lock.json index ccf7117..3888036 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -12,6 +12,7 @@ "@types/express": "^4.17.13", "bcrypt": "^5.1.0", "cloudinary": "^1.29.0", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "cross-env": "^7.0.3", "dotenv": "^16.0.0", @@ -24,6 +25,7 @@ }, "devDependencies": { "@types/bcrypt": "^5.0.0", + "@types/cookie-parser": "^1.4.7", "@types/cors": "^2.8.12", "@types/jest": "^27.4.1", "@types/jsonwebtoken": "^8.5.8", @@ -2454,6 +2456,15 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==", + "dev": true, + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cookiejar": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", @@ -3981,6 +3992,26 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "dependencies": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", @@ -12447,6 +12478,15 @@ "@types/node": "*" } }, + "@types/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==", + "dev": true, + "requires": { + "@types/express": "*" + } + }, "@types/cookiejar": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.2.tgz", @@ -13594,6 +13634,22 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==" }, + "cookie-parser": { + "version": "1.4.6", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.6.tgz", + "integrity": "sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==", + "requires": { + "cookie": "0.4.1", + "cookie-signature": "1.0.6" + }, + "dependencies": { + "cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==" + } + } + }, "cookie-signature": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", diff --git a/backend/package.json b/backend/package.json index 1cfd6c8..0edc97c 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,6 +22,7 @@ "@types/express": "^4.17.13", "bcrypt": "^5.1.0", "cloudinary": "^1.29.0", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "cross-env": "^7.0.3", "dotenv": "^16.0.0", @@ -34,6 +35,7 @@ }, "devDependencies": { "@types/bcrypt": "^5.0.0", + "@types/cookie-parser": "^1.4.7", "@types/cors": "^2.8.12", "@types/jest": "^27.4.1", "@types/jsonwebtoken": "^8.5.8", diff --git a/backend/requests/post-requests.rest b/backend/requests/post-requests.rest index 1e24bb1..e1be640 100644 --- a/backend/requests/post-requests.rest +++ b/backend/requests/post-requests.rest @@ -11,7 +11,7 @@ Content-Type: application/json ### #log in -POST http://localhost:3001/api/login +POST http://localhost:3001/api/auth/login Content-Type: application/json { diff --git a/backend/requests/put-requests.rest b/backend/requests/put-requests.rest index 0fcd77a..fcc0361 100644 --- a/backend/requests/put-requests.rest +++ b/backend/requests/put-requests.rest @@ -24,7 +24,7 @@ Content-Type: application/json ### #log in first user @token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWQiOiI2NWU0ZjZhZTNjMjg4Y2EzODVkYzJhNGEiLCJpYXQiOjE3MDk2MTEzMTAsImV4cCI6MTcwOTYxNDkxMH0.gmUZvHrjxO8ceZVOm0kTh3fYboJldmsCdxMPMyUmbH4 -POST http://localhost:3001/api/login +POST http://localhost:3001/api/auth/login Content-Type: application/json { diff --git a/backend/src/app.ts b/backend/src/app.ts index bd1027d..7c960be 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,11 +1,12 @@ import express from 'express'; import cors from 'cors'; import morgan from 'morgan'; +import cookieParser from 'cookie-parser'; import mongodbConnect, { testMongodb } from './mongo'; import config from './utils/config'; import postRouter from './routes/posts'; import userRouter from './routes/users'; -import loginRouter from './routes/login'; +import authRouter from './routes/auth'; import testRouter from './routes/tests'; import likeRouter from './routes/likes'; import { errorHandler } from './utils/middleware'; @@ -25,6 +26,7 @@ app.get('/health', (_req, res) => { res.send('ok'); }); +app.use(cookieParser()); app.use(cors()); app.use(express.static('build')); app.use(express.json({ limit: '50mb' })); @@ -33,9 +35,12 @@ app.use(morgan('dev')); app.use('/api/posts', postRouter); app.use('/api/users', userRouter); -app.use('/api/login', loginRouter); +app.use('/api/auth', authRouter); app.use('/api/likes', likeRouter); -if (NODE_ENV !== 'production') app.use('/api/test', testRouter); +if (NODE_ENV !== 'production') { + app.use('/api/test', testRouter); +} + app.use(errorHandler); export default app; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts new file mode 100644 index 0000000..506bb2a --- /dev/null +++ b/backend/src/routes/auth.ts @@ -0,0 +1,137 @@ +import jwt from 'jsonwebtoken'; +import bcrypt from 'bcrypt'; +import express from 'express'; +import fieldParsers from '../utils/field-parsers'; +import logger from '../utils/logger'; +import { User } from '../mongo'; +import config from '../utils/config'; + +const router = express.Router(); +const { JWT_SECRET } = config; + +router.post('/login', async (req, res) => { + if (!JWT_SECRET) { + return res.status(500).send({ error: 'JWT_SECRET is not set.' }); + } + + const { body } = req; + let loginFields: { + username: string, + password: string, + }; + + try { + loginFields = fieldParsers.proofLogInFields(body); + } catch (error) { + return res.status(400).send({ error: logger.getErrorMessage(error) }); + } + + try { + const user = await User.findOne({ username: loginFields.username }); + const isUserValidated = !user + ? false + : await bcrypt.compare(loginFields.password, user.passwordHash); + + if (!isUserValidated) { + return res.status(401).send({ + error: 'Invalid username or password.', + }); + } + + const userTokenInfo = { + username: user.username, + id: user.id, + }; + + const accessToken = jwt.sign( + userTokenInfo, + JWT_SECRET, + { expiresIn: '15m' }, + ); + const refreshToken = jwt.sign( + userTokenInfo, + JWT_SECRET, + { expiresIn: '30d' }, + ); + + res.cookie('refreshToken', refreshToken, { + expires: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + }); + + return res.status(200).send({ accessToken, username: user.username }); + } catch (error) { + return res.status(500).send({ error: 'Something went wrong!' }); + } +}); + +router.post('/refresh', async (req, res, next) => { + if (!JWT_SECRET) { + return res.status(500).send({ error: 'JWT_SECRET is not set.' }); + } + + let decodedRefreshToken; + const { refreshToken } = req.cookies; + + if (refreshToken) { + try { + decodedRefreshToken = jwt.verify( + refreshToken, + JWT_SECRET, + ) as jwt.JwtPayload; + } catch (error) { + res.clearCookie('refreshToken'); + + return next(error); + } + } + + if ( + !decodedRefreshToken + || !decodedRefreshToken.id + || !decodedRefreshToken.username + ) { + res.clearCookie('refreshToken'); + + return res.status(401).send({ + error: 'refresh token missing or invalid.', + }); + } + + const user = await User.findById(decodedRefreshToken.id); + + if (!user) { + res.clearCookie('refreshToken'); + + return res.status(401).send({ + error: 'refresh token is not valid for any user.', + }); + } + + // generate new access token + const userTokenInfo = { + username: user.username, + id: decodedRefreshToken.id, + }; + + const newAccessToken = jwt.sign( + userTokenInfo, + JWT_SECRET, + { expiresIn: '15m' }, + ); + + return res.status(200).send( + { + accessToken: newAccessToken, + username: user.username, + }, + ); +}); + +router.post('/logout', (_req, res) => { + res.clearCookie('refreshToken'); + res.status(204).end(); +}); + +export default router; diff --git a/backend/src/routes/login.ts b/backend/src/routes/login.ts index 3d123ad..e69de29 100644 --- a/backend/src/routes/login.ts +++ b/backend/src/routes/login.ts @@ -1,53 +0,0 @@ -import jwt from 'jsonwebtoken'; -import bcrypt from 'bcrypt'; -import express from 'express'; -import fieldParsers from '../utils/field-parsers'; -import logger from '../utils/logger'; -import { User } from '../mongo'; - -const router = express.Router(); - -router.post('/', async (req, res) => { - const { body } = req; - let loginFields: { - username: string, - password: string, - }; - - try { - loginFields = fieldParsers.proofLogInFields(body); - } catch (error) { - return res.status(400).send({ error: logger.getErrorMessage(error) }); - } - - try { - // TODO: populate fields? frontend needs the image and posts of the logged in user. - const user = await User.findOne({ username: loginFields.username }); - const isUserValidated = !user - ? false - : await bcrypt.compare(loginFields.password, user.passwordHash); - - if (!isUserValidated) { - return res.status(401).send({ - error: 'Invalid username or password.', - }); - } - - const userTokenInfo = { - username: user.username, - id: user.id, - }; - - const token = jwt.sign( - userTokenInfo, - process.env.SECRET = 'scrt', - { expiresIn: 60 * 60 }, - ); - - return res.status(200).send({ token, username: user.username }); - } catch (error) { - return res.status(500).send({ error: 'Something went wrong!' }); - } -}); - -export default router; diff --git a/backend/src/utils/config.ts b/backend/src/utils/config.ts index bf0f1e9..7bda809 100644 --- a/backend/src/utils/config.ts +++ b/backend/src/utils/config.ts @@ -6,6 +6,7 @@ const { CLOUDINARY_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET, + JWT_SECRET, } = process.env; export default { @@ -14,4 +15,5 @@ export default { CLOUDINARY_NAME, CLOUDINARY_API_KEY, CLOUDINARY_API_SECRET, + JWT_SECRET, }; diff --git a/backend/src/utils/middleware.ts b/backend/src/utils/middleware.ts index 5f25a4e..4b5ef2b 100644 --- a/backend/src/utils/middleware.ts +++ b/backend/src/utils/middleware.ts @@ -1,15 +1,23 @@ import { Request, Response, NextFunction } from 'express'; import jwt, { JwtPayload } from 'jsonwebtoken'; import logger from './logger'; +import config from './config'; + +const { JWT_SECRET } = config; export const authenticator = () => ( req: Request, res: Response, next: NextFunction, ) => { + if (!JWT_SECRET) { + return res.status(500).send({ error: 'JWT_SECRET is not set.' }); + } + let token; let decodedToken; const authorization = req.get('authorization'); + if (authorization && authorization.toLowerCase().startsWith('bearer')) { token = authorization.substring(7); } @@ -17,7 +25,7 @@ export const authenticator = () => ( if (token) { decodedToken = jwt.verify( token, - process.env.SECRET as string, + JWT_SECRET, ) as JwtPayload; } diff --git a/backend/test/auth-api.test.ts b/backend/test/auth-api.test.ts new file mode 100644 index 0000000..cce7277 --- /dev/null +++ b/backend/test/auth-api.test.ts @@ -0,0 +1,291 @@ +import supertest from 'supertest'; +import jwt, { JwtPayload } from 'jsonwebtoken'; +import app from '../src/app'; +import testHelpers from './helpers/test-helpers'; +import { testMongodb } from '../src/mongo'; +import { User } from '../src/types'; +import config from '../src/utils/config'; + +const api = supertest(app); +const LOGIN_URL = '/api/auth/login'; +const REFRESH_URL = '/api/auth/refresh'; +const { JWT_SECRET } = config; + +beforeAll(async () => { await testMongodb.connect(); }); +beforeEach(async () => { await testMongodb.clear(); }); +afterAll(async () => { await testMongodb.close(); }); + +let testUser: User; + +describe('successful login', () => { + beforeEach(async () => { + testUser = await testHelpers.createTestUser({ + username: 'admin', + fullName: 'Bobby Bo', + email: 'bobby@email.com', + password: 'secret', + }); + }); + + test('responds with 200 code and returns JSON', async () => { + await api + .post(LOGIN_URL) + .send({ username: testUser.username, password: 'secret' }) + .expect(200) + .expect('Content-Type', /application\/json/); + }); + + test('responds with the access token', async () => { + const response = await api + .post(LOGIN_URL) + .send({ username: testUser.username, password: 'secret' }); + + expect(response.body.accessToken).toBeDefined(); + }); + + test('decoded access token contains username and id', async () => { + const response = await api + .post(LOGIN_URL) + .send({ username: testUser.username, password: 'secret' }); + + const decodedToken = jwt.verify( + response.body.accessToken, + JWT_SECRET || '', + ) as JwtPayload; + + expect(decodedToken).toBeDefined(); + expect(decodedToken.id).toBe(testUser.id); + expect(decodedToken.username).toBe(testUser.username); + }); + + test('sets a cookie with the refresh token', async () => { + const response = await api + .post(LOGIN_URL) + .send({ username: testUser.username, password: 'secret' }); + + const refreshTokenCookie = response.header['set-cookie'][0]; + + expect(refreshTokenCookie).toBeDefined(); + expect(refreshTokenCookie).toMatch(/refreshToken=/); + + const refreshToken = refreshTokenCookie.split(';')[0].split('=')[1]; + const decodedToken = jwt.verify( + refreshToken, + JWT_SECRET || '', + ) as JwtPayload; + + expect(decodedToken).toBeDefined(); + expect(decodedToken.id).toBe(testUser.id); + expect(decodedToken.username).toBe(testUser.username); + }); +}); + +describe('unsuccessful login', () => { + test('login with non-existing username fails with 401 error code', async () => { + const nonExistingUser = { + username: 'i dont exist', + password: 'i also dont exist', + }; + + const response = await api + .post(LOGIN_URL) + .send(nonExistingUser) + .expect(401) + .expect('Content-Type', /application\/json/); + expect(response.body.error).toMatch(/invalid username or password/i); + }); + + test('login with wrong password responds with 401 error code', async () => { + const user = await testHelpers.createTestUser({ + username: 'admin', + fullName: 'Bobby Bo', + email: 'bobby@email.com', + password: 'secret', + }); + + const invalidLoginInfo = { + username: user.username, + password: 'wrong password', + }; + + const response = await api + .post(LOGIN_URL) + .send(invalidLoginInfo) + .expect(401) + .expect('Content-Type', /application\/json/); + + expect(response.body.error).toMatch(/invalid username or password/i); + }); + + test('does not set a cookie with the refresh token', async () => { + const user = await testHelpers.createTestUser({ + username: 'admin', + fullName: 'Bobby Bo', + email: 'bobby@email.com', + password: 'secret', + }); + + const invalidLoginInfo = { + username: user.username, + password: 'wrong password', + }; + + const response = await api + .post(LOGIN_URL) + .send(invalidLoginInfo) + .expect(401) + .expect('Content-Type', /application\/json/); + + expect(response.headers['set-cookie']).toBeUndefined(); + }); +}); + +describe('successful token refresh', () => { + let refreshTokenCookie: string; + + beforeEach(async () => { + testUser = await testHelpers.createTestUser({ + username: 'admin', + fullName: 'Bobby Bo', + email: 'bobby@email.com', + password: 'secret', + }); + + const response = await api + .post(LOGIN_URL) + .send({ username: testUser.username, password: 'secret' }) + .expect(200); + + [refreshTokenCookie] = response.header['set-cookie']; + }); + + test('responds with 200 code and returns JSON', async () => { + await api + .post('/api/auth/refresh') + .expect(200) + .set('Cookie', refreshTokenCookie) + .expect('Content-Type', /application\/json/); + }); + + test('responds with a new access token and username', async () => { + const response = await api + .post('/api/auth/refresh') + .set('Cookie', refreshTokenCookie); + + expect(response.body.accessToken).toBeDefined(); + expect(response.body.username).toBeDefined(); + expect(response.body.username).toBe(testUser.username); + }); + + test('decoded access token contains username and id', async () => { + const response = await api + .post('/api/auth/refresh') + .set('Cookie', refreshTokenCookie); + + const decodedToken = jwt.verify( + response.body.accessToken, + JWT_SECRET || '', + ) as JwtPayload; + + expect(decodedToken).toBeDefined(); + expect(decodedToken.id).toBe(testUser.id); + expect(decodedToken.username).toBe(testUser.username); + }); + + test('when user updates their username and refreshes token, the new username is returned', async () => { + const updatedUserFields = { username: 'newusername' }; + + // logging in to get access token + const loginResponse = await api + .post(LOGIN_URL) + .send({ username: testUser.username, password: 'secret' }); + + const { accessToken } = loginResponse.body; + + await api + .put(`/api/users/${testUser.id}`) + .send(updatedUserFields) + .set('authorization', `bearer ${accessToken}`) + .expect(200); + + const refreshResponse = await api + .post('/api/auth/refresh') + .set('Cookie', refreshTokenCookie); + + expect(refreshResponse.body.username).toBe(updatedUserFields.username); + }); +}); + +describe('unsuccessful token refresh', () => { + test('responds with 401 code if no refresh token is sent', async () => { + await api + .post(REFRESH_URL) + .expect(401); + }); + + test('returns no access token', async () => { + const response = await api + .post(REFRESH_URL) + .expect(401); + + expect(response.body.accessToken).toBeUndefined(); + }); + + test('responds with 401 code if invalid refresh token is sent', async () => { + await api + .post(REFRESH_URL) + .set('Cookie', 'refreshToken=invalidtoken') + .expect(401); + }); + + test('responds with 401 code if expired refresh token is sent', async () => { + const expiredToken = jwt.sign( + { id: '123', username: 'test' }, + JWT_SECRET || '', + { expiresIn: -1 }, + ); + + await api + .post(REFRESH_URL) + .set('Cookie', `refreshToken=${expiredToken}`) + .expect(401); + }); +}); + +describe('logout', () => { + let refreshTokenCookie: string; + + beforeEach(async () => { + testUser = await testHelpers.createTestUser({ + username: 'admin', + fullName: 'Bobby Bo', + email: 'bobby@email.com', + password: 'secret', + }); + + const response = await api + .post(LOGIN_URL) + .send({ username: testUser.username, password: 'secret' }) + .expect(200); + + [refreshTokenCookie] = response.header['set-cookie']; + }); + + test('responds with 204 code', async () => { + await api + .post('/api/auth/logout') + .set('Cookie', refreshTokenCookie) + .expect(204); + }); + + test('clears the refresh token cookie', async () => { + const response = await api + .post('/api/auth/logout') + .set('Cookie', refreshTokenCookie); + + const setCookieHeader = response.headers['set-cookie']; + + expect(setCookieHeader).toBeDefined(); + expect(setCookieHeader[0]).toMatch(/refreshToken=;/); + }); +}); diff --git a/backend/test/comment-api.test.ts b/backend/test/comment-api.test.ts index 369a167..f8ca0ac 100644 --- a/backend/test/comment-api.test.ts +++ b/backend/test/comment-api.test.ts @@ -5,7 +5,7 @@ import { User } from '../src/types'; import testHelpers from './helpers/test-helpers'; const api = supertest(app); -let token: string; +let accessToken: string; let testUser: User; beforeAll(async () => { await testMongodb.connect(); }); @@ -19,13 +19,13 @@ beforeEach(async () => { }); const response = await api - .post('/api/login') + .post('/api/auth/login') .send({ username: testUser.username, password: 'secret', }); - token = response.body.token; + accessToken = response.body.accessToken; const initialPosts = [ { @@ -54,7 +54,7 @@ beforeEach(async () => { afterAll(async () => { await testMongodb.close(); }); describe('when creating comments', () => { - test('request without token should fail', async () => { + test('request without accessToken should fail', async () => { const post = await Post.findOne({ creator: testUser.id }); const response = await api @@ -67,7 +67,6 @@ describe('when creating comments', () => { test('request without comment body should fail', async () => { const post = await Post.findOne({ creator: testUser.id }); - const invalidComment = { post: post.id, author: testUser.id, @@ -80,13 +79,13 @@ describe('when creating comments', () => { const responseOne = await api .post(`/api/posts/${post.id}/comments`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send(invalidComment) .expect(400); const responseTwo = await api .post(`/api/posts/${post.id}/comments`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send(alsoInvalidComment) .expect(400); @@ -106,7 +105,7 @@ describe('when creating comments', () => { const response = await api .post(`/api/posts/${postId}/comments`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send(comment) .expect(404); @@ -122,7 +121,7 @@ describe('when creating comments', () => { await api .post(`/api/posts/${post.id}/comments`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send(validComment) .expect(201); @@ -150,7 +149,7 @@ describe('when creating comments', () => { const response = await api .post(`/api/posts/${post.id}/comments`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send(reply) .expect(404); @@ -176,7 +175,7 @@ describe('when creating comments', () => { await api .post(`/api/posts/${post.id}/comments`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send(reply) .expect(201); @@ -206,7 +205,7 @@ describe('when getting parent comments', () => { await api .delete(`/api/posts/${postId}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); const response = await api @@ -268,7 +267,7 @@ describe('when getting replies', () => { await api .delete(`/api/posts/${postId}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); const response = await api @@ -370,12 +369,12 @@ describe('when deleting comments', () => { await api .delete(`/api/posts/${postId}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); const response = await api .delete(`/api/posts/${postId}/comments/${comment.id}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(404); expect(response.body.error).toMatch(/post not found/i); @@ -397,7 +396,7 @@ describe('when deleting comments', () => { const response = await api .delete(`/api/posts/${post.id}/comments/${comment.id}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(404); expect(response.body.error).toMatch(/comment not found/i); @@ -427,7 +426,7 @@ describe('when deleting comments', () => { const response = await api .delete(`/api/posts/${post.id}/comments/${replyComment.id}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(404); expect(response.body.error).toMatch(/comment not found/i); @@ -447,7 +446,7 @@ describe('when deleting comments', () => { await api .delete(`/api/posts/${post.id}/comments/${comment.id}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); const updatedPost = await Post.findById(post.id); @@ -477,7 +476,7 @@ describe('when deleting comments', () => { await api .delete(`/api/posts/${post.id}/comments/${replyComment.id}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); const updatedParentComment = await Comment.findById(parentComment.id); @@ -512,7 +511,7 @@ describe('when deleting comments', () => { await api .delete(`/api/posts/${post.id}/comments/${parentComment.id}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); const updatedPost = await Post.findById(post.id); diff --git a/backend/test/like-api.test.ts b/backend/test/like-api.test.ts index e8e8cfc..183c59a 100644 --- a/backend/test/like-api.test.ts +++ b/backend/test/like-api.test.ts @@ -7,7 +7,7 @@ import testHelpers from './helpers/test-helpers'; import { User } from '../src/types'; const api = supertest(app); -let token: string; +let accessToken: string; let testUser: User; beforeAll(async () => { await testMongodb.connect(); }); @@ -21,13 +21,13 @@ beforeEach(async () => { }); const response = await api - .post('/api/login') + .post('/api/auth/login') .send({ username: testUser.username, password: 'secret', }); - token = response.body.token; + accessToken = response.body.accessToken; const initialPosts = [ { @@ -56,7 +56,7 @@ beforeEach(async () => { afterAll(async () => { await testMongodb.close(); }); describe('when liking entities', () => { - test('request without token should fail', async () => { + test('request without accessToken should fail', async () => { const post = await Post.findOne({ creator: testUser.id }); const response = await api @@ -78,7 +78,7 @@ describe('when liking entities', () => { const response = await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Post', @@ -94,7 +94,7 @@ describe('when liking entities', () => { const response = await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Comment', @@ -110,7 +110,7 @@ describe('when liking entities', () => { const response = await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'InvalidModel', @@ -126,7 +126,7 @@ describe('when liking entities', () => { await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Post', @@ -157,7 +157,7 @@ describe('when liking entities', () => { await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Comment', @@ -178,7 +178,7 @@ describe('when liking entities', () => { await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Post', @@ -191,7 +191,7 @@ describe('when liking entities', () => { const response = await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Post', @@ -207,7 +207,7 @@ describe('when liking entities', () => { }); describe('when unliking entities', () => { - test('request without token should fail', async () => { + test('request without accessToken should fail', async () => { const post = await Post.findOne({ creator: testUser.id }); const entityId = post.id; @@ -226,7 +226,7 @@ describe('when unliking entities', () => { await api .delete(`/api/likes/${entityId}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); }); @@ -236,7 +236,7 @@ describe('when unliking entities', () => { await api .delete(`/api/likes/${entityId}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); }); @@ -246,7 +246,7 @@ describe('when unliking entities', () => { await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Post', @@ -259,7 +259,7 @@ describe('when unliking entities', () => { await api .delete(`/api/likes/${entityId}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); const postLikeCountAfter = await Like.countDocuments({ 'likedEntity.id': entityId }); @@ -283,7 +283,7 @@ describe('when unliking entities', () => { await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Comment', @@ -296,7 +296,7 @@ describe('when unliking entities', () => { await api .delete(`/api/likes/${entityId}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); const commentLikeCountAfter = await Like.countDocuments({ 'likedEntity.id': entityId }); @@ -310,7 +310,7 @@ describe('when unliking entities', () => { await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Post', @@ -323,12 +323,12 @@ describe('when unliking entities', () => { await api .delete(`/api/likes/${entityId}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); await api .delete(`/api/likes/${entityId}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); const postLikeCountAfter = await Like.countDocuments({ 'likedEntity.id': entityId }); @@ -344,7 +344,7 @@ describe('when getting like count', () => { await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Post', @@ -414,7 +414,7 @@ describe('when getting like users', () => { await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Post', @@ -432,7 +432,7 @@ describe('when getting like users', () => { }); describe('when getting has user liked entity', () => { - test('request without token should fail', async () => { + test('request without accessToken should fail', async () => { const post = await Post.findOne({ creator: testUser.id }); const entityId = post.id; @@ -451,7 +451,7 @@ describe('when getting has user liked entity', () => { const response = await api .get(`/api/likes/${entityId}/hasLiked`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(200); expect(response.body.hasLiked).toBe(false); @@ -463,7 +463,7 @@ describe('when getting has user liked entity', () => { const response = await api .get(`/api/likes/${entityId}/hasLiked`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(200); expect(response.body.hasLiked).toBe(false); @@ -475,7 +475,7 @@ describe('when getting has user liked entity', () => { await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Post', @@ -484,7 +484,7 @@ describe('when getting has user liked entity', () => { const response = await api .get(`/api/likes/${entityId}/hasLiked`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(200); expect(response.body.hasLiked).toBe(true); @@ -496,7 +496,7 @@ describe('when getting has user liked entity', () => { await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Post', @@ -505,12 +505,12 @@ describe('when getting has user liked entity', () => { await api .delete(`/api/likes/${entityId}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); const response = await api .get(`/api/likes/${entityId}/hasLiked`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(200); expect(response.body.hasLiked).toBe(false); @@ -528,11 +528,11 @@ describe('when getting has user liked entity', () => { }); const otherUserToken = (await api - .post('/api/login') + .post('/api/auth/login') .send({ username: otherUser.username, password: 'secret', - })).body.token; + })).body.accessToken; // liking the post with the other user @@ -549,7 +549,7 @@ describe('when getting has user liked entity', () => { const response = await api .get(`/api/likes/${entityId}/hasLiked`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(200); expect(response.body.hasLiked).toBe(false); @@ -563,7 +563,7 @@ describe('when deleting entities with likes', () => { await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Post', @@ -576,7 +576,7 @@ describe('when deleting entities with likes', () => { await api .delete(`/api/posts/${entityId}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); const postLikeCountAfter = await Like.countDocuments({ 'likedEntity.id': entityId }); @@ -601,7 +601,7 @@ describe('when deleting entities with likes', () => { await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Comment', @@ -614,7 +614,7 @@ describe('when deleting entities with likes', () => { await api .delete(`/api/posts/${post.id}/comments/${entityId}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); const commentLikeCountAfter = await Like.countDocuments({ 'likedEntity.id': entityId }); @@ -639,7 +639,7 @@ describe('when deleting entities with likes', () => { await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Comment', @@ -652,7 +652,7 @@ describe('when deleting entities with likes', () => { await api .delete(`/api/posts/${post.id}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); const commentLikeCountAfter = await Like.countDocuments({ 'likedEntity.id': entityId }); @@ -686,7 +686,7 @@ describe('when deleting entities with likes', () => { await api .post('/api/likes') - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .send({ entityId, entityModel: 'Comment', @@ -699,7 +699,7 @@ describe('when deleting entities with likes', () => { await api .delete(`/api/posts/${post.id}/comments/${parentComment.id}`) - .set('Authorization', `Bearer ${token}`) + .set('Authorization', `Bearer ${accessToken}`) .expect(204); const replyLikeCountAfter = await Like.countDocuments({ 'likedEntity.id': entityId }); diff --git a/backend/test/login-api.test.ts b/backend/test/login-api.test.ts deleted file mode 100644 index d95ab2f..0000000 --- a/backend/test/login-api.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import supertest from 'supertest'; -import jwt, { JwtPayload } from 'jsonwebtoken'; -import app from '../src/app'; -import testHelpers from './helpers/test-helpers'; -import { testMongodb } from '../src/mongo'; -import { User } from '../src/types'; - -const api = supertest(app); - -beforeAll(async () => { await testMongodb.connect(); }); -beforeEach(async () => { await testMongodb.clear(); }); -afterAll(async () => { await testMongodb.close(); }); - -let testUser: User; - -describe('successful login', () => { - beforeEach(async () => { - testUser = await testHelpers.createTestUser({ - username: 'admin', - fullName: 'Bobby Bo', - email: 'bobby@email.com', - password: 'secret', - }); - }); - - test('responds with 200 code and returns JSON', async () => { - await api - .post('/api/login') - .send({ username: testUser.username, password: 'secret' }) - .expect(200) - .expect('Content-Type', /application\/json/); - }); - - test('responds with the token', async () => { - const response = await api - .post('/api/login') - .send({ username: testUser.username, password: 'secret' }); - - expect(response.body.token).toBeDefined(); - }); - - test('decoded token contains username and id', async () => { - const response = await api - .post('/api/login') - .send({ username: testUser.username, password: 'secret' }); - - const decodedToken = jwt.verify( - response.body.token, - process.env.SECRET as string, - ) as JwtPayload; - - expect(decodedToken).toBeDefined(); - expect(decodedToken.id).toBe(testUser.id); - expect(decodedToken.username).toBe(testUser.username); - }); -}); - -describe('unsuccessful login', () => { - test('login with non-existing username fails with 401 error code', async () => { - const nonExistingUser = { - username: 'i dont exist', - password: 'i also dont exist', - }; - - const response = await api - .post('/api/login') - .send(nonExistingUser) - .expect(401) - .expect('Content-Type', /application\/json/); - expect(response.body.error).toMatch(/invalid username or password/i); - }); - - test('login with wrong password responds with 401 error code', async () => { - const user = await testHelpers.createTestUser({ - username: 'admin', - fullName: 'Bobby Bo', - email: 'bobby@email.com', - password: 'secret', - }); - - const invalidLoginInfo = { - username: user.username, - password: 'wrong password', - }; - - const response = await api - .post('/api/login') - .send(invalidLoginInfo) - .expect(401) - .expect('Content-Type', /application\/json/); - - expect(response.body.error).toMatch(/invalid username or password/i); - }); -}); diff --git a/backend/test/post-api.test.ts b/backend/test/post-api.test.ts index 22fff88..e2a7115 100644 --- a/backend/test/post-api.test.ts +++ b/backend/test/post-api.test.ts @@ -7,7 +7,7 @@ import { User } from '../src/types'; import cloudinary from '../src/utils/cloudinary'; const api = supertest(app); -let token: string; +let accessToken: string; let testUser: User; beforeAll(async () => { await testMongodb.connect(); }); @@ -21,13 +21,13 @@ beforeEach(async () => { }); const response = await api - .post('/api/login') + .post('/api/auth/login') .send({ username: testUser.username, password: 'secret', }); - token = response.body.token; + accessToken = response.body.accessToken; const initialPosts = [ { @@ -62,7 +62,7 @@ describe('when there are posts in the database', () => { await api .get(`/api/posts/${targetPost.id}`) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(200) .expect('Content-Type', /application\/json/); }); @@ -72,7 +72,7 @@ describe('when there are posts in the database', () => { const response = await api .get(`/api/posts/${targetPost.id}`) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(200); const fetchedPost = response.body; @@ -85,7 +85,7 @@ describe('when there are posts in the database', () => { const targetPost = (await testHelpers.postsInDB())[0]; const response = await api .get(`/api/posts/${targetPost.id}`) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(200); const fetchedPost = response.body; @@ -96,7 +96,7 @@ describe('when there are posts in the database', () => { }); describe('when creating posts', () => { - test('request without token fails with 401 error code.', async () => { + test('request without accessToken fails with 401 error code.', async () => { const response = await api .post('/api/posts') .expect(401) @@ -117,7 +117,7 @@ describe('when there are posts in the database', () => { const response = await api .post('/api/posts') .send(invalidPostFields) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(400); expect(response.body.error).toMatch(/missing image/i); @@ -132,7 +132,7 @@ describe('when there are posts in the database', () => { await api .post('/api/posts') .send(validPostFields) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(201); const posts = await testHelpers.postsInDB(); @@ -149,7 +149,7 @@ describe('when there are posts in the database', () => { await api .post('/api/posts') .send(validPostFields) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(201); const posts = await testHelpers.postsInDB(); @@ -165,7 +165,7 @@ describe('when there are posts in the database', () => { await api .post('/api/posts') .send(validPostFields) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(201); const endPosts = await testHelpers.postsInDB(); @@ -175,7 +175,7 @@ describe('when there are posts in the database', () => { }); describe('when updating posts', () => { - test('request without token fails with 401 error code', async () => { + test('request without accessToken fails with 401 error code', async () => { const targetPost = (await testHelpers.postsInDB())[0]; const updatedPostFields = { caption: 'new caption', @@ -200,13 +200,13 @@ describe('when there are posts in the database', () => { }); const tokenResponse = await api - .post('/api/login') + .post('/api/auth/login') .send({ username: differentUser.username, password: 'secret', }); - const wrongUserToken = tokenResponse.body.token; + const wrongUserToken = tokenResponse.body.accessToken; const updatedPostFields = { caption: 'new caption', @@ -232,7 +232,7 @@ describe('when there are posts in the database', () => { await api .put(`/api/posts/${targetPost.id}`) .send(updatedPostFields) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(200); }); @@ -246,7 +246,7 @@ describe('when there are posts in the database', () => { const response = await api .put(`/api/posts/${targetPost.id}`) .send(updatedPostFields) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(200) .expect('Content-Type', /application\/json/); @@ -265,7 +265,7 @@ describe('when there are posts in the database', () => { const response = await api .put(`/api/posts/${targetPost.id}`) .send(updatedPostFields) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(200) .expect('Content-Type', /application\/json/); @@ -278,7 +278,7 @@ describe('when there are posts in the database', () => { }); describe('when deleting posts', () => { - test('request without token fails with 401 error code', async () => { + test('request without accessToken fails with 401 error code', async () => { const targetPost = (await testHelpers.postsInDB())[0]; await api @@ -290,7 +290,7 @@ describe('when there are posts in the database', () => { test('when user making request is not creator of post, fails with 401 error code', async () => { const targetPost = (await testHelpers.postsInDB())[0]; - // creating a new user and logging them in to get a token + // creating a new user and logging them in to get a accessToken const differentUser = await testHelpers.createTestUser({ username: 'dobbybo', @@ -300,13 +300,13 @@ describe('when there are posts in the database', () => { }); const tokenResponse = await api - .post('/api/login') + .post('/api/auth/login') .send({ username: differentUser.username, password: 'secret', }); - const wrongUserToken = tokenResponse.body.token; + const wrongUserToken = tokenResponse.body.accessToken; const response = await api .delete(`/api/posts/${targetPost.id}`) @@ -323,7 +323,7 @@ describe('when there are posts in the database', () => { const response = await api .delete(`/api/posts/${targetPostId}`) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(404); expect(response.body.error).toMatch(/post not found/i); @@ -335,7 +335,7 @@ describe('when there are posts in the database', () => { await api .delete(`/api/posts/${targetPost.id}`) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(204); const endPosts = await testHelpers.postsInDB(); @@ -356,7 +356,7 @@ describe('when there are posts in the database', () => { const response = await api .post('/api/posts') .send(validPostFields) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(201); const newPost = response.body; @@ -365,7 +365,7 @@ describe('when there are posts in the database', () => { await api .delete(`/api/posts/${newPost.id}`) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(204); expect(await cloudinary.checkIfImageExists(newPost.image.publicId)).toBe(false); diff --git a/backend/test/user-api.test.ts b/backend/test/user-api.test.ts index 736848a..d745ae8 100644 --- a/backend/test/user-api.test.ts +++ b/backend/test/user-api.test.ts @@ -158,7 +158,7 @@ describe('When there are multiple users in the database', () => { describe('When updating a user', () => { let targetUser: UserType; - let token: string; + let accessToken: string; jest.setTimeout(15000); beforeEach(async () => { @@ -166,13 +166,13 @@ describe('When there are multiple users in the database', () => { targetUser = (await testHelpers.usersInDB())[0]; const response = await api - .post('/api/login') + .post('/api/auth/login') .send({ username: targetUser.username, password: 'secret', }); - token = response.body.token; + accessToken = response.body.accessToken; }); test('update request without token fails with 401 error code', async () => { @@ -201,7 +201,7 @@ describe('When there are multiple users in the database', () => { const response = await api .put(`/api/users/${differentId}`) .send(updatedUserFields) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(401); expect(response.body.error).toMatch(/unauthorized/i); @@ -215,7 +215,7 @@ describe('When there are multiple users in the database', () => { const response = await api .put(`/api/users/${targetUser.id}`) .send(updatedUserFields) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(200) .expect('Content-Type', /application\/json/); @@ -231,7 +231,7 @@ describe('When there are multiple users in the database', () => { const response = await api .put(`/api/users/${targetUser.id}`) .send(updatedUserFields) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(200); const returnedUser = response.body; @@ -246,7 +246,7 @@ describe('When there are multiple users in the database', () => { test('user can not follow themselves', async () => { const response = await api .put(`/api/users/${targetUser.id}/follow`) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(400); const nonUpdatedUser = (await testHelpers.usersInDB()) @@ -262,12 +262,12 @@ describe('When there are multiple users in the database', () => { await api .put(`/api/users/${differentUser.id}/follow`) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(200); const response = await api .put(`/api/users/${differentUser.id}/follow`) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(400); expect(response.body.error).toMatch(/you already follow that user/i); @@ -279,7 +279,7 @@ describe('When there are multiple users in the database', () => { await api .put(`/api/users/${differentUser.id}/follow`) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(200); const followedUser = (await testHelpers.usersInDB()).find( @@ -296,7 +296,7 @@ describe('When there are multiple users in the database', () => { await api .put(`/api/users/${differentUser.id}/follow`) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(200); const updatedTargetUser = (await testHelpers.usersInDB()) @@ -313,7 +313,7 @@ describe('When there are multiple users in the database', () => { await api .put(`/api/users/${differentUserId}/follow`) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(200); let updatedTargetUser = (await testHelpers.usersInDB()).find( @@ -330,7 +330,7 @@ describe('When there are multiple users in the database', () => { await api .put(`/api/users/${differentUserId}/unfollow`) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(200); updatedTargetUser = (await testHelpers.usersInDB()).find( @@ -359,14 +359,14 @@ describe('When there are multiple users in the database', () => { }; const response = await api - .post('/api/login') + .post('/api/auth/login') .send({ username: targetUser.username, password: 'secret' }); - const { token } = response.body; + const { accessToken } = response.body; await api .post('/api/posts') - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .send(post); }); @@ -400,16 +400,16 @@ describe('When there are multiple users in the database', () => { test('user profile image can be posted and deleted', async () => { const initalUser = (await testHelpers.usersInDB())[0]; const tokenResponse = await api - .post('/api/login') + .post('/api/auth/login') .send({ username: initalUser.username, password: 'secret', }); - const { token } = tokenResponse.body; + const { accessToken } = tokenResponse.body; await api .put(`/api/users/${initalUser.id}`) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .send({ imageDataUrl: testDataUri }) .expect(200); @@ -421,7 +421,7 @@ describe('When there are multiple users in the database', () => { await api .delete(`/api/users/${initalUser.id}/image`) - .set('Authorization', `bearer ${token}`) + .set('Authorization', `bearer ${accessToken}`) .expect(204); const endUser = (await testHelpers.usersInDB()).find((user) => user.id === initalUser.id); diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js index 41b4a16..8c0fc15 100644 --- a/frontend/.eslintrc.js +++ b/frontend/.eslintrc.js @@ -35,7 +35,7 @@ module.exports = { ] }], 'react/jsx-props-no-spreading': ['error', {'custom': 'ignore'}], - 'import/no-extraneous-dependencies': ['error', { 'devDependencies': ['**/test/**/*.[jt]s?(x)'] }], + 'import/no-extraneous-dependencies': ['error', { 'devDependencies': ['**/test/**/*.[jt]s?(x)', '**/*.test.[jt]s?(x)', '**/*.spec.[jt]s?(x)'] }], "jsx-a11y/label-has-associated-control": [ "error", { diff --git a/frontend/cypress/integration/auth-flow.spec.ts b/frontend/cypress/integration/auth-flow.spec.ts new file mode 100644 index 0000000..d78de7c --- /dev/null +++ b/frontend/cypress/integration/auth-flow.spec.ts @@ -0,0 +1,143 @@ +beforeEach(() => { + cy.request('POST', 'http://localhost:3001/api/test/reset'); +}); + +describe('login view', () => { + beforeEach(() => { + cy.visit('http://localhost:3000/login'); + }); + + it('displays the login view', () => { + cy.get('input[name="username"]'); + cy.get('input[name="password"]'); + cy.contains(/don't have an account/i).should('be.visible'); + }); + + it('allows users to login', () => { + const user1 = Cypress.env('user1'); + cy.createUser(user1); + + cy.get('input[name="username"]').type(user1.username); + cy.get('input[name="password"]').type(user1.password); + cy.get('button[type="submit"]').click(); + + cy.get('[data-testid="homepage-container"]').should('be.visible'); + }); + + it('displays error message on unsuccessful login', () => { + const user1 = Cypress.env('user1'); + cy.createUser(user1); + + cy.get('input[name="username"]').type(user1.username); + cy.get('input[name="password"]').type('invalidPassword'); + cy.get('button[type="submit"]').click(); + + cy.get('[data-testid="homepage-container"]').should('not.exist'); + cy.contains(/invalid username or password/i).should('be.visible'); + }); +}); + +describe('logout', () => { + const user1 = Cypress.env('user1'); + + beforeEach(() => { + cy.createUser(user1); + cy.login({ username: user1.username, password: user1.password }); + }); + + it('user can logout on desktop', () => { + cy.get('[data-testid="usermenu"]').click(); + cy.contains(/log out/i).click(); + + cy.get('[data-testid="homepage-container"]').should('not.exist'); + cy.contains(/don't have an account/i).should('be.visible'); + }); + + it('user can logout on mobile', () => { + cy.viewport('iphone-6'); + cy.visit(`http://localhost:3000/${user1.username}`); + + cy.get('[data-testid="user-profile-info-dots"]').click(); + + cy.contains(/log out/i).click(); + + cy.get('[data-testid="homepage-container"]').should('not.exist'); + cy.contains(/don't have an account/i).should('be.visible'); + }); +}); + +describe('logged in state persistence', () => { + it('maintains the logged in state of a user after a page reload', () => { + const user1 = Cypress.env('user1'); + cy.createUser(user1); + + cy.login({ username: user1.username, password: user1.password }); + + cy.get('[data-testid="homepage-container"]').should('be.visible'); + + cy.reload(); + + cy.get('[data-testid="homepage-container"]').should('be.visible'); + }); +}); + +describe('signup view', () => { + beforeEach(() => { + cy.visit('http://localhost:3000/signup'); + }); + + it('displays the signup view', () => { + cy.contains(/sign up to see photos/i); + cy.get('input[name="email"]'); + cy.get('input[name="username"]'); + cy.get('input[name="password"]'); + cy.get('input[name="fullName"]'); + cy.contains(/have an account/i).should('be.visible'); + }); + + it('allows users to signup', () => { + const user1 = Cypress.env('user1'); + + cy.get('input[name="email"]').type(user1.email); + cy.get('input[name="username"]').type(user1.username); + cy.get('input[name="password"]').type(user1.password); + cy.get('input[name="fullName"]').type(user1.fullName); + + cy.get('button[type="submit"]').click(); + + cy.get('[data-testid="homepage-container"]').should('be.visible'); + }); + + it('logs in the user after signup', () => { + const user1 = Cypress.env('user1'); + + cy.get('input[name="email"]').type(user1.email); + cy.get('input[name="username"]').type(user1.username); + cy.get('input[name="password"]').type(user1.password); + cy.get('input[name="fullName"]').type(user1.fullName); + + cy.get('button[type="submit"]').click(); + + cy.get('[data-testid="homepage-container"]').should('be.visible'); + + cy.reload(); + + cy.get('[data-testid="homepage-container"]').should('be.visible'); + }); + + it('displays error message on unsuccessful signup', () => { + const user1 = Cypress.env('user1'); + + cy.createUser(user1); + + cy.get('input[name="email"]').type(user1.email); + cy.get('input[name="username"]').type(user1.username); + cy.get('input[name="password"]').type(user1.password); + cy.get('input[name="fullName"]').type(user1.fullName); + + cy.get('button[type="submit"]').click(); + + cy.get('[data-testid="homepage-container"]').should('not.exist'); + cy.contains(/username is already taken/i).should('be.visible'); + }); +}); diff --git a/frontend/cypress/integration/signup.spec.ts b/frontend/cypress/integration/signup.spec.ts deleted file mode 100644 index 6314cf0..0000000 --- a/frontend/cypress/integration/signup.spec.ts +++ /dev/null @@ -1,12 +0,0 @@ -beforeEach(() => { - cy.request('POST', 'http://localhost:3001/api/test/reset'); - cy.visit('http://localhost:3000/signup'); -}); - -it('signup form is displayed', () => { - cy.contains(/sign up/i); - cy.get('input[name="email"]'); - cy.get('input[name="username"]'); - cy.get('input[name="password"]'); - cy.get('input[name="fullName"]'); -}); diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts index 22431a3..27fccc9 100644 --- a/frontend/cypress/support/commands.ts +++ b/frontend/cypress/support/commands.ts @@ -47,24 +47,27 @@ Cypress.Commands.add('login', ({ username, password, }) => { cy.request({ - url: 'http://localhost:3001/api/login', + url: 'http://localhost:3001/api/auth/login', method: 'POST', body: { username, password, }, }).then((res) => { - localStorage.setItem('instacloneSCToken', JSON.stringify(res.body)); + localStorage.setItem('cy-login-res-data', JSON.stringify(res.body)); }); cy.visit('http://localhost:3000'); }); Cypress.Commands.add('logout', () => { - localStorage.removeItem('instacloneSCToken'); - cy.visit('http://localhost:3000'); + localStorage.removeItem('cy-login-res-data'); + cy.request({ + url: 'http://localhost:3001/api/auth/logout', + method: 'POST', + }); }); Cypress.Commands.add('createPost', () => { - const { token, username } = JSON.parse(localStorage.getItem('instacloneSCToken')); + const { accessToken, username } = JSON.parse(localStorage.getItem('cy-login-res-data')); cy.request({ url: 'http://localhost:3001/api/posts', @@ -74,13 +77,13 @@ Cypress.Commands.add('createPost', () => { imageDataUrl: testImageDataUrl, }, headers: { - Authorization: `bearer ${token}`, + Authorization: `bearer ${accessToken}`, }, }); }); Cypress.Commands.add('editUser', (updatedUserFields) => { - const { token, username } = JSON.parse(localStorage.getItem('instacloneSCToken')); + const { accessToken, username } = JSON.parse(localStorage.getItem('cy-login-res-data')); const userId = localStorage.getItem(`cy-${username}-id`); cy.request({ @@ -88,20 +91,20 @@ Cypress.Commands.add('editUser', (updatedUserFields) => { method: 'PUT', body: updatedUserFields, headers: { - Authorization: `bearer ${token}`, + Authorization: `bearer ${accessToken}`, }, }); }); Cypress.Commands.add('followUser', (username) => { - const { token } = JSON.parse(localStorage.getItem('instacloneSCToken')); + const { accessToken } = JSON.parse(localStorage.getItem('cy-login-res-data')); const userId = localStorage.getItem(`cy-${username}-id`); cy.request({ url: `http://localhost:3001/api/users/${userId}/follow`, method: 'PUT', headers: { - Authorization: `bearer ${token}`, + Authorization: `bearer ${accessToken}`, }, }); }); diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 804a471..0d4a453 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -3,14 +3,15 @@ import { Route, useLocation, } from 'react-router-dom'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useMediaQuery } from '@mantine/hooks'; +import { Container, Loader } from '@mantine/core'; import Damion from '../common/components/Damion'; import GlobalStyles from '../common/components/GlobalStyles'; -import Login from '../features/auth/Login'; +import Login from '../features/auth/Login/Login'; import SignUp from '../features/users/SignUp'; import Home from '../features/posts/Home/Home'; -import RequireAuth from '../features/auth/RequireAuth'; +import RequireAuth from '../features/auth/RequireAuth/RequireAuth'; import useAuth from '../common/hooks/useAuth'; import DesktopNavbar from '../common/components/Navbars/DesktopNavbar/DesktopNavbar'; import BottomNavBar from '../common/components/Navbars/BottomNavbar/BottomNavbar'; @@ -24,13 +25,14 @@ import MobileHomeNavBar from '../common/components/Navbars/MobileHomeNavbar/Mobi import FollowingFollowersView from '../features/users/FollowingFollowersView/FollowingFollowersView'; import CommentsView from '../features/comments/CommentsView/CommentsView'; import LikedByView from '../features/users/LikedByView/LikedByView'; +import { usePrefetch } from './apiSlice'; interface LocationState { background: string, } function App() { - const [user] = useAuth(); + const [user, { refreshAccessToken }] = useAuth(); const location = useLocation(); const [alertText, setAlertText] = useState(''); const isCreatePage = /create/i.test(location.pathname); @@ -38,6 +40,40 @@ function App() { const state = location.state as LocationState; const background = state && state.background; const isMediumScreenOrWider = useMediaQuery('(min-width: 992px)'); + const [loading, setLoading] = useState(true); + const prefetchUsers = usePrefetch('getUsers', { force: true }); + + useEffect(() => { + let isCancelled = false; + + (async () => { + setLoading(true); + await refreshAccessToken(); + prefetchUsers(); + if (!isCancelled) { + setLoading(false); + } + })(); + + return () => { + isCancelled = true; + }; + }, []); + + if (loading) { + return ( + ({ + height: '100vh', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + })} + > + + + ); + } return ( <> @@ -76,7 +112,7 @@ function App() { - )} + )} /> : } /> : } /> @@ -89,7 +125,7 @@ function App() { - )} + )} /> - )} + )} /> - )} + )} /> } /> } /> diff --git a/frontend/src/app/apiSlice.ts b/frontend/src/app/apiSlice.ts index d26ea95..86c36d1 100644 --- a/frontend/src/app/apiSlice.ts +++ b/frontend/src/app/apiSlice.ts @@ -1,4 +1,10 @@ -import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { + createApi, + fetchBaseQuery, + type BaseQueryFn, + type FetchBaseQueryError, + type FetchArgs, +} from '@reduxjs/toolkit/query/react'; import { createEntityAdapter, EntityState, @@ -17,9 +23,8 @@ import { type DeleteCommentRequestFields, type NewLikeRequestFields, } from './types'; -import type { AuthState } from '../features/auth/authSlice'; -// eslint-disable-next-line import/no-cycle -import { RootState } from './store'; +import { type AuthState, setAuthenticatedState, removeAuthenticatedState } from '../features/auth/authSlice'; +import { type RootState } from './store'; // Normalizing users cache const usersAdapter = createEntityAdapter({ @@ -31,16 +36,59 @@ interface EditUserMutationArg { id: string, } -export const apiSlice = createApi({ - baseQuery: fetchBaseQuery({ - baseUrl: '/api', - prepareHeaders: (headers, { getState }) => { - const { token } = (getState() as RootState).auth; - if (token) headers.set('authorization', `bearer ${token}`); +const baseQuery = fetchBaseQuery({ + baseUrl: '/api', + prepareHeaders: (headers, { getState }) => { + const { accessToken } = (getState() as RootState).auth; - return headers; - }, - }), + if (accessToken) { + headers.set('authorization', `bearer ${accessToken}`); + } + + return headers; + }, +}); + +const baseQueryWithRefresh: BaseQueryFn< +string | FetchArgs, +unknown, +FetchBaseQueryError +> = async (args, api, extraOptions) => { + let result = await baseQuery(args, api, extraOptions); + + if (result.error && result.error.status === 401) { + // trying to refresh the access token + const refreshResult = await baseQuery({ + url: '/auth/refresh', + method: 'POST', + credentials: 'include', + }, api, extraOptions) as { data?: AuthState }; + + if ( + refreshResult.data + && refreshResult.data.accessToken + && refreshResult.data.username + ) { + const { accessToken, username } = refreshResult.data; + + api.dispatch(setAuthenticatedState({ accessToken, username })); + + // retry the original request + result = await baseQuery(args, api, extraOptions); + } else { + await baseQuery({ + url: '/auth/logout', + method: 'POST', + credentials: 'include', + }, api, extraOptions); + api.dispatch(removeAuthenticatedState()); + } + } + return result; +}; + +export const apiSlice = createApi({ + baseQuery: baseQueryWithRefresh, tagTypes: ['User', 'Post', 'Comment'], endpoints: (builder) => ({ addUser: builder.mutation({ @@ -158,11 +206,24 @@ export const apiSlice = createApi({ }), login: builder.mutation({ query: (loginFields) => ({ - url: '/login', + url: '/auth/login', method: 'POST', body: loginFields, }), }), + logout: builder.mutation({ + query: () => ({ + url: '/auth/logout', + method: 'POST', + }), + }), + refreshAccessToken: builder.mutation({ + query: () => ({ + url: '/auth/refresh', + method: 'POST', + credentials: 'include', + }), + }), }), }); @@ -186,6 +247,9 @@ export const { useGetEntityLikeCountByIDQuery, useGetEntityLikeUsersByIDQuery, useGetHasUserLikedEntityQuery, + useLogoutMutation, + useRefreshAccessTokenMutation, + usePrefetch, } = apiSlice; const selectUsersResult = apiSlice.endpoints.getUsers.select(); diff --git a/frontend/src/app/store.ts b/frontend/src/app/store.ts index 2f1715e..7a9a9cd 100644 --- a/frontend/src/app/store.ts +++ b/frontend/src/app/store.ts @@ -1,5 +1,4 @@ import { configureStore } from '@reduxjs/toolkit'; -// eslint-disable-next-line import/no-cycle import { apiSlice } from './apiSlice'; import authReducer from '../features/auth/authSlice'; diff --git a/frontend/src/test/int/profile-image-nav.test.tsx b/frontend/src/common/components/Navbars/BottomNavbar/BottomNavbar.test.tsx similarity index 54% rename from frontend/src/test/int/profile-image-nav.test.tsx rename to frontend/src/common/components/Navbars/BottomNavbar/BottomNavbar.test.tsx index 4192c4a..864d142 100644 --- a/frontend/src/test/int/profile-image-nav.test.tsx +++ b/frontend/src/common/components/Navbars/BottomNavbar/BottomNavbar.test.tsx @@ -6,19 +6,19 @@ import { mockLogout, waitFor, within, -} from '../utils/test-utils'; -import App from '../../app/App'; + testStore, +} from '../../../../test/utils/test-utils'; import '@testing-library/jest-dom/extend-expect'; -import { fakeUser } from '../mocks/handlers'; -import server from '../mocks/server'; -import { apiSlice } from '../../app/apiSlice'; -import { store } from '../../app/store'; +import { fakeUser } from '../../../../test/mocks/handlers'; +import server from '../../../../test/mocks/server'; +import { apiSlice } from '../../../../app/apiSlice'; +import BottomNavBar from './BottomNavbar'; beforeAll(() => server.listen()); beforeEach(() => { const fakeTokenInfo = { username: fakeUser.username, - token: 'supersecrettoken', + accessToken: 'supersecrettoken', }; mockLogin({ fakeTokenInfo }); }); @@ -40,8 +40,8 @@ test('when user has a profile image, it is displayed in mobile nav', async () => }]))), ); - store.dispatch(apiSlice.endpoints.getUsers.initiate()); - renderWithRouter(, { route: '/' }); + testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); + renderWithRouter(); await waitFor(() => { const avatar = screen.getByTestId('bottom-nav-avatar'); @@ -52,8 +52,8 @@ test('when user has a profile image, it is displayed in mobile nav', async () => }); test('when user has no profile image, a default image is displayed in mobile nav', async () => { - store.dispatch(apiSlice.endpoints.getUsers.initiate()); - renderWithRouter(, { route: '/' }); + testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); + renderWithRouter(); const avatar = await screen.findByTestId('bottom-nav-avatar'); @@ -65,26 +65,3 @@ test('when user has no profile image, a default image is displayed in mobile nav expect(image).toBeNull(); }); }); - -test('when user has a profile image, it is displayed in desktop nav', async () => { - const imageSrc = 'https://i.picsum.photos/id/237/200/300.jpg?hmac=TmmQSbShHz9CdQm0NkEjx1Dyh_Y984R9LpNrpvH2D_U'; - server.use( - rest.get('/api/users', (req, res, ctx) => res(ctx.status(200), ctx.json([{ - ...fakeUser, - image: { - url: imageSrc, - publicId: 'fakePublicId', - }, - }]))), - ); - - store.dispatch(apiSlice.endpoints.getUsers.initiate()); - renderWithRouter(, { route: '/' }); - - await waitFor(() => { - const avatar = screen.getByTestId('bottom-nav-avatar'); - const profileImage = within(avatar).getByRole('img'); - - expect(profileImage).toHaveAttribute('src', imageSrc); - }); -}); diff --git a/frontend/src/common/components/Navbars/BottomNavbar/BottomNavbar.tsx b/frontend/src/common/components/Navbars/BottomNavbar/BottomNavbar.tsx index cccb184..cd74a41 100644 --- a/frontend/src/common/components/Navbars/BottomNavbar/BottomNavbar.tsx +++ b/frontend/src/common/components/Navbars/BottomNavbar/BottomNavbar.tsx @@ -15,7 +15,6 @@ interface BottomNavBarProps { user: string | null, } -// TODO: render a blank bar when not logged in function BottomNavBar({ user }: BottomNavBarProps) { const { classes } = useStyles(); const [image, setImage] = useState(''); diff --git a/frontend/src/common/components/SignUpLogInTextInput/SignUpLogInTextInput.tsx b/frontend/src/common/components/SignUpLogInTextInput/SignUpLogInTextInput.tsx index be0a7ab..b935d56 100644 --- a/frontend/src/common/components/SignUpLogInTextInput/SignUpLogInTextInput.tsx +++ b/frontend/src/common/components/SignUpLogInTextInput/SignUpLogInTextInput.tsx @@ -33,6 +33,7 @@ function SignUpLogInTextInput({ name, ...props }: FormikInputProps) { strokeWidth={1.5} size={25} color="#868E96" + data-testid="input-circle-check" /> ); } diff --git a/frontend/src/common/components/UserMenu/UserMenu.test.tsx b/frontend/src/common/components/UserMenu/UserMenu.test.tsx new file mode 100644 index 0000000..c723613 --- /dev/null +++ b/frontend/src/common/components/UserMenu/UserMenu.test.tsx @@ -0,0 +1,52 @@ +import { rest } from 'msw'; +import { + screen, + renderWithRouter, + mockLogin, + mockLogout, + waitFor, + within, + testStore, +} from '../../../test/utils/test-utils'; +import '@testing-library/jest-dom/extend-expect'; +import { fakeUser } from '../../../test/mocks/handlers'; +import server from '../../../test/mocks/server'; +import { apiSlice } from '../../../app/apiSlice'; +import UserMenu from './UserMenu'; + +beforeAll(() => server.listen()); +beforeEach(() => { + const fakeTokenInfo = { + username: fakeUser.username, + accessToken: 'supersecrettoken', + }; + mockLogin({ fakeTokenInfo }); +}); +afterEach(() => { + mockLogout({ resetApiState: true }); + server.resetHandlers(); +}); +afterAll(() => server.close()); + +test('when user has a profile image, it is displayed in user menu', async () => { + const imageSrc = 'https://i.picsum.photos/id/237/200/300.jpg?hmac=TmmQSbShHz9CdQm0NkEjx1Dyh_Y984R9LpNrpvH2D_U'; + server.use( + rest.get('/api/users', (req, res, ctx) => res(ctx.status(200), ctx.json([{ + ...fakeUser, + image: { + url: imageSrc, + publicId: 'fakePublicId', + }, + }]))), + ); + + testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); + renderWithRouter(); + + await waitFor(() => { + const avatar = screen.getByTestId('usermenu'); + const profileImage = within(avatar).getByRole('img'); + + expect(profileImage).toHaveAttribute('src', imageSrc); + }); +}); diff --git a/frontend/src/common/components/UserMenu/UserMenu.tsx b/frontend/src/common/components/UserMenu/UserMenu.tsx index 3ff40db..80e218e 100644 --- a/frontend/src/common/components/UserMenu/UserMenu.tsx +++ b/frontend/src/common/components/UserMenu/UserMenu.tsx @@ -5,7 +5,6 @@ import { } from '@mantine/core'; import { IconUserCircle, - IconSettings, } from '@tabler/icons-react'; import { Link } from 'react-router-dom'; import useStyles from './UserMenu.styles'; @@ -13,7 +12,6 @@ import useAuth from '../../hooks/useAuth'; import { useAppSelector } from '../../hooks/selector-dispatch-hooks'; import { selectUserByUsername } from '../../../app/apiSlice'; -// TODO: add user prop function UserMenu() { const { classes } = useStyles(); const [user, { logout }] = useAuth(); @@ -43,13 +41,14 @@ function UserMenu() { } component={Link} to={`/${user}`}> Profile - }>Settings logout()} - sx={{ backGroundColor: 'white' }} + onClick={async () => { + await logout(); + }} + sx={{ backGroundColor: 'white', color: 'red' }} > Log Out diff --git a/frontend/src/common/hooks/useAuth.ts b/frontend/src/common/hooks/useAuth.ts index 1e0107e..005cc38 100644 --- a/frontend/src/common/hooks/useAuth.ts +++ b/frontend/src/common/hooks/useAuth.ts @@ -3,37 +3,72 @@ import { useNavigate } from 'react-router-dom'; import { useAppSelector, useAppDispatch } from './selector-dispatch-hooks'; import { selectCurrentUser, - setAuthedUser, - removeCurrentUser, + setAuthenticatedState, + removeAuthenticatedState, updateAuthedUsername, } from '../../features/auth/authSlice'; -import { useLoginMutation } from '../../app/apiSlice'; +import { + useLoginMutation, + useLogoutMutation, + useRefreshAccessTokenMutation, +} from '../../app/apiSlice'; import type { LoginFields } from '../../app/types'; -import { toErrorWithMessage } from '../utils/getErrorMessage'; +import getErrorMessage, { toErrorWithMessage } from '../utils/getErrorMessage'; const useAuth = () => { const user = useAppSelector(selectCurrentUser); const dispatch = useAppDispatch(); const navigate = useNavigate(); - const [loginMutation, { isLoading }] = useLoginMutation(); + const [loginMutation] = useLoginMutation(); + const [logoutMutation] = useLogoutMutation(); + const [ + refreshAccessTokenMutation, + ] = useRefreshAccessTokenMutation(); const login = async (loginFields: LoginFields) => { try { const data = await loginMutation(loginFields).unwrap(); - const tokenInfo = { - username: data.username!, - token: data.token!, - }; - localStorage.setItem('instacloneSCToken', JSON.stringify(tokenInfo)); - dispatch(setAuthedUser(tokenInfo)); + + if (data.accessToken && data.username) { + const loginResponseData = { + username: data.username, + accessToken: data.accessToken, + }; + + dispatch(setAuthenticatedState(loginResponseData)); + } else { + throw new Error('Something went wrong. Try again.'); + } } catch (error) { throw toErrorWithMessage(error) as Error; } }; - const logout = () => { - localStorage.removeItem('instacloneSCToken'); - dispatch(removeCurrentUser()); + const refreshAccessToken = async () => { + try { + const data = await refreshAccessTokenMutation().unwrap(); + + if (data.accessToken && data.username) { + const refreshResponseData = { + username: data.username, + accessToken: data.accessToken, + }; + + dispatch(setAuthenticatedState(refreshResponseData)); + } + } catch (error) { + console.error(getErrorMessage(error)); + } + }; + + const logout = async () => { + try { + await logoutMutation().unwrap(); + } catch (error) { + console.error(getErrorMessage(error)); + } + + dispatch(removeAuthenticatedState()); navigate('/login'); }; @@ -48,7 +83,7 @@ const useAuth = () => { return [useMemo(() => (user), [user]), { login, logout, - isLoading, + refreshAccessToken, updateTokenUsername, }] as const; }; diff --git a/frontend/src/features/auth/Login/Login.test.tsx b/frontend/src/features/auth/Login/Login.test.tsx new file mode 100644 index 0000000..57421fd --- /dev/null +++ b/frontend/src/features/auth/Login/Login.test.tsx @@ -0,0 +1,97 @@ +import { rest } from 'msw'; +import { + renderWithRouter, + screen, + waitFor, + mockLogout, +} from '../../../test/utils/test-utils'; +import '@testing-library/jest-dom/extend-expect'; +import { fakeUser } from '../../../test/mocks/handlers'; +import server from '../../../test/mocks/server'; +import Login from './Login'; + +beforeAll(() => server.listen()); +afterEach(() => { + mockLogout({ resetApiState: true }); + server.resetHandlers(); +}); +afterAll(() => server.close()); + +const loginFields = { + username: fakeUser.username, + password: 'secret', +}; + +test('submit button is disabled until all fields are filled', async () => { + const { user } = renderWithRouter(); + + const loginButton = screen.getByRole('button'); + const usernameInput = screen.getByPlaceholderText(/username/i); + const passwordInput = screen.getByPlaceholderText(/password/i); + + expect(loginButton).toBeDisabled(); + + await user.type(usernameInput, loginFields.username); + expect(loginButton).toBeDisabled(); + + await user.type(passwordInput, loginFields.password); + + expect(loginButton).not.toBeDisabled(); +}); + +test('user can see checkmarks when input is valid', async () => { + const { user } = renderWithRouter(); + + const usernameInput = screen.getByPlaceholderText(/username/i); + const passwordInput = screen.getByPlaceholderText(/password/i); + + await waitFor(() => { + expect(screen.queryByTestId('input-circle-check')).toBeNull(); + }); + + await user.type(usernameInput, loginFields.username); + await user.type(passwordInput, loginFields.password); + await user.click(usernameInput); + + await waitFor(() => { + expect(screen.getAllByTestId('input-circle-check')).toHaveLength(2); + }); +}); + +test('user can see red x\'s when focusing away from empty inputs', async () => { + const { user } = renderWithRouter(); + + const usernameInput = screen.getByPlaceholderText(/username/i); + const passwordInput = screen.getByPlaceholderText(/password/i); + + await user.click(usernameInput); + await user.click(passwordInput); + await waitFor(() => { + expect(screen.getByTestId('redx')).toBeVisible(); + }); + + await user.click(passwordInput); + await user.click(usernameInput); + await waitFor(() => { + expect(screen.getAllByTestId('redx')).toHaveLength(2); + }); +}); + +test('user can see error message after unsuccessful login', async () => { + server.use( + rest.post('/api/auth/login', (req, res, ctx) => res(ctx.status(401), ctx.json({ error: 'Invalid username or password' }))), + ); + const { user } = renderWithRouter(); + + const usernameInput = await screen.findByPlaceholderText(/username/i); + const passwordInput = await screen.findByPlaceholderText(/password/i); + const loginButton = screen.getByRole('button'); + + await user.type(usernameInput, loginFields.username); + await user.type(passwordInput, loginFields.password); + await user.click(loginButton); + + await waitFor(() => { + expect(screen.getByText(/invalid username or password/i)).toBeVisible(); + }); +}); diff --git a/frontend/src/features/auth/Login.tsx b/frontend/src/features/auth/Login/Login.tsx similarity index 88% rename from frontend/src/features/auth/Login.tsx rename to frontend/src/features/auth/Login/Login.tsx index 444662e..013db7a 100644 --- a/frontend/src/features/auth/Login.tsx +++ b/frontend/src/features/auth/Login/Login.tsx @@ -13,12 +13,12 @@ import { useNavigate, useLocation, } from 'react-router-dom'; -import type { LoginFields } from '../../app/types'; -import SignUpLogInTextInput from '../../common/components/SignUpLogInTextInput/SignUpLogInTextInput'; -import Button from '../../common/components/Button'; -import FormContainer from '../../common/components/FormContainer'; -import useAuth from '../../common/hooks/useAuth'; -import getErrorMessage from '../../common/utils/getErrorMessage'; +import type { LoginFields } from '../../../app/types'; +import SignUpLogInTextInput from '../../../common/components/SignUpLogInTextInput/SignUpLogInTextInput'; +import Button from '../../../common/components/Button'; +import FormContainer from '../../../common/components/FormContainer'; +import useAuth from '../../../common/hooks/useAuth'; +import getErrorMessage from '../../../common/utils/getErrorMessage'; // exported for use in SignUp.tsx, which follows the same structure and design. export const useStyles = createStyles(() => ({ diff --git a/frontend/src/features/auth/RequireAuth/RequireAuth.test.tsx b/frontend/src/features/auth/RequireAuth/RequireAuth.test.tsx new file mode 100644 index 0000000..0595561 --- /dev/null +++ b/frontend/src/features/auth/RequireAuth/RequireAuth.test.tsx @@ -0,0 +1,31 @@ +import RequireAuth from './RequireAuth'; +import { + renderWithRouter, + mockLogout, + mockLogin, + screen, +} from '../../../test/utils/test-utils'; +import server from '../../../test/mocks/server'; + +beforeAll(() => server.listen()); +afterEach(() => { + mockLogout({ resetApiState: true }); + server.resetHandlers(); +}); +afterAll(() => server.close()); + +test('logged in user can see children components', () => { + const fakeTokenInfo = { + username: 'bobbydob', + accessToken: 'supersecrettoken', + }; + mockLogin({ fakeTokenInfo }); + + const child =
I am a child
; + + renderWithRouter({child}); + + const childText = screen.getByText(/I am a child/i); + + expect(childText).toBeVisible(); +}); diff --git a/frontend/src/features/auth/RequireAuth.tsx b/frontend/src/features/auth/RequireAuth/RequireAuth.tsx similarity index 86% rename from frontend/src/features/auth/RequireAuth.tsx rename to frontend/src/features/auth/RequireAuth/RequireAuth.tsx index 2ba341c..2c64501 100644 --- a/frontend/src/features/auth/RequireAuth.tsx +++ b/frontend/src/features/auth/RequireAuth/RequireAuth.tsx @@ -1,6 +1,6 @@ import { useLocation, Navigate } from 'react-router-dom'; import { ReactElement } from 'react'; -import useAuth from '../../common/hooks/useAuth'; +import useAuth from '../../../common/hooks/useAuth'; function RequireAuth({ children }: { children: ReactElement }) { const [user] = useAuth(); diff --git a/frontend/src/features/auth/authSlice.ts b/frontend/src/features/auth/authSlice.ts index 23a80af..2966108 100644 --- a/frontend/src/features/auth/authSlice.ts +++ b/frontend/src/features/auth/authSlice.ts @@ -4,15 +4,15 @@ import { ThunkAction, AnyAction, } from '@reduxjs/toolkit'; -import type { RootState } from '../../app/store'; +import { type RootState } from '../../app/store'; export interface AuthState { - token: string | null, + accessToken: string | null, username: string | null, } const initialState: AuthState = { - token: null, + accessToken: null, username: null, }; @@ -20,16 +20,21 @@ const authSlice = createSlice({ name: 'auth', initialState, reducers: { - setAuthedUser: ( + setAuthenticatedState: ( state, - { payload: { username, token } }: PayloadAction<{ username: string, token: string }>, + { + payload: { username, accessToken }, + }: PayloadAction<{ + username: string, + accessToken: string + }>, ) => { state.username = username; - state.token = token; + state.accessToken = accessToken; }, - removeCurrentUser: (state) => { + removeAuthenticatedState: (state) => { state.username = null; - state.token = null; + state.accessToken = null; }, updateCurrentUsername: ( state, @@ -40,20 +45,14 @@ const authSlice = createSlice({ }, }); -export const { setAuthedUser, removeCurrentUser, updateCurrentUsername } = authSlice.actions; +export const { + setAuthenticatedState, + removeAuthenticatedState, + updateCurrentUsername, +} = authSlice.actions; export default authSlice.reducer; -export const initAuthedUser = (): ThunkAction => { - const token = localStorage.getItem('instacloneSCToken'); - return (dispatch) => { - if (token) { - const parsedToken = JSON.parse(token); - dispatch(setAuthedUser(parsedToken)); - } - }; -}; - /** * Updates the username in the stored JWT. Necessary for when user edits their username * so that the application can continue to display the correct updated username. diff --git a/frontend/src/features/posts/EditPostDetails/EditPostDetails.tsx b/frontend/src/features/posts/EditPostDetails/EditPostDetails.tsx index b8911c8..d34eb2e 100644 --- a/frontend/src/features/posts/EditPostDetails/EditPostDetails.tsx +++ b/frontend/src/features/posts/EditPostDetails/EditPostDetails.tsx @@ -23,6 +23,7 @@ import GoBackNavbar from '../../../common/components/Navbars/GoBackNavbar/GoBack import baseStyles from '../../../common/components/Navbars/mobile-nav-styles'; import useStyles from './EditPostDetails.styles'; import { NewPostFields } from '../../../app/types'; +import useGoBack from '../../../common/hooks/useGoBack'; interface EditPostDetailsProps { username: string; @@ -54,6 +55,7 @@ function EditPostDetails({ username, setAlertText }: EditPostDetailsProps) { const { classes: baseClasses } = baseStyles(); const { classes } = useStyles(); const isMediumOrWider = useMediaQuery('(min-width: 992px)'); + const goBack = useGoBack(); const handleSubmit = () => { if (formRef.current) { @@ -61,14 +63,6 @@ function EditPostDetails({ username, setAlertText }: EditPostDetailsProps) { } }; - const handleGoBack = () => { - if (window.history.state && window.history.state.idx > 0) { - navigate(-1); - } else { - navigate('/', { replace: true }); - } - }; - if (state && state?.croppedImage && user) { return ( <> @@ -180,7 +174,7 @@ function EditPostDetails({ username, setAlertText }: EditPostDetailsProps) { > - ); - } - - if (isCurrentUserLoggedIn) { - return ( -
- - -
- ); - } - - return null; - }; - - const bio = () => ( -
- {user.fullName} - - {user.bio} - -
- ); + const [isLogoutPopoverOpen, setIsLogoutPopoverOpen] = useState(false); + const [, { logout }] = useAuth(); return ( <> @@ -181,10 +138,45 @@ function UserProfileInfo({
- - {user.username} - - {buttons()} + + + {user.username} + + + { + (isCurrentUserProfile && !isMediumScreenOrWider) && ( + setIsLogoutPopoverOpen(false)} + target={( + setIsLogoutPopoverOpen(!isLogoutPopoverOpen)} + data-testid="user-profile-info-dots" + /> + )} + position="left" + spacing="xs" + > + { + await logout(); + } + } + > + Log out + + + ) + } + + +
{isMediumScreenOrWider && ( <> @@ -193,13 +185,21 @@ function UserProfileInfo({ followerCount={user.followers?.length ?? 0} followingCount={user.following?.length ?? 0} /> - {bio()} + )}
- {!isMediumScreenOrWider && bio()} + {!isMediumScreenOrWider && ( + + )} ); diff --git a/frontend/src/features/users/UserProfile/UserProfileInfo/UserProfileInfoButtons/UserProfileInfoButtons.styles.tsx b/frontend/src/features/users/UserProfile/UserProfileInfo/UserProfileInfoButtons/UserProfileInfoButtons.styles.tsx new file mode 100644 index 0000000..aa50d79 --- /dev/null +++ b/frontend/src/features/users/UserProfile/UserProfileInfo/UserProfileInfoButtons/UserProfileInfoButtons.styles.tsx @@ -0,0 +1,29 @@ +import { createStyles } from '@mantine/core'; + +export default createStyles((theme) => ({ + buttonRoot: { + height: 30, + width: '100%', + }, + buttonOutline: { + borderColor: theme.colors.gray[4], + color: theme.colors.instaDark[6], + }, + editButtonRoot: { + [`@media (min-width: ${theme.breakpoints.md}px)`]: { + width: 'inherit', + }, + }, + followButtonRoot: { + flexShrink: 2, + '@media (max-width: 335px)': { + padding: '0 10px', + }, + }, + mainSectionButtonGroup: { + display: 'flex', + flexDirection: 'row', + gap: 10, + maxWidth: 250, + }, +})); diff --git a/frontend/src/features/users/UserProfile/UserProfileInfo/UserProfileInfoButtons/UserProfileInfoButtons.tsx b/frontend/src/features/users/UserProfile/UserProfileInfo/UserProfileInfoButtons/UserProfileInfoButtons.tsx new file mode 100644 index 0000000..4ae74f3 --- /dev/null +++ b/frontend/src/features/users/UserProfile/UserProfileInfo/UserProfileInfoButtons/UserProfileInfoButtons.tsx @@ -0,0 +1,87 @@ +import { Button } from '@mantine/core'; +import { Link } from 'react-router-dom'; +import useAuth from '../../../../../common/hooks/useAuth'; +import { + useFollowUserByIdMutation, + useUnfollowUserByIdMutation, +} from '../../../../../app/apiSlice'; +import getErrorMessage from '../../../../../common/utils/getErrorMessage'; +import useStyles from './UserProfileInfoButtons.styles'; + +interface UserProfileInfoButtonsProps { + userId: string; + isCurrentUserProfile?: boolean; + isCurrentUserFollowing: boolean; +} + +function UserProfileInfoButtons({ + userId, + isCurrentUserProfile, + isCurrentUserFollowing, +}: UserProfileInfoButtonsProps) { + const [user] = useAuth(); + const { classes, cx } = useStyles(); + const isCurrentUserLoggedIn = user != null; + const [followUser, { isLoading: isFollowLoading }] = useFollowUserByIdMutation(); + const [unfollowUser, { isLoading: isUnfollowLoading }] = useUnfollowUserByIdMutation(); + + const onFollowBtnClick = async () => { + const followFunc = isCurrentUserFollowing ? unfollowUser : followUser; + + try { + await followFunc(userId).unwrap(); + } catch (error) { + console.error(getErrorMessage(error)); + } + }; + + if (isCurrentUserProfile) { + return ( + + ); + } + + if (isCurrentUserLoggedIn) { + return ( +
+ + +
+ ); + } + + return null; +} + +UserProfileInfoButtons.defaultProps = { + isCurrentUserProfile: false, +}; + +export default UserProfileInfoButtons; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 5fec5c5..fe9f01d 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -8,12 +8,6 @@ import App from './app/App'; import theme from './app/theme'; import reportWebVitals from './reportWebVitals'; import { store } from './app/store'; -import { initAuthedUser } from './features/auth/authSlice'; -import { apiSlice } from './app/apiSlice'; -/* TODO: create a /validendpoint token to verify that the token is valid? -if valid, setAuthedUser */ -store.dispatch(initAuthedUser()); -store.dispatch(apiSlice.endpoints.getUsers.initiate()); ReactDOM.render( diff --git a/frontend/src/test/int/appAuthRefreshFlow.test.tsx b/frontend/src/test/int/appAuthRefreshFlow.test.tsx new file mode 100644 index 0000000..e933232 --- /dev/null +++ b/frontend/src/test/int/appAuthRefreshFlow.test.tsx @@ -0,0 +1,46 @@ +import { rest } from 'msw'; +import { renderWithRouter, screen, mockLogout } from '../utils/test-utils'; +import server from '../mocks/server'; +import App from '../../app/App'; + +beforeAll(() => server.listen()); +afterEach(() => { + server.resetHandlers(); + mockLogout({ resetApiState: true }); +}); +afterAll(() => server.close()); + +it('if App refresh request is successful, user is redirected to homepage', async () => { + renderWithRouter(); + + const homepage = await screen.findByTestId('homepage-container'); + expect(homepage).toBeVisible(); +}); + +it('displays a loading spinner while App refresh request is pending and disappears after', async () => { + renderWithRouter(); + + const loader = await screen.findByTestId('app-loader'); + + expect(loader).toBeVisible(); + + await screen.findByTestId('homepage-container'); + + expect(loader).not.toBeInTheDocument(); +}); + +it('if App refresh request fails, user is redirected to login page', async () => { + server.use( + rest.post('/api/auth/refresh', (_req, res, ctx) => res(ctx.status(401), ctx.json({ + error: 'refresh token missing or invalid.', + }))), + ); + + renderWithRouter(); + + const loginText = await screen.findByText(/don't have an account?/i); + const loginButton = await screen.findByRole('button', { name: /log in/i }); + + expect(loginText).toBeVisible(); + expect(loginButton).toBeVisible(); +}); diff --git a/frontend/src/test/int/editpostdetails.test.tsx b/frontend/src/test/int/editPostDetailsPostCreation.test.tsx similarity index 91% rename from frontend/src/test/int/editpostdetails.test.tsx rename to frontend/src/test/int/editPostDetailsPostCreation.test.tsx index b92dd39..ff497c5 100644 --- a/frontend/src/test/int/editpostdetails.test.tsx +++ b/frontend/src/test/int/editPostDetailsPostCreation.test.tsx @@ -10,8 +10,8 @@ import { Providers, waitFor, waitForElementToBeRemoved, + testStore, } from '../utils/test-utils'; -import { store } from '../../app/store'; import { fakeUser } from '../mocks/handlers'; import server from '../mocks/server'; import { apiSlice } from '../../app/apiSlice'; @@ -22,7 +22,7 @@ beforeAll(() => server.listen()); beforeEach(() => { const fakeTokenInfo = { username: fakeUser.username, - token: 'supersecrettoken', + accessToken: 'supersecrettoken', }; mockLogin({ fakeTokenInfo }); }); @@ -33,7 +33,7 @@ afterEach(() => { afterAll(() => server.close()); test('creating a post navigates to homepage and displays an alert', async () => { - await store.dispatch(apiSlice.endpoints.getUsers.initiate()); + await testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); const history = createBrowserHistory(); history.push('/create/details', { croppedImage: testImage }); @@ -59,7 +59,7 @@ test('creating a post navigates to homepage and displays an alert', async () => }); test('share button not visible while post is being created', async () => { - await store.dispatch(apiSlice.endpoints.getUsers.initiate()); + await testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); const history = createBrowserHistory(); history.push('/create/details', { croppedImage: testImage }); @@ -92,7 +92,7 @@ test('unsuccessful post creation navigates to homepage and displays an alert', a rest.post('/api/posts', (req, res, ctx) => res(ctx.status(500))), ); - await store.dispatch(apiSlice.endpoints.getUsers.initiate()); + await testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); const history = createBrowserHistory(); history.push('/create/details', { croppedImage: testImage }); diff --git a/frontend/src/test/int/gobacknavbar.test.tsx b/frontend/src/test/int/goBackNavbarNavigation.tsx similarity index 85% rename from frontend/src/test/int/gobacknavbar.test.tsx rename to frontend/src/test/int/goBackNavbarNavigation.tsx index f9a5e1b..2a85107 100644 --- a/frontend/src/test/int/gobacknavbar.test.tsx +++ b/frontend/src/test/int/goBackNavbarNavigation.tsx @@ -1,20 +1,20 @@ import { - screen, renderWithRouter, mockLogin, mockLogout, + screen, + renderWithRouter, + mockLogin, + mockLogout, } from '../utils/test-utils'; import { fakeUser } from '../mocks/handlers'; -import { store } from '../../app/store'; import server from '../mocks/server'; -import { apiSlice } from '../../app/apiSlice'; import App from '../../app/App'; beforeAll(() => server.listen()); beforeEach(() => { const fakeTokenInfo = { username: fakeUser.username, - token: 'supersecrettoken', + accessToken: 'supersecrettoken', }; mockLogin({ fakeTokenInfo }); - store.dispatch(apiSlice.endpoints.getUsers.initiate()); }); afterEach(() => { mockLogout({ resetApiState: true }); diff --git a/frontend/src/test/int/httpRefreshInterceptor.test.tsx b/frontend/src/test/int/httpRefreshInterceptor.test.tsx new file mode 100644 index 0000000..0e6c234 --- /dev/null +++ b/frontend/src/test/int/httpRefreshInterceptor.test.tsx @@ -0,0 +1,79 @@ +import { rest } from 'msw'; +import PostComponent from '../../features/posts/PostComponent/PostComponent'; +import { + renderWithRouter, + screen, + waitFor, + mockLogout, +} from '../utils/test-utils'; +import server from '../mocks/server'; +import '@testing-library/jest-dom/extend-expect'; +import { fakeUser } from '../mocks/handlers'; +import testImageDataUrl from '../../../cypress/fixtures/test-image-data-url'; + +beforeAll(() => server.listen()); +afterEach(() => { + server.resetHandlers(); + mockLogout({ resetApiState: true }); +}); +afterAll(() => server.close()); + +/* + Just some background on this test (because I'll surely forget): + + The apiSlice intercepts HTTP requests and checks if a request + returns 401 (unauthorized), which usually would mean that the access token is expired. + + If it does, the apiSlice makes a call to + the refresh endpoint to get a new access token and then retries the original request. + + This test is just to make that sure that works as expected. + + From the user's perspective, the user should not see any difference in the UI. + + This test will use the PostComponent as the vehicle to test this through the like button. + + The user should be able to like a post like normal. + + So as far as assertions go, we just need to make sure that the like count updates like normal. +*/ + +test('user with "expired" access token can still like a post', async () => { + // setting up the server to return a 401 on the first like attempt + server.use( + rest.post('/api/likes', (req, res, ctx) => { + console.log('POST /api/likes - failure'); + return res.once(ctx.status(401), ctx.json({ error: 'token expired.' })); + }), + rest.post('/api/likes', (req, res, ctx) => { + console.log('POST /api/likes - success'); + return res(ctx.status(201)); + }), + ); + + const post = { + id: '1', + creator: { ...fakeUser }, + caption: 'This is a post', + image: { url: testImageDataUrl, publicId: 'fakepublicid' }, + comments: [], + likes: [], + createdAt: '2021-08-01T00:00:00.000Z', + updatedAt: '2021-08-01T00:00:00.000Z', + }; + const setAlertText = jest.fn(); + + const { user } = renderWithRouter(); + + expect(screen.queryByText(/1 like/i)).not.toBeInTheDocument(); + + const likeButton = screen.getByTestId('like-post-btn'); + + await user.click(likeButton); + + // if the like count was updated + // then we know the second like was made and was successful + await waitFor(async () => { + expect(screen.getByText(/1 like/i)).toBeVisible(); + }); +}); diff --git a/frontend/src/test/int/editpostimage.test.tsx b/frontend/src/test/int/imageUpload.test.tsx similarity index 91% rename from frontend/src/test/int/editpostimage.test.tsx rename to frontend/src/test/int/imageUpload.test.tsx index 8a888aa..9530bc3 100644 --- a/frontend/src/test/int/editpostimage.test.tsx +++ b/frontend/src/test/int/imageUpload.test.tsx @@ -7,9 +7,9 @@ import { waitFor, fireEvent, dataURItoBlob, + testStore, } from '../utils/test-utils'; import { fakeUser } from '../mocks/handlers'; -import { store } from '../../app/store'; import server from '../mocks/server'; import { apiSlice } from '../../app/apiSlice'; import App from '../../app/App'; @@ -19,10 +19,10 @@ beforeAll(() => server.listen()); beforeEach(() => { const fakeTokenInfo = { username: fakeUser.username, - token: 'supersecrettoken', + accessToken: 'supersecrettoken', }; mockLogin({ fakeTokenInfo }); - store.dispatch(apiSlice.endpoints.getUsers.initiate()); + testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); }); afterEach(() => { mockLogout({ resetApiState: true }); diff --git a/frontend/src/test/int/login.test.tsx b/frontend/src/test/int/login.test.tsx deleted file mode 100644 index 9c23486..0000000 --- a/frontend/src/test/int/login.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { rest } from 'msw'; -import { - renderWithRouter, - screen, - waitFor, - mockLogout, -} from '../utils/test-utils'; -import App from '../../app/App'; -import '@testing-library/jest-dom/extend-expect'; -import { fakeUser } from '../mocks/handlers'; -import server from '../mocks/server'; - -beforeAll(() => server.listen()); -afterEach(() => { - mockLogout({ resetApiState: true }); - server.resetHandlers(); -}); -afterAll(() => server.close()); - -const loginFields = { - username: fakeUser.username, - password: 'secret', -}; - -test('user can login successfully', async () => { - const { user } = renderWithRouter(); - - await user.type(screen.getByPlaceholderText(/username/i), loginFields.username); - await user.type(screen.getByPlaceholderText(/password/i), loginFields.password); - await user.click(screen.getByRole('button')); - - await waitFor(() => { - const token = localStorage.getItem('instacloneSCToken'); - expect(token).not.toBeNull(); - const parsedToken = JSON.parse(token!); - expect(parsedToken.username).toBe(loginFields.username); - }); - - expect(screen.getByTestId('homepage-container')).toBeVisible(); -}); - -test('error is displayed on unsuccessful login', async () => { - server.use( - rest.post('/api/login', (req, res, ctx) => res(ctx.status(401), ctx.json({ error: 'Invalid username or password' }))), - ); - - const { user } = renderWithRouter(); - - await user.type(screen.getByPlaceholderText(/username/i), loginFields.username); - await user.type(screen.getByPlaceholderText(/password/i), loginFields.password); - await user.click(screen.getByRole('button')); - - await waitFor(() => { - const token = localStorage.getItem('instacloneSCToken'); - expect(token).toBeNull(); - expect(screen.getByText(/invalid username or password/i)).toBeVisible(); - }); -}); diff --git a/frontend/src/test/int/userprofile.test.tsx b/frontend/src/test/int/navigatingToUserProfiles.tsx similarity index 55% rename from frontend/src/test/int/userprofile.test.tsx rename to frontend/src/test/int/navigatingToUserProfiles.tsx index 157f8fe..0593602 100644 --- a/frontend/src/test/int/userprofile.test.tsx +++ b/frontend/src/test/int/navigatingToUserProfiles.tsx @@ -5,17 +5,17 @@ import { mockLogout, mockLogin, renderWithRouter, + testStore, } from '../utils/test-utils'; import App from '../../app/App'; import server from '../mocks/server'; import { fakeUser } from '../mocks/handlers'; import '@testing-library/jest-dom/extend-expect'; -import { store } from '../../app/store'; import { apiSlice } from '../../app/apiSlice'; const fakeTokenInfo = { username: fakeUser.username, - token: 'supersecrettoken', + accessToken: 'supersecrettoken', }; beforeAll(() => server.listen()); @@ -34,11 +34,11 @@ test('navigating to profile page of non-existing user displays not found page', }); test('navigating to profile page of existing user displays their profile', async () => { - store.dispatch(apiSlice.endpoints.getUsers.initiate()); - + testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); renderWithRouter(, { route: '/bobbydob' }); + await waitFor(() => { - expect(screen.getByText(/bobbydob/i)).toBeVisible(); + expect(screen.getAllByText(/bobbydob/i)).toHaveLength(2); }); }); @@ -55,14 +55,18 @@ test('if not logged in and profile has avatar image, clicking on avatar does not publicId: 'fakePublicId', }, }]))), + rest.post('/api/auth/refresh', (req, res, ctx) => { + console.log('POST /api/auth/refresh - failure'); + ctx.delay(2500); + + return res(ctx.status(401), ctx.json({ error: 'refresh token missing or invalid.' })); + }), ); - store.dispatch(apiSlice.endpoints.getUsers.initiate()); + testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); const { user } = renderWithRouter(, { route: `/${fakeUser.username}` }); - mockLogout({ resetApiState: false }); - const profileAvatar = await screen.findByTestId('profile-info-avatar'); await user.click(profileAvatar); @@ -70,40 +74,3 @@ test('if not logged in and profile has avatar image, clicking on avatar does not expect(screen.queryByTestId('change-avatar-modal')).not.toBeInTheDocument(); }); }); - -test('if logged in and profile has avatar image, clicking on avatar displays modal', async () => { - mockLogin({ fakeTokenInfo }); - - server.use( - rest.get('/api/users', (req, res, ctx) => res(ctx.status(200), ctx.json([{ - ...fakeUser, - image: { - url: 'https://i.picsum.photos/id/237/200/300.jpg?hmac=TmmQSbShHz9CdQm0NkEjx1Dyh_Y984R9LpNrpvH2D_U', - publicId: 'fakePublicId', - }, - }]))), - ); - - store.dispatch(apiSlice.endpoints.getUsers.initiate()); - const { user } = renderWithRouter(, { route: '/accounts/edit' }); - - const avatar = await screen.findByTestId('avatar'); - await user.click(avatar); - - await waitFor(async () => { - expect(screen.queryByTestId('change-avatar-modal')).toBeVisible(); - }); -}); - -// testing ChangeAvatarModal features inside UseProfile -test('when clicking on avatar, modal does not appear if user does not have an image', async () => { - mockLogin({ fakeTokenInfo }); - - store.dispatch(apiSlice.endpoints.getUsers.initiate()); - const { user } = renderWithRouter(, { route: '/accounts/edit' }); - - await waitFor(async () => { - await user.click(screen.getByTestId('avatar')); - expect(screen.queryByText(/remove current photo/i)).toBeNull(); - }); -}); diff --git a/frontend/src/test/int/requireAuthLoginRedirect.test.tsx b/frontend/src/test/int/requireAuthLoginRedirect.test.tsx new file mode 100644 index 0000000..3f644d7 --- /dev/null +++ b/frontend/src/test/int/requireAuthLoginRedirect.test.tsx @@ -0,0 +1,43 @@ +import { rest } from 'msw'; +import { + screen, + renderWithRouter, + mockLogout, + waitFor, +} from '../utils/test-utils'; +import App from '../../app/App'; +import '@testing-library/jest-dom/extend-expect'; +import { fakeUser } from '../mocks/handlers'; +import server from '../mocks/server'; + +beforeAll(() => server.listen()); +afterEach(() => { + mockLogout({ resetApiState: true }); + server.resetHandlers(); +}); +afterAll(() => server.close()); + +test('RequireAuth.tsx protects route, Login.tsx redirects user back to protected router after they login', async () => { + server.use( + rest.post('/api/auth/refresh', (req, res, ctx) => { + console.log('POST /api/auth/refresh - failure'); + ctx.delay(2500); + + return res(ctx.status(401), ctx.json({ error: 'refresh token missing or invalid.' })); + }), + ); + + const { user } = renderWithRouter(, { route: '/accounts/edit' }); + + const usernameInput = await screen.findByPlaceholderText(/username/i); + const passwordInput = await screen.findByPlaceholderText(/password/i); + const loginButton = await screen.findByRole('button'); + + await user.type(usernameInput, fakeUser.username); + await user.type(passwordInput, 'secret'); + await user.click(loginButton); + + await waitFor(() => { + expect(screen.getByText(/bio/i)).toBeVisible(); + }); +}); diff --git a/frontend/src/test/int/requireauth.test.tsx b/frontend/src/test/int/requireauth.test.tsx deleted file mode 100644 index 39581ef..0000000 --- a/frontend/src/test/int/requireauth.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { - screen, - renderWithRouter, - mockLogin, - mockLogout, - waitFor, -} from '../utils/test-utils'; -import App from '../../app/App'; -import '@testing-library/jest-dom/extend-expect'; -import { fakeUser } from '../mocks/handlers'; -import server from '../mocks/server'; -import { apiSlice } from '../../app/apiSlice'; -import { store } from '../../app/store'; - -beforeAll(() => server.listen()); -afterEach(() => { - mockLogout({ resetApiState: true }); - server.resetHandlers(); -}); -afterAll(() => server.close()); - -test('redirects user to login form when not logged in', () => { - renderWithRouter( - , - ); - - expect(screen.getByText(/Don't have an account/i)).toBeVisible(); -}); - -test('logged in user is taken to home page', async () => { - const fakeTokenInfo = { - username: 'bobbydob', - token: 'supersecrettoken', - }; - mockLogin({ fakeTokenInfo }); - - renderWithRouter( - , - ); - - expect(screen.getByTestId('homepage-container')).toBeVisible(); -}); - -test('when user logs in, it redirects user to route they were attempting to visit', async () => { - store.dispatch(apiSlice.endpoints.getUsers.initiate()); - - const { user } = renderWithRouter(, { route: '/accounts/edit' }); - await user.type(screen.getByPlaceholderText(/username/i), fakeUser.username); - await user.type(screen.getByPlaceholderText(/password/i), 'secret'); - await user.click(screen.getByRole('button')); - - await waitFor(() => { - expect(screen.getByText(/bio/i)).toBeVisible(); - }); -}); diff --git a/frontend/src/test/int/signup.test.tsx b/frontend/src/test/int/signup.test.tsx deleted file mode 100644 index 616f8e5..0000000 --- a/frontend/src/test/int/signup.test.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { rest } from 'msw'; -import { - screen, - waitFor, - mockLogin, - mockLogout, - renderWithRouter, -} from '../utils/test-utils'; -import App from '../../app/App'; -import server from '../mocks/server'; -import { fakeUser } from '../mocks/handlers'; -import '@testing-library/jest-dom/extend-expect'; - -beforeAll(() => server.listen()); -afterEach(() => { - mockLogout({ resetApiState: true }); - server.resetHandlers(); -}); -afterAll(() => server.close()); - -const signupFields = { - email: fakeUser.email, - username: fakeUser.username, - fullName: fakeUser.fullName, - password: 'secret', -}; - -jest.setTimeout(10000); - -test('user can signup successfully', async () => { - const { user } = renderWithRouter(); - - await user.click(screen.getByText('Sign up')); - await user.type(screen.getByPlaceholderText(/email/i), signupFields.email); - await user.type(screen.getByPlaceholderText(/username/i), signupFields.username); - await user.type(screen.getByPlaceholderText(/password/i), signupFields.password); - await user.type(screen.getByPlaceholderText(/full name/i), signupFields.fullName); - - await user.click(screen.getByRole('button')); - - // Testing that user is logged in after successful signup - - await waitFor(() => { - const token = localStorage.getItem('instacloneSCToken'); - expect(token).not.toBeNull(); - const parsedToken = JSON.parse(token!); - expect(parsedToken.username).toBe(signupFields.username); - }); - - expect(screen.getByTestId('homepage-container')).toBeVisible(); -}); - -test('error is displayed on unsuccessful signup', async () => { - server.use( - rest.post('/api/users', (req, res, ctx) => res(ctx.status(400), ctx.json({ error: 'That username is already taken!' }))), - ); - - const { user } = renderWithRouter(); - - await user.click(screen.getByText('Sign up')); - await user.type(screen.getByPlaceholderText(/email/i), signupFields.email); - await user.type(screen.getByPlaceholderText(/username/i), signupFields.username); - await user.type(screen.getByPlaceholderText(/password/i), signupFields.password); - await user.type(screen.getByPlaceholderText(/full name/i), signupFields.fullName); - - await user.click(screen.getByRole('button')); - - await waitFor(() => { - const token = localStorage.getItem('instacloneSCToken'); - expect(token).toBeNull(); - expect(screen.getByText(/That username is already taken/i)).toBeVisible(); - }); -}); - -test('user is redirected to home page if they visit the signup page and are already logged in', async () => { - const fakeTokenInfo = { - username: 'bobbydob', - token: 'supersecrettoken', - }; - mockLogin({ fakeTokenInfo }); - - renderWithRouter(, { route: '/signup' }); - - expect(screen.queryByText(/don't have an account?/i)).toBeNull(); - expect(screen.getByTestId('homepage-container')).toBeVisible(); -}); diff --git a/frontend/src/test/int/userProfileEditChangeAvatarModal.test.tsx b/frontend/src/test/int/userProfileEditChangeAvatarModal.test.tsx new file mode 100644 index 0000000..b9ffa9e --- /dev/null +++ b/frontend/src/test/int/userProfileEditChangeAvatarModal.test.tsx @@ -0,0 +1,62 @@ +import { waitFor } from '@testing-library/react'; +import { rest } from 'msw'; +import { apiSlice } from '../../app/apiSlice'; +import UserProfileEdit from '../../features/users/UserProfile/UserProfileEdit/UserProfileEdit'; +import { fakeUser } from '../mocks/handlers'; +import server from '../mocks/server'; +import { + mockLogin, + testStore, + renderWithRouter, + screen, + mockLogout, +} from '../utils/test-utils'; + +const fakeTokenInfo = { + username: fakeUser.username, + accessToken: 'supersecrettoken', +}; + +beforeAll(() => server.listen()); +afterEach(() => { + mockLogout({ resetApiState: true }); + server.resetHandlers(); +}); +afterAll(() => server.close()); + +test('if logged in and profile has avatar image, clicking on avatar displays modal', async () => { + mockLogin({ fakeTokenInfo }); + + server.use( + rest.get('/api/users', (req, res, ctx) => res(ctx.status(200), ctx.json([{ + ...fakeUser, + image: { + url: 'https://i.picsum.photos/id/237/200/300.jpg?hmac=TmmQSbShHz9CdQm0NkEjx1Dyh_Y984R9LpNrpvH2D_U', + publicId: 'fakePublicId', + }, + }]))), + ); + + testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); + const { user } = renderWithRouter(); + + const avatar = await screen.findByTestId('avatar'); + await user.click(avatar); + + await waitFor(async () => { + expect(screen.queryByTestId('change-avatar-modal')).toBeVisible(); + }); +}); + +// testing ChangeAvatarModal features inside UseProfile +test('when clicking on avatar, modal does not appear if user does not have an image', async () => { + mockLogin({ fakeTokenInfo }); + testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); + + const { user } = renderWithRouter(); + + await waitFor(async () => { + await user.click(screen.getByTestId('avatar')); + expect(screen.queryByText(/remove current photo/i)).toBeNull(); + }); +}); diff --git a/frontend/src/test/int/userprofileedit.test.tsx b/frontend/src/test/int/userprofileedit.test.tsx index a4258f3..298c2ea 100644 --- a/frontend/src/test/int/userprofileedit.test.tsx +++ b/frontend/src/test/int/userprofileedit.test.tsx @@ -6,19 +6,19 @@ import { mockLogout, waitFor, fireEvent, + testStore, } from '../utils/test-utils'; import App from '../../app/App'; import '@testing-library/jest-dom/extend-expect'; import { fakeUser } from '../mocks/handlers'; import server from '../mocks/server'; import { apiSlice } from '../../app/apiSlice'; -import { store } from '../../app/store'; beforeAll(() => server.listen()); beforeEach(() => { const fakeTokenInfo = { username: fakeUser.username, - token: 'supersecrettoken', + accessToken: 'supersecrettoken', }; mockLogin({ fakeTokenInfo }); }); @@ -43,7 +43,7 @@ test('user with an image can upload a new one', async () => { const file = new File(['hello'], 'anyfile.png', { type: 'image/png' }); - store.dispatch(apiSlice.endpoints.getUsers.initiate()); + testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); const { user } = renderWithRouter(, { route: '/accounts/edit' }); const avatarImages = await screen.findAllByRole('img'); @@ -71,7 +71,7 @@ test('user with an image can upload a new one', async () => { }); test('updating username updates the JWT username field', async () => { - store.dispatch(apiSlice.endpoints.getUsers.initiate()); + testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); const { user } = renderWithRouter(, { route: '/accounts/edit' }); const usernameInput = await screen.findByPlaceholderText(/username/i); @@ -92,7 +92,7 @@ test('updating username updates the JWT username field', async () => { // testing ChangeAvatarModal features inside UserProfileEdit test('when clicking on avatar, modal does not appear if user does not have an image', async () => { - store.dispatch(apiSlice.endpoints.getUsers.initiate()); + testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); const { user } = renderWithRouter(, { route: '/accounts/edit' }); await waitFor(async () => { @@ -112,7 +112,7 @@ test('when clicking on avatar, modal does appear if user has an image', async () }]))), ); - store.dispatch(apiSlice.endpoints.getUsers.initiate()); + testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); const { user } = renderWithRouter(, { route: '/accounts/edit' }); const avatar = await screen.findByTestId('avatar'); diff --git a/frontend/src/test/int/viewingposts.test.tsx b/frontend/src/test/int/viewingPostsAfterPosting.test.tsx similarity index 94% rename from frontend/src/test/int/viewingposts.test.tsx rename to frontend/src/test/int/viewingPostsAfterPosting.test.tsx index 29208c2..a226a86 100644 --- a/frontend/src/test/int/viewingposts.test.tsx +++ b/frontend/src/test/int/viewingPostsAfterPosting.test.tsx @@ -9,8 +9,8 @@ import { render, Providers, waitFor, + testStore, } from '../utils/test-utils'; -import { store } from '../../app/store'; import { fakeUser } from '../mocks/handlers'; import server from '../mocks/server'; import { apiSlice } from '../../app/apiSlice'; @@ -21,7 +21,7 @@ beforeAll(() => server.listen()); beforeEach(() => { const fakeTokenInfo = { username: fakeUser.username, - token: 'supersecrettoken', + accessToken: 'supersecrettoken', }; mockLogin({ fakeTokenInfo }); }); @@ -39,7 +39,7 @@ afterAll(() => server.close()); */ test('post is viewable in homepage after creating it', async () => { - await store.dispatch(apiSlice.endpoints.getUsers.initiate()); + await testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); const history = createBrowserHistory(); history.push('/create/details', { croppedImage: testImage }); @@ -69,7 +69,7 @@ test('post is viewable in homepage after creating it', async () => { }); test('post is viewable in the user profile after creating it', async () => { - await store.dispatch(apiSlice.endpoints.getUsers.initiate()); + await testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); const history = createBrowserHistory(); history.push('/create/details', { croppedImage: testImage }); @@ -109,7 +109,7 @@ test('post is viewable in the user profile after creating it', async () => { }); test('post is viewable in the post view page', async () => { - await store.dispatch(apiSlice.endpoints.getUsers.initiate()); + await testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); const history = createBrowserHistory(); history.push('/create/details', { croppedImage: testImage }); @@ -159,7 +159,7 @@ test('post is viewable in the post view page', async () => { }); test('when a post has a caption, it is viewable in the post view page', async () => { - await store.dispatch(apiSlice.endpoints.getUsers.initiate()); + await testStore.dispatch(apiSlice.endpoints.getUsers.initiate()); const history = createBrowserHistory(); history.push('/create/details', { croppedImage: testImage }); diff --git a/frontend/src/test/mocks/handlers.ts b/frontend/src/test/mocks/handlers.ts index b8fa7cd..412fdab 100644 --- a/frontend/src/test/mocks/handlers.ts +++ b/frontend/src/test/mocks/handlers.ts @@ -21,6 +21,11 @@ export const fakeUser: User = { following: [], }; +export const fakeAccessToken = { + username: fakeUser.username, + accessToken: 'supersecrettoken', +}; + const posts: Post[] = []; const users: User[] = [fakeUser]; @@ -75,12 +80,19 @@ export const handlers = [ return res(ctx.status(404)); }), - rest.post('/api/login', (req, res, ctx) => { - console.log('POST /api/login'); - return res(ctx.status(200), ctx.json({ - username: req.body.username, - token: 'supersecrettoken', - })); + rest.post('/api/auth/login', (req, res, ctx) => { + console.log('POST /api/auth/login'); + return res(ctx.status(200), ctx.json(fakeAccessToken)); + }), + rest.post('/api/auth/logout', (_req, res, ctx) => { + console.log('POST /api/auth/logout'); + return res(ctx.status(204)); + }), + rest.post('/api/auth/refresh', (req, res, ctx) => { + console.log('POST /api/auth/refresh'); + ctx.delay(2500); + + return res(ctx.status(200), ctx.json(fakeAccessToken)); }), rest.put('/api/users/:id', (req, res, ctx) => { console.log('PUT /api/users/:id'); diff --git a/frontend/src/test/utils/mockLogin.test.ts b/frontend/src/test/utils/mockLogin.test.ts index 40f505f..61371cb 100644 --- a/frontend/src/test/utils/mockLogin.test.ts +++ b/frontend/src/test/utils/mockLogin.test.ts @@ -1,29 +1,19 @@ -import { mockLogin } from './test-utils'; -import { store } from '../../app/store'; -import { removeCurrentUser } from '../../features/auth/authSlice'; +import { mockLogin, testStore } from './test-utils'; +import { removeAuthenticatedState } from '../../features/auth/authSlice'; afterEach(() => { - localStorage.removeItem('instacloneSCToken'); - store.dispatch(removeCurrentUser()); + testStore.dispatch(removeAuthenticatedState()); }); const fakeTokenInfo = { username: 'bobbydob', - token: 'supersecrettoken', + accessToken: 'supersecrettoken', }; -test('mockLogin saves a token in localStorage', () => { +test('mockLogin stores the access token data in the Redux store', () => { mockLogin({ fakeTokenInfo }); - const storedToken = localStorage.getItem('instacloneSCToken'); - - expect(storedToken).not.toBeNull(); - expect(JSON.parse(storedToken!)).toEqual(fakeTokenInfo); -}); - -test('mockLogin stores the token info in the Redux store', () => { - mockLogin({ fakeTokenInfo }); - const state = store.getState(); + const state = testStore.getState(); expect(state.auth.username).toBe(fakeTokenInfo.username); - expect(state.auth.token).toBe(fakeTokenInfo.token); + expect(state.auth.accessToken).toBe(fakeTokenInfo.accessToken); }); diff --git a/frontend/src/test/utils/test-utils.tsx b/frontend/src/test/utils/test-utils.tsx index 3b17939..f7becb1 100644 --- a/frontend/src/test/utils/test-utils.tsx +++ b/frontend/src/test/utils/test-utils.tsx @@ -1,18 +1,27 @@ +/* eslint-disable max-len */ import React, { ReactElement, ReactNode } from 'react'; import { render, RenderOptions } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MantineProvider } from '@mantine/core'; import { Provider } from 'react-redux'; import { BrowserRouter } from 'react-router-dom'; +import { configureStore } from '@reduxjs/toolkit'; import theme from '../../app/theme'; -import { store } from '../../app/store'; -import { initAuthedUser, removeCurrentUser } from '../../features/auth/authSlice'; +import authReducer, { removeAuthenticatedState, setAuthenticatedState } from '../../features/auth/authSlice'; import { apiSlice } from '../../app/apiSlice'; +export const testStore = configureStore({ + reducer: { + [apiSlice.reducerPath]: apiSlice.reducer, + auth: authReducer, + }, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(apiSlice.middleware), +}); + export function Providers({ children }: { children: ReactNode }) { return ( - + {children} @@ -40,7 +49,7 @@ export const renderWithRouter = ( window.history.pushState({}, 'string', route); const view = customRender( - { ui } + {ui} , ); return { @@ -50,33 +59,34 @@ export const renderWithRouter = ( }; interface MockLogInOptions { - fakeTokenInfo: { username: string, token: string } + fakeTokenInfo: { username: string, accessToken: string } } /** - * Mocks a logged in state in the DOM and Redux. + * Mocks a logged in state in Redux. * @param {{ username: string, token: string }} fakeTokenInfo - The mock token object * @param {string} fakeTokenInfo.username - The test user's username * @param {string} fakeTokenInfo.token - The mock token. Can be any string. */ export const mockLogin = ({ fakeTokenInfo }: MockLogInOptions) => { - localStorage.setItem('instacloneSCToken', JSON.stringify(fakeTokenInfo)); - store.dispatch(initAuthedUser()); + testStore.dispatch(setAuthenticatedState(fakeTokenInfo)); }; /** - * Mocks a logged out state by resetting the `api`, - * clearing the `auth` state and removing the application's JWT token from `localStorage`. + * Mocks a logged out state by resetting the `api` and + * clearing the authenticated state in the Redux store. + * + * @param {{ resetApiState: boolean }} options - The options object + * @param {boolean} options.resetApiState - Boolean variable that will reset the `api` state if set to true. */ export const mockLogout = ({ resetApiState }: { resetApiState: boolean }) => { if (resetApiState) { - store.dispatch(apiSlice.util.resetApiState()); + testStore.dispatch(apiSlice.util.resetApiState()); } - store.dispatch(removeCurrentUser()); - localStorage.removeItem('instacloneSCToken'); + testStore.dispatch(removeAuthenticatedState()); }; /**