From e9b6599c2401fac542bbc997d7a594a32777222e Mon Sep 17 00:00:00 2001 From: Diego Santos <106088657+dizerdev@users.noreply.github.com> Date: Thu, 5 Jun 2025 11:35:55 +0000 Subject: [PATCH 1/3] build: install `bcryptjs` --- package-lock.json | 10 ++++++++++ package.json | 1 + 2 files changed, 11 insertions(+) diff --git a/package-lock.json b/package-lock.json index bed3121..efa2a76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "async-retry": "1.3.3", + "bcryptjs": "^3.0.2", "dotenv": "16.5.0", "dotenv-expand": "12.0.2", "next": "15.3.1", @@ -3716,6 +3717,15 @@ ], "license": "MIT" }, + "node_modules/bcryptjs": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz", + "integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==", + "license": "BSD-3-Clause", + "bin": { + "bcrypt": "bin/bcrypt" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", diff --git a/package.json b/package.json index 53f36e9..7c9190b 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "license": "MIT", "dependencies": { "async-retry": "1.3.3", + "bcryptjs": "3.0.2", "dotenv": "16.5.0", "dotenv-expand": "12.0.2", "next": "15.3.1", From 898b5e55c1c21fb97892c5c1cf3279f9b2a4f5c6 Mon Sep 17 00:00:00 2001 From: Diego Santos <106088657+dizerdev@users.noreply.github.com> Date: Thu, 5 Jun 2025 11:37:05 +0000 Subject: [PATCH 2/3] feat: hash `password` in `user.create` --- models/password.js | 22 +++++++++++++++++++ models/user.js | 7 ++++++ .../api/v1/users/[username]/get.test.js | 4 ++-- tests/integration/api/v1/users/post.test.js | 16 +++++++++++++- 4 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 models/password.js diff --git a/models/password.js b/models/password.js new file mode 100644 index 0000000..04bb324 --- /dev/null +++ b/models/password.js @@ -0,0 +1,22 @@ +import bcryptjs from "bcryptjs"; +import user from "./user"; + +async function hash(password) { + const rounds = getNumbersOfRounds(); + return await bcryptjs.hash(password, rounds); +} + +function getNumbersOfRounds() { + return process.env.NODE_ENV === "development" ? 1 : 14; +} + +async function compare(providedPassword, storedPassword) { + return await bcryptjs.compare(providedPassword, storedPassword); +} + +const password = { + hash, + compare, +}; + +export default password; diff --git a/models/user.js b/models/user.js index a0f9548..9331c9f 100644 --- a/models/user.js +++ b/models/user.js @@ -1,9 +1,11 @@ import database from "infra/database.js"; +import password from "models/password.js"; import { ValidationError, NotFoundError } from "infra/errors.js"; async function create(userInputValues) { await validateUniqueEmail(userInputValues.email); await validateUniqueUsername(userInputValues.username); + await hashPasswordInObject(userInputValues); const newUser = await runInsertQuery(userInputValues); return newUser; @@ -50,6 +52,11 @@ async function create(userInputValues) { } } + async function hashPasswordInObject(userInputValues) { + const hashedPassword = await password.hash(userInputValues.password); + userInputValues.password = hashedPassword; + } + async function runInsertQuery(userInputValues) { const results = await database.query({ text: ` diff --git a/tests/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js index 476c1bd..fb60fa7 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -33,7 +33,7 @@ describe("GET api/v1/users/[username]", () => { id: response2Body.id, username: "MesmoCase", email: "mesmo.case@teste.com.br", - password: "123abc", + password: response2Body.password, created_at: response2Body.created_at, updated_at: response2Body.updated_at, }); @@ -66,7 +66,7 @@ describe("GET api/v1/users/[username]", () => { id: response2Body.id, username: "CaseDiferente", email: "case.diferente@teste.com.br", - password: "123abc", + password: response2Body.password, created_at: response2Body.created_at, updated_at: response2Body.updated_at, }); diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index e941882..6b5c1a6 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -1,5 +1,7 @@ import { version as uuidVersion } from "uuid"; import orchestrator from "tests/orchestrator.js"; +import user from "models/user"; +import password from "models/password.js"; beforeAll(async () => { await orchestrator.waitForAllServices(); @@ -29,13 +31,25 @@ describe("POST api/v1/users", () => { id: responseBody.id, username: "luciacal", email: "diegosantos@teste.com.br", - password: "123abc", + password: responseBody.password, created_at: responseBody.created_at, updated_at: responseBody.updated_at, }); expect(uuidVersion(responseBody.id)).toBe(4); expect(Date.parse(responseBody.created_at)).not.toBeNaN(); expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + + const userInDatabase = await user.findOneByUsername("luciacal"); + const correctPasswordMatch = await password.compare( + "123abc", + userInDatabase.password, + ); + const incorrectPasswordMatch = await password.compare( + "senhaErrada", + userInDatabase.password, + ); + expect(correctPasswordMatch).toBe(true); + expect(incorrectPasswordMatch).toBe(false); }); test("With duplicated 'email'", async () => { From b0c82e5761b5f610e32b9f0849e1f80d747659d5 Mon Sep 17 00:00:00 2001 From: Diego Santos <106088657+dizerdev@users.noreply.github.com> Date: Fri, 6 Jun 2025 12:01:33 +0000 Subject: [PATCH 3/3] feat: add support to `PATCH` on `/api/v1/users/[username]` --- models/password.js | 1 - models/user.js | 153 ++++++---- pages/api/v1/users/[username]/index.js | 10 + .../api/v1/users/[username]/patch.test.js | 270 ++++++++++++++++++ tests/integration/api/v1/users/post.test.js | 4 +- 5 files changed, 382 insertions(+), 56 deletions(-) create mode 100644 tests/integration/api/v1/users/[username]/patch.test.js diff --git a/models/password.js b/models/password.js index 04bb324..b27e29a 100644 --- a/models/password.js +++ b/models/password.js @@ -1,5 +1,4 @@ import bcryptjs from "bcryptjs"; -import user from "./user"; async function hash(password) { const rounds = getNumbersOfRounds(); diff --git a/models/user.js b/models/user.js index 9331c9f..6aa880c 100644 --- a/models/user.js +++ b/models/user.js @@ -2,60 +2,42 @@ import database from "infra/database.js"; import password from "models/password.js"; import { ValidationError, NotFoundError } from "infra/errors.js"; -async function create(userInputValues) { - await validateUniqueEmail(userInputValues.email); - await validateUniqueUsername(userInputValues.username); - await hashPasswordInObject(userInputValues); - - const newUser = await runInsertQuery(userInputValues); - return newUser; - - async function validateUniqueEmail(email) { - const results = await database.query({ - text: ` - SELECT - email - FROM - users - WHERE - LOWER(email) = LOWER($1) - ;`, - values: [email], - }); - - if (results.rowCount > 0) { - throw new ValidationError({ - message: "O email informado já esta sendo utilizado.", - action: "Utilize outro email para realizar o cadastro.", - }); - } - } +async function findOneByUsername(username) { + const userFound = await runSelectQuery(username); + return userFound; - async function validateUniqueUsername(username) { + async function runSelectQuery(username) { const results = await database.query({ text: ` SELECT - username + * FROM users WHERE LOWER(username) = LOWER($1) + LIMIT + 1 ;`, values: [username], }); - if (results.rowCount > 0) { - throw new ValidationError({ - message: "O username informado já esta sendo utilizado.", - action: "Utilize outro username para realizar o cadastro.", + if (results.rowCount === 0) { + throw new NotFoundError({ + message: "O username informado não foi encontrado no sistema.", + action: "Verifique se o username está digitado corretamente.", }); } + return results.rows[0]; } +} - async function hashPasswordInObject(userInputValues) { - const hashedPassword = await password.hash(userInputValues.password); - userInputValues.password = hashedPassword; - } +async function create(userInputValues) { + await validateUniqueUsername(userInputValues.username); + await validateUniqueEmail(userInputValues.email); + await hashPasswordInObject(userInputValues); + + const newUser = await runInsertQuery(userInputValues); + return newUser; async function runInsertQuery(userInputValues) { const results = await database.query({ @@ -78,38 +60,103 @@ async function create(userInputValues) { } } -async function findOneByUsername(username) { - const userFound = await runSelectQuery(username); - return userFound; +async function update(username, userInputValues) { + const currentUser = await findOneByUsername(username); - async function runSelectQuery(username) { + if ("username" in userInputValues) { + await validateUniqueUsername(userInputValues.username); + } + + if ("email" in userInputValues) { + await validateUniqueEmail(userInputValues.email); + } + + if ("password" in userInputValues) { + await hashPasswordInObject(userInputValues); + } + + const userWithNewValues = { ...currentUser, ...userInputValues }; + + const updatedUser = await runUpdateQuery(userWithNewValues); + return updatedUser; + + async function runUpdateQuery(userWithNewValues) { const results = await database.query({ text: ` + UPDATE + users + SET + username = $2, + email = $3, + password = $4, + updated_at = timezone('utc', now()) + WHERE + id = $1 + RETURNING + * + `, + values: [ + userWithNewValues.id, + userWithNewValues.username, + userWithNewValues.email, + userWithNewValues.password, + ], + }); + return results.rows[0]; + } +} + +async function validateUniqueUsername(username) { + const results = await database.query({ + text: ` SELECT - * + username FROM users WHERE LOWER(username) = LOWER($1) - LIMIT - 1 ;`, - values: [username], + values: [username], + }); + + if (results.rowCount > 0) { + throw new ValidationError({ + message: "O username informado já esta sendo utilizado.", + action: "Utilize outro username para realizar esta operação.", }); + } +} - if (results.rowCount === 0) { - throw new NotFoundError({ - message: "O username informado não foi encontrado no sistema.", - action: "Verifique se o username está digitado corretamente.", - }); - } - return results.rows[0]; +async function validateUniqueEmail(email) { + const results = await database.query({ + text: ` + SELECT + email + FROM + users + WHERE + LOWER(email) = LOWER($1) + ;`, + values: [email], + }); + + if (results.rowCount > 0) { + throw new ValidationError({ + message: "O email informado já esta sendo utilizado.", + action: "Utilize outro email para realizar esta operação.", + }); } } +async function hashPasswordInObject(userInputValues) { + const hashedPassword = await password.hash(userInputValues.password); + userInputValues.password = hashedPassword; +} + const user = { create, findOneByUsername, + update, }; export default user; diff --git a/pages/api/v1/users/[username]/index.js b/pages/api/v1/users/[username]/index.js index 2e2ecb7..d843feb 100644 --- a/pages/api/v1/users/[username]/index.js +++ b/pages/api/v1/users/[username]/index.js @@ -5,6 +5,7 @@ import user from "models/user.js"; const router = createRouter(); router.get(getHandler); +router.patch(patchHandler); export default router.handler(controller.errorHandlers); @@ -13,3 +14,12 @@ async function getHandler(request, response) { const userFound = await user.findOneByUsername(username); return response.status(200).json(userFound); } + +async function patchHandler(request, response) { + const username = request.query.username; + const userInputValues = request.body; + + const updatedUser = await user.update(username, userInputValues); + + return response.status(200).json(updatedUser); +} diff --git a/tests/integration/api/v1/users/[username]/patch.test.js b/tests/integration/api/v1/users/[username]/patch.test.js new file mode 100644 index 0000000..7f779dd --- /dev/null +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -0,0 +1,270 @@ +import { version as uuidVersion } from "uuid"; +import orchestrator from "tests/orchestrator.js"; +import user from "models/user"; +import password from "models/password.js"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("GET api/v1/users/[username]", () => { + describe("Anonymous user", () => { + test("With nonexistent 'username'", async () => { + const response = await fetch( + "http://localhost:3000/api/v1/users/usuarioinexistente", + { + method: "PATCH", + }, + ); + expect(response.status).toBe(404); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "NotFoundError", + message: "O username informado não foi encontrado no sistema.", + action: "Verifique se o username está digitado corretamente.", + status_code: 404, + }); + }); + + test("With duplicated 'username'", async () => { + const user1Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "user1", + email: "user1@teste.com.br", + password: "123abc", + }), + }); + expect(user1Response.status).toBe(201); + + const user2Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "user2", + email: "user2@teste.com.br", + password: "123abc", + }), + }); + expect(user2Response.status).toBe(201); + + const response = await fetch("http://localhost:3000/api/v1/users/user2", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "user1", + }), + }); + expect(response.status).toBe(400); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ValidationError", + message: "O username informado já esta sendo utilizado.", + action: "Utilize outro username para realizar esta operação.", + status_code: 400, + }); + }); + + test("With duplicated 'email'", async () => { + const user1Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "email1", + email: "email1@teste.com.br", + password: "123abc", + }), + }); + expect(user1Response.status).toBe(201); + + const user2Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "email2", + email: "email2@teste.com.br", + password: "123abc", + }), + }); + expect(user2Response.status).toBe(201); + + const response = await fetch( + "http://localhost:3000/api/v1/users/email2", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "email1@teste.com.br", + }), + }, + ); + expect(response.status).toBe(400); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + name: "ValidationError", + message: "O email informado já esta sendo utilizado.", + action: "Utilize outro email para realizar esta operação.", + status_code: 400, + }); + }); + + test("With unique 'username'", async () => { + const user1Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "uniqueUser1", + email: "uniqueUser1@teste.com.br", + password: "123abc", + }), + }); + expect(user1Response.status).toBe(201); + + const response = await fetch( + "http://localhost:3000/api/v1/users/uniqueUser1", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "uniqueUser2", + }), + }, + ); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + id: responseBody.id, + username: "uniqueUser2", + email: "uniqueUser1@teste.com.br", + password: responseBody.password, + created_at: responseBody.created_at, + updated_at: responseBody.updated_at, + }); + expect(uuidVersion(responseBody.id)).toBe(4); + expect(Date.parse(responseBody.created_at)).not.toBeNaN(); + expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + expect(responseBody.updated_at > responseBody.created_at).toBe(true); + }); + + test("With unique 'email'", async () => { + const user1Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "uniqueEmail1", + email: "uniqueEmail1@teste.com.br", + password: "123abc", + }), + }); + expect(user1Response.status).toBe(201); + + const response = await fetch( + "http://localhost:3000/api/v1/users/uniqueEmail1", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "uniqueEmail2@teste.com.br", + }), + }, + ); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + id: responseBody.id, + username: "uniqueEmail1", + email: "uniqueEmail2@teste.com.br", + password: responseBody.password, + created_at: responseBody.created_at, + updated_at: responseBody.updated_at, + }); + expect(uuidVersion(responseBody.id)).toBe(4); + expect(Date.parse(responseBody.created_at)).not.toBeNaN(); + expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + expect(responseBody.updated_at > responseBody.created_at).toBe(true); + }); + + test("With new 'password'", async () => { + const user1Response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "newPassword1", + email: "newPassword1@teste.com.br", + password: "newPassword1", + }), + }); + expect(user1Response.status).toBe(201); + + const response = await fetch( + "http://localhost:3000/api/v1/users/newPassword1", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + password: "newPassword2", + }), + }, + ); + expect(response.status).toBe(200); + + const responseBody = await response.json(); + expect(responseBody).toEqual({ + id: responseBody.id, + username: "newPassword1", + email: "newPassword1@teste.com.br", + password: responseBody.password, + created_at: responseBody.created_at, + updated_at: responseBody.updated_at, + }); + expect(uuidVersion(responseBody.id)).toBe(4); + expect(Date.parse(responseBody.created_at)).not.toBeNaN(); + expect(Date.parse(responseBody.updated_at)).not.toBeNaN(); + expect(responseBody.updated_at > responseBody.created_at).toBe(true); + + const userInDatabase = await user.findOneByUsername("newPassword1"); + const correctPasswordMatch = await password.compare( + "newPassword2", + userInDatabase.password, + ); + const incorrectPasswordMatch = await password.compare( + "newPassword1", + userInDatabase.password, + ); + expect(correctPasswordMatch).toBe(true); + expect(incorrectPasswordMatch).toBe(false); + }); + }); +}); diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js index 6b5c1a6..540992c 100644 --- a/tests/integration/api/v1/users/post.test.js +++ b/tests/integration/api/v1/users/post.test.js @@ -83,7 +83,7 @@ describe("POST api/v1/users", () => { expect(response2Body).toEqual({ name: "ValidationError", message: "O email informado já esta sendo utilizado.", - action: "Utilize outro email para realizar o cadastro.", + action: "Utilize outro email para realizar esta operação.", status_code: 400, }); }); @@ -119,7 +119,7 @@ describe("POST api/v1/users", () => { expect(response2Body).toEqual({ name: "ValidationError", message: "O username informado já esta sendo utilizado.", - action: "Utilize outro username para realizar o cadastro.", + action: "Utilize outro username para realizar esta operação.", status_code: 400, }); });