diff --git a/src/__tests__/auth.exercise.js b/src/__tests__/auth.exercise.js index a6c9eee..4ffafae 100644 --- a/src/__tests__/auth.exercise.js +++ b/src/__tests__/auth.exercise.js @@ -2,48 +2,118 @@ // 🐨 import the things you'll need // 💰 here, I'll just give them to you. You're welcome -// import axios from 'axios' -// import {resetDb} from 'utils/db-utils' -// import * as generate from 'utils/generate' -// import startServer from '../start' +import axios from 'axios' +import { resetDb } from 'utils/db-utils' +import * as generate from 'utils/generate' +import { handleRequestFailure, getData, resolve } from 'utils/async' +import * as usersDB from '../db/users' +import startServer from '../start' -// 🐨 you'll need to start/stop the server using beforeAll and afterAll -// 💰 This might be helpful: server = await startServer({port: 8000}) +let api, server -// 🐨 beforeEach test in this file we want to reset the database +beforeAll(async () => { + server = await startServer({ port: 8000 }) + const baseURL = `http://localhost:${server.address().port}/api` + api = axios.create({ baseURL }) + api.interceptors.response.use(getData, handleRequestFailure) +}) + +afterAll(() => server.close()) +beforeEach(() => resetDb()) test('auth flow', async () => { - // 🐨 get a username and password from generate.loginForm() - // - // register - // 🐨 use axios.post to post the username and password to the registration endpoint - // 💰 http://localhost:8000/api/auth/register - // - // 🐨 assert that the result you get back is correct - // 💰 it'll have an id and a token that will be random every time. - // You can either only check that `result.data.user.username` is correct, or - // for a little extra credit 💯 you can try using `expect.any(String)` - // (an asymmetric matcher) with toEqual. - // 📜 https://jestjs.io/docs/en/expect#expectanyconstructor - // 📜 https://jestjs.io/docs/en/expect#toequalvalue - // - // login - // 🐨 use axios.post to post the username and password again, but to the login endpoint - // 💰 http://localhost:8000/api/auth/login - // - // 🐨 assert that the result you get back is correct - // 💰 tip: the data you get back is exactly the same as the data you get back - // from the registration call, so this can be done really easily by comparing - // the data of those results with toEqual - // - // authenticated request - // 🐨 use axios.get(url, config) to GET the user's information - // 💰 http://localhost:8000/api/auth/me - // 💰 This request must be authenticated via the Authorization header which - // you can add to the config object: {headers: {Authorization: `Bearer ${token}`}} - // Remember that you have the token from the registration and login requests. - // - // 🐨 assert that the result you get back is correct - // 💰 (again, this should be the same data you get back in the other requests, - // so you can compare it with that). + const { username, password } = generate.loginForm() + const registerResponse = await api.post('auth/register', { + username, + password, + }) + + expect(registerResponse.user).toEqual({ + token: expect.any(String), + id: expect.any(String), + username, + }) + + const loginResponse = await api.post('auth/login', { + username, + password, + }) + + expect(registerResponse.user).toEqual(loginResponse.user) + + const meResponse = await api.get('auth/me', { + headers: { + Authorization: `Bearer ${loginResponse.user.token}`, + }, + }) + + expect(loginResponse.user).toEqual(meResponse.user) +}) + +test('username must be unique', async () => { + const { username, password } = generate.loginForm() + await usersDB.insert(generate.buildUser({ username })) + + const error = await api + .post('auth/register', { + username, + password, + }) + .catch(resolve) + + expect(error).toMatchInlineSnapshot( + `[Error: 400: {"message":"username taken"}]`, + ) +}) + +test('get me unauthenticated returns error', async () => { + const error = await api.get('auth/me').catch(resolve) + expect(error).toMatchInlineSnapshot( + `[Error: 401: {"code":"credentials_required","message":"No authorization token was found"}]`, + ) +}) + +test('username required to register', async () => { + const error = await api + .post('auth/register', { password: generate.password }) + .catch(resolve) + expect(error).toMatchInlineSnapshot( + `[Error: 400: {"message":"username can't be blank"}]`, + ) +}) + +test('password required to register', async () => { + const error = await api + .post('auth/register', { username: generate.username }) + .catch(resolve) + expect(error).toMatchInlineSnapshot( + `[Error: 400: {"message":"username can't be blank"}]`, + ) +}) + +test('username required to login', async () => { + const error = await api + .post('auth/login', { password: generate.password }) + .catch(resolve) + expect(error).toMatchInlineSnapshot( + `[Error: 400: {"message":"username can't be blank"}]`, + ) +}) + +test('password required to login', async () => { + const error = await api + .post('auth/login', { username: generate.username }) + .catch(resolve) + expect(error).toMatchInlineSnapshot( + `[Error: 400: {"message":"username can't be blank"}]`, + ) +}) + +test('user must exist to login', async () => { + const error = await api + .post('auth/login', generate.loginForm({ username: 'this_papu_not_exist' })) + .catch(resolve) + expect(error).toMatchInlineSnapshot( + `[Error: 400: {"message":"username or password is invalid"}]`, + ) }) diff --git a/src/__tests__/list-items.exercise.js b/src/__tests__/list-items.exercise.js index 5e6cf1b..e7c0731 100644 --- a/src/__tests__/list-items.exercise.js +++ b/src/__tests__/list-items.exercise.js @@ -1,8 +1,8 @@ // Testing CRUD API Routes import axios from 'axios' -import {resetDb, insertTestUser} from 'utils/db-utils' -import {getData, handleRequestFailure, resolve} from 'utils/async' +import { resetDb, insertTestUser } from 'utils/db-utils' +import { getData, handleRequestFailure, resolve } from 'utils/async' import * as generate from 'utils/generate' import * as booksDB from '../db/books' import startServer from '../start' @@ -23,48 +23,47 @@ async function setup() { // so I'm going to give it to you, but don't just skip over it. Try to figure // out what's going on here. const testUser = await insertTestUser() - const authAPI = axios.create({baseURL}) + const authAPI = axios.create({ baseURL }) authAPI.defaults.headers.common.authorization = `Bearer ${testUser.token}` authAPI.interceptors.response.use(getData, handleRequestFailure) - return {testUser, authAPI} + return { testUser, authAPI } } test('listItem CRUD', async () => { - const {testUser, authAPI} = await setup() + const { testUser, authAPI } = await setup() - // 🐨 create a book object and insert it into the database - // 💰 use generate.buildBook and await booksDB.insert + const book = generate.buildBook() + await booksDB.insert(book) - // CREATE - // 🐨 create a new list-item by posting to the list-items endpoint with a bookId - // 💰 the data you send should be: {bookId: book.id} - - // 🐨 assert that the data you get back is correct - // 💰 it should have an ownerId (testUser.id) and a bookId (book.id) - // 💰 if you don't want to assert on all the other properties, you can use - // toMatchObject: https://jestjs.io/docs/en/expect#tomatchobjectobject - - // 💰 you might find this useful for the future requests: - // const listItemId = cData.listItem.id - // const listItemIdUrl = `list-items/${listItemId}` + const cData = await authAPI.post('list-items', { bookId: book.id }) + expect(cData.listItem).toMatchObject({ + ownerId: testUser.id, + bookId: book.id, + }) + const listItemId = cData.listItem.id + const listItemIdUrl = `list-items/${listItemId}` // READ - // 🐨 make a GET to the `listItemIdUrl` - // 🐨 assert that this returns the same thing you got when you created the list item + const rData = await authAPI.get(listItemIdUrl) + expect(rData.listItem).toEqual(cData.listItem) // UPDATE // 🐨 make a PUT request to the `listItemIdUrl` with some updates - // 💰 const updates = {notes: generate.notes()} - // 🐨 assert that this returns the right stuff (should be the same as the READ except with the updated notes) + const updates = { notes: generate.notes() } + const uData = await authAPI.put(listItemIdUrl, updates) + expect(uData.listItem).toEqual({ ...rData.listItem, ...updates }) // DELETE - // 🐨 make a DELETE request to the `listItemIdUrl` - // 🐨 assert that this returns the right stuff (💰 {success: true}) + const dData = await authAPI.delete(listItemIdUrl) + expect(dData).toEqual({ success: true }) + + const error = await authAPI.get(listItemIdUrl).catch(resolve) + expect(error.status).toBe(404) - // 🐨 try to make a GET request to the `listItemIdUrl` again. - // 💰 this promise should reject. You can do a try/catch if you want, or you - // can use the `resolve` utility from utils/async: - // 💰 const error = await authAPI.get(listItemIdUrl).catch(resolve) + const idlessMessage = error.data.message.replace(listItemId, 'LIST_ITEM_ID') + expect(idlessMessage).toMatchInlineSnapshot( + `"No list item was found with the id of LIST_ITEM_ID"`, + ) // 🐨 assert that the status is 404 and the error.data is correct }) diff --git a/src/routes/__tests__/list-items-controller.exercise.js b/src/routes/__tests__/list-items-controller.exercise.js index f9c1db0..8bfbb53 100644 --- a/src/routes/__tests__/list-items-controller.exercise.js +++ b/src/routes/__tests__/list-items-controller.exercise.js @@ -1,45 +1,233 @@ // Testing Controllers -// 🐨 you'll need a few of the generaters from test/utils/generate.js -// 💰 remember, you can import files in the test/utils directory as if they're node_modules -// 💰 import * as generate from 'utils/generate' +import { + buildRes, + buildReq, + buildNext, + buildUser, + buildBook, + buildListItem, +} from 'utils/generate' +import * as listItemsDB from '../../db/list-items' +import * as booksDB from '../../db/books' +import * as listItemsController from '../list-items-controller' -// 🐨 getListItem calls `expandBookData` which calls `booksDB.readById` -// so you'll need to import the booksDB from '../../db/books' -// 💰 import * as booksDB from '../../db/books' +jest.mock('../../db/list-items') +jest.mock('../../db/books') -// 🐨 don't forget to import the listItemsController from '../list-items-controller' -// here, that's the thing we're testing afterall :) -// 💰 import * as listItemsController from '../list-items-controller' +beforeEach(() => { + jest.clearAllMocks() +}) + +test('getListItem returns the req.listItem', async () => { + const user = buildUser() + const book = buildBook() + const listItem = buildListItem({ ownerId: user.id, bookId: book.id }) -// 🐨 use jest.mock to mock '../../db/books' because we don't actually want to make -// database calls in this test file. + booksDB.readById.mockResolvedValueOnce(book) -// 🐨 ensure that all mock functions have their call history cleared using -// jest.clearAllMocks here as in the example. + const req = buildReq({ user, listItem }) + const res = buildRes() -test('getListItem returns the req.listItem', async () => { - // 🐨 create a user - // - // 🐨 create a book - // - // 🐨 create a listItem that has the user as the owner and the book - // 💰 const listItem = buildListItem({ownerId: user.id, bookId: book.id}) - // - // 🐨 mock booksDB.readById to resolve to the book - // 💰 use mockResolvedValueOnce - // - // 🐨 make a request object that has properties for the user and listItem - // 💰 checkout the implementation of getListItem in ../list-items-controller - // to see how the request object is used and what properties it needs. - // 💰 and you can use buildReq from utils/generate - // - // 🐨 make a response object - // 💰 just use buildRes from utils/generate - // - // 🐨 make a call to getListItem with the req and res (`await` the result) - // - // 🐨 assert that booksDB.readById was called correctly - // - //🐨 assert that res.json was called correctly + await listItemsController.getListItem(req, res) + + expect(booksDB.readById).toHaveBeenCalledWith(book.id) + expect(booksDB.readById).toHaveBeenCalledTimes(1) + + expect(res.json).toHaveBeenCalledWith({ + listItem: { ...listItem, book }, + }) + + expect(res.json).toHaveBeenCalledTimes(1) +}) + +test('createListItem returns a 400 error if no bookId is provided', async () => { + const req = buildReq() + const res = buildRes() + await listItemsController.createListItem(req, res) + expect(res.status).toHaveBeenCalledWith(400) + expect(res.json).toHaveBeenCalledTimes(1) + expect(res.json).toHaveBeenCalledWith({ message: `No bookId provided` }) + expect(res.json.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "message": "No bookId provided", + }, + ] + `) +}) + +test('setListItem sets the listItem on the req', async () => { + const user = buildUser() + const listItem = buildListItem({ ownerId: user.id }) + listItemsDB.readById.mockResolvedValueOnce(listItem) + + const req = buildReq({ user, params: { id: listItem.id } }) + const res = buildRes() + const next = buildNext() + + await listItemsController.setListItem(req, res, next) + + expect(listItemsDB.readById).toHaveBeenCalledWith(listItem.id) + expect(listItemsDB.readById).toHaveBeenCalledTimes(1) + + expect(next).toHaveBeenCalledWith(/* nothing */) + expect(next).toHaveBeenCalledTimes(1) + expect(req.listItem).toBe(listItem) +}) + +test('setListItem returns a 404 error if the list iten does not exists', async () => { + listItemsDB.readById.mockResolvedValueOnce(null) + + const fakeListItemId = 'FAKE_LIST_ITEM_ID' + const req = buildReq({ params: { id: fakeListItemId } }) + const res = buildRes() + const next = buildNext() + + await listItemsController.setListItem(req, res, next) + + expect(listItemsDB.readById).toHaveBeenCalledWith(fakeListItemId) + expect(listItemsDB.readById).toHaveBeenCalledTimes(1) + + expect(next).not.toHaveBeenCalled() + + expect(res.status).toHaveBeenCalledWith(404) + expect(res.status).toHaveBeenCalledTimes(1) + expect(res.json.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "message": "No list item was found with the id of FAKE_LIST_ITEM_ID", + }, + ] + `) + expect(res.json).toHaveBeenCalledTimes(1) +}) + +test('setListItem returns a 403 error if the list item does not belong to the user', async () => { + const user = buildUser({ id: 'FAKE_USER_ID' }) + const listItem = buildListItem({ + ownerId: 'SOMEONE_ELSE', + id: 'FAKE_LIST_ITEM_ID', + }) + + listItemsDB.readById.mockResolvedValueOnce(listItem) + + const req = buildReq({ user, params: { id: listItem.id } }) + const res = buildRes() + const next = buildNext() + + await listItemsController.setListItem(req, res, next) + + expect(listItemsDB.readById).toHaveBeenCalledWith(listItem.id) + expect(listItemsDB.readById).toHaveBeenCalledTimes(1) + + expect(next).not.toHaveBeenCalled() + + expect(res.status).toHaveBeenCalledWith(403) + expect(res.status).toHaveBeenCalledTimes(1) + expect(res.json.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "message": "User with id FAKE_USER_ID is not authorized to access the list item FAKE_LIST_ITEM_ID", + }, + ] + `) + expect(res.json).toHaveBeenCalledTimes(1) +}) + +test(`getListItems returns a user's list items`, async () => { + const user = buildUser() + const books = [buildBook(), buildBook()] + const listItems = [ + buildListItem({ ownerId: user.id, bookId: books[0].id }), + buildListItem({ ownerId: user.id, bookId: books[1].id }), + ] + + booksDB.readManyById.mockResolvedValueOnce(books) + listItemsDB.query.mockResolvedValueOnce(listItems) + + const req = buildReq({ user }) + const res = buildRes() + await listItemsController.getListItems(req, res) + + expect(booksDB.readManyById).toHaveBeenCalledWith([books[0].id, books[1].id]) + expect(booksDB.readManyById).toHaveBeenCalledTimes(1) + expect(listItemsDB.query).toHaveBeenCalledWith({ ownerId: req.user.id }) + expect(listItemsDB.query).toHaveBeenCalledTimes(1) + expect(res.json).toHaveBeenCalledWith({ + listItems: [ + { ...listItems[0], book: books[0] }, + { ...listItems[1], book: books[1] }, + ], + }) + expect(res.json).toHaveBeenCalledTimes(1) +}) + +test('createListItem creates and returns a list item', async () => { + const user = buildUser() + const book = buildBook() + const createdListItem = buildListItem({ bookId: book.id, ownerId: user.id }) + + listItemsDB.query.mockResolvedValueOnce([]) + listItemsDB.create.mockResolvedValueOnce(createdListItem) + booksDB.readById.mockResolvedValueOnce(book) + + const req = buildReq({ user, body: { bookId: book.id } }) + const res = buildRes() + await listItemsController.createListItem(req, res) + + expect(listItemsDB.query).toHaveBeenCalledWith({ + ownerId: user.id, + bookId: book.id, + }) + expect(listItemsDB.query).toHaveBeenCalledTimes(1) + + expect(listItemsDB.create).toHaveBeenCalledWith({ + ownerId: user.id, + bookId: book.id, + }) + expect(listItemsDB.create).toHaveBeenCalledTimes(1) + + expect(booksDB.readById).toHaveBeenCalledWith(book.id) + expect(booksDB.readById).toHaveBeenCalledTimes(1) + + expect(res.json).toHaveBeenCalledWith({ + listItem: { + ...createdListItem, + book, + }, + }) + expect(res.json).toHaveBeenCalledTimes(1) +}) + +test('createListItem returns a 400 error if the user already has a list item for the given book', async () => { + const fakeBookId = 'FAKE_BOOK_ID' + const fakeUserId = 'FAKE_USER_ID' + const user = buildUser({ id: fakeUserId }) + const book = buildBook({ id: fakeBookId }) + const existingListItem = buildListItem({ bookId: book.id, ownerId: user.id }) + + listItemsDB.query.mockResolvedValueOnce([existingListItem]) + + const req = buildReq({ user, body: { bookId: book.id } }) + const res = buildRes() + + await listItemsController.createListItem(req, res) + + expect(listItemsDB.query).toHaveBeenCalledWith({ + ownerId: user.id, + bookId: book.id, + }) + expect(listItemsDB.query).toHaveBeenCalledTimes(1) + + expect(res.status).toHaveBeenCalledWith(400) + expect(res.status).toHaveBeenCalledTimes(1) + expect(res.json.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "message": "User FAKE_USER_ID already has a list item for the book with the ID FAKE_BOOK_ID", + }, + ] + `) + expect(res.json).toHaveBeenCalledTimes(1) }) diff --git a/src/utils/__tests__/auth.exercise.js b/src/utils/__tests__/auth.exercise.js index 5af8b5c..e2ae671 100644 --- a/src/utils/__tests__/auth.exercise.js +++ b/src/utils/__tests__/auth.exercise.js @@ -1,21 +1,46 @@ // Testing Pure Functions // 💣 remove this todo test (it's only here so you don't get an error about missing tests) -test.todo('remove me') - // 🐨 import the function that we're testing // 💰 import {isPasswordAllowed} from '../auth' +import cases from 'jest-in-case' +import { isPasswordAllowed } from '../auth' // 🐨 write tests for valid and invalid passwords // 💰 here are some you can use: // -// valid: -// - !aBc123 -// -// invalid: -// - a2c! // too short -// - 123456! // no alphabet characters -// - ABCdef! // no numbers -// - abc123! // no uppercase letters -// - ABC123! // no lowercase letters -// - ABCdef123 // no non-alphanumeric characters + +function casify(obj) { + return Object.entries(obj).map(([name, password]) => { + return { + name: `${password} - ${name}`, + password + } + }) +} + +cases( + 'isPasswordAllowed valid passwords', + (options) => { + expect(isPasswordAllowed(options.password)).toBe(true) + }, + casify({ + 'valid password': '!aBc123' + }) +) + +cases( + 'isPasswordAllowed invalid passwords', + (options) => { + expect(isPasswordAllowed(options.password)).toBe(false) + }, + casify({ + 'too short': 'a2c!', + 'no letters': '123456!', + 'no numbers': 'ABCdef!', + 'no uppercase letters': 'abc123!', + 'no lowercase letters': 'ABC123!', + 'non alphanumeric characters': 'ABCdef123' + }) +) + diff --git a/src/utils/__tests__/error-middleware.exercise.js b/src/utils/__tests__/error-middleware.exercise.js index 9efcd65..2a233cd 100644 --- a/src/utils/__tests__/error-middleware.exercise.js +++ b/src/utils/__tests__/error-middleware.exercise.js @@ -1,16 +1,54 @@ // Testing Middleware -// 💣 remove this todo test (it's only here so you don't get an error about missing tests) -test.todo('remove me') - -// 🐨 you'll need both of these: -// import {UnauthorizedError} from 'express-jwt' -// import errorMiddleware from '../error-middleware' +import { buildRes, buildReq, buildNext } from 'utils/generate' +import { UnauthorizedError } from 'express-jwt' +import errorMiddleware from '../error-middleware' // 🐨 Write a test for the UnauthorizedError case -// 💰 const error = new UnauthorizedError('some_error_code', {message: 'Some message'}) -// 💰 const res = {json: jest.fn(() => res), status: jest.fn(() => res)} +test('response with 401 for express-jwt Unauthorized Error', () => { + const req = buildReq() + const next = buildNext() + const code = 'some_error_code' + const message = 'Some message' + const error = new UnauthorizedError(code, { message }) + const res = buildRes() + errorMiddleware(error, req, res, next) + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(401) + expect(res.status).toHaveBeenCalledTimes(1) + expect(res.json).toHaveBeenCalledWith({ + code: error.code, + message: error.message + }) + expect(res.json).toHaveBeenCalledTimes(1) +}) + // 🐨 Write a test for the headersSent case +test('response headersSent Error', () => { + const req = buildReq() + const next = buildNext() + const error = new Error('blah') + const res = buildRes({ headersSent: true }) + errorMiddleware(error, req, res, next) + expect(next).toHaveBeenCalledWith(error) + expect(res.status).not.toHaveBeenCalled() + expect(res.json).not.toHaveBeenCalled() +}) // 🐨 Write a test for the else case (responds with a 500) +test('response with 500 for express-jwt Error', () => { + const req = buildReq() + const next = buildNext() + const error = new Error('blah') + const res = buildRes() + errorMiddleware(error, req, res, next) + expect(next).not.toHaveBeenCalled() + expect(res.status).toHaveBeenCalledWith(500) + expect(res.status).toHaveBeenCalledTimes(1) + expect(res.json).toHaveBeenCalledWith({ + message: error.message, + stack: error.stack + }) + expect(res.json).toHaveBeenCalledTimes(1) +}) \ No newline at end of file diff --git a/test/setup-env.js b/test/setup-env.js index f873983..7967e25 100644 --- a/test/setup-env.js +++ b/test/setup-env.js @@ -1 +1,3 @@ -process.env.PORT = 0 +const port = 8000 + Number(process.env.JEST_WORKER_ID) + +process.env.PORT = process.env.PORT || port