diff --git a/infra/migrations/1711495753953_Teste.js b/infra/migrations/1748084895204_users.js old mode 100755 new mode 100644 similarity index 91% rename from infra/migrations/1711495753953_Teste.js rename to infra/migrations/1748084895204_users.js index 7c2175f..51bdd62 --- a/infra/migrations/1711495753953_Teste.js +++ b/infra/migrations/1748084895204_users.js @@ -1,7 +1,3 @@ -/* eslint-disable camelcase */ - -exports.shorthands = undefined; - exports.up = (pgm) => { pgm.createTable("users", { id: { @@ -45,4 +41,4 @@ exports.up = (pgm) => { }); }; -exports.down = false; +exports.down = false; \ No newline at end of file diff --git a/models/user.js b/models/user.js index e69de29..5066a53 100755 --- a/models/user.js +++ b/models/user.js @@ -0,0 +1,164 @@ +import database from "infra/database.js"; +import password from "models/password.js"; +import { ValidationError, NotFoundError } from "infra/errors.js"; + +async function findOneByUsername(username) { + const userFound = await runSelectQuery(username); + + return userFound; + + async function runSelectQuery(username) { + const results = await database.query({ + text: ` + SELECT + * + FROM + users + WHERE + LOWER(username) = LOWER($1) + LIMIT + 1 + ;`, + values: [username], + }); + + 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 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({ + text: ` + INSERT INTO + users (username, email, password) + VALUES + ($1, $2, $3) + RETURNING + * + ;`, + values: [ + userInputValues.username, + userInputValues.email, + userInputValues.password, + ], + }); + return results.rows[0]; + } +} + +async function update(username, userInputValues) { + const currentUser = await findOneByUsername(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) + ;`, + values: [username], + }); + + if (results.rowCount > 0) { + throw new ValidationError({ + message: "O username informado já está sendo utilizado.", + action: "Utilize outro username para realizar esta operação.", + }); + } +} + +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á está 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; \ No newline at end of file diff --git a/pages/api/v1/users/[username]/index.js b/pages/api/v1/users/[username]/index.js new file mode 100644 index 0000000..29be540 --- /dev/null +++ b/pages/api/v1/users/[username]/index.js @@ -0,0 +1,24 @@ +import { createRouter } from "next-connect"; +import controller from "infra/controller.js"; +import user from "models/user.js"; + +const router = createRouter(); + +router.get(getHandler); +router.patch(patchHandler); + +export default router.handler(controller.errorHandlers); + +async function getHandler(request, response) { + const username = request.query.username; + 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); +} \ No newline at end of file diff --git a/pages/api/v1/users/index.js b/pages/api/v1/users/index.js new file mode 100644 index 0000000..6217002 --- /dev/null +++ b/pages/api/v1/users/index.js @@ -0,0 +1,15 @@ +import { createRouter } from "next-connect"; +import controller from "infra/controller.js"; +import user from "models/user.js"; + +const router = createRouter(); + +router.post(postHandler); + +export default router.handler(controller.errorHandlers); + +async function postHandler(request, response) { + const userInputValues = request.body; + const newUser = await user.create(userInputValues); + return response.status(201).json(newUser); +} \ No newline at end of file diff --git a/tests/integration/api/v1/migrations/post.test.js b/tests/integration/api/v1/migrations/post.test.js index 88b55f9..22dfe08 100755 --- a/tests/integration/api/v1/migrations/post.test.js +++ b/tests/integration/api/v1/migrations/post.test.js @@ -3,6 +3,7 @@ import orchestrator from "tests/orchestrator.js"; beforeAll(async () => { await orchestrator.waitForAllServices(); await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); }); describe("POST /api/v1/migrations", () => { diff --git a/tests/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js new file mode 100644 index 0000000..5b4db9d --- /dev/null +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -0,0 +1,83 @@ +import { version as uuidVersion } from "uuid"; +import orchestrator from "tests/orchestrator.js"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("GET /api/v1/users/[username]", () => { + describe("Anonymous user", () => { + test("With exact case match", async () => { + const createdUser = await orchestrator.createUser({ + username: "MesmoCase", + }); + + const response = await fetch( + "http://localhost:3000/api/v1/users/MesmoCase", + ); + + expect(response.status).toBe(200); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: responseBody.id, + username: "MesmoCase", + email: createdUser.email, + 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(); + }); + + test("With case mismatch", async () => { + const createdUser = await orchestrator.createUser({ + username: "CaseDiferente", + }); + + const response = await fetch( + "http://localhost:3000/api/v1/users/casediferente", + ); + + expect(response.status).toBe(200); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: responseBody.id, + username: "CaseDiferente", + email: createdUser.email, + 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(); + }); + + test("With nonexistent username", async () => { + const response = await fetch( + "http://localhost:3000/api/v1/users/UsuarioInexistente", + ); + + 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, + }); + }); + }); +}); \ No newline at end of file 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..92d10e7 --- /dev/null +++ b/tests/integration/api/v1/users/[username]/patch.test.js @@ -0,0 +1,224 @@ +import { version as uuidVersion } from "uuid"; +import orchestrator from "tests/orchestrator.js"; +import user from "models/user.js"; +import password from "models/password.js"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("PATCH /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 () => { + await orchestrator.createUser({ + username: "user1", + }); + + await orchestrator.createUser({ + username: "user2", + }); + + 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á está sendo utilizado.", + action: "Utilize outro username para realizar esta operação.", + status_code: 400, + }); + }); + + test("With duplicated 'email'", async () => { + await orchestrator.createUser({ + email: "email1@curso.dev", + }); + + const createdUser2 = await orchestrator.createUser({ + email: "email2@curso.dev", + }); + + const response = await fetch( + `http://localhost:3000/api/v1/users/${createdUser2.username}`, + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "email1@curso.dev", + }), + }, + ); + + expect(response.status).toBe(400); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "ValidationError", + message: "O email informado já está sendo utilizado.", + action: "Utilize outro email para realizar esta operação.", + status_code: 400, + }); + }); + + test("With unique 'username'", async () => { + const createdUser = await orchestrator.createUser(); + + const response = await fetch( + `http://localhost:3000/api/v1/users/${createdUser.username}`, + { + 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: createdUser.email, + 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 createdUser = await orchestrator.createUser(); + + const response = await fetch( + `http://localhost:3000/api/v1/users/${createdUser.username}`, + + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email: "uniqueEmail2@curso.dev", + }), + }, + ); + + expect(response.status).toBe(200); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: responseBody.id, + username: createdUser.username, + email: "uniqueEmail2@curso.dev", + 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 createdUser = await orchestrator.createUser({ + password: "newPassword1", + }); + + const response = await fetch( + `http://localhost:3000/api/v1/users/${createdUser.username}`, + { + 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: createdUser.username, + email: createdUser.email, + 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(createdUser.username); + const correctPasswordMatch = await password.compare( + "newPassword2", + userInDatabase.password, + ); + + const incorrectPasswordMatch = await password.compare( + "newPassword1", + userInDatabase.password, + ); + + expect(correctPasswordMatch).toBe(true); + expect(incorrectPasswordMatch).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/tests/integration/api/v1/users/post.test.js b/tests/integration/api/v1/users/post.test.js new file mode 100644 index 0000000..419df68 --- /dev/null +++ b/tests/integration/api/v1/users/post.test.js @@ -0,0 +1,137 @@ +import { version as uuidVersion } from "uuid"; +import orchestrator from "tests/orchestrator.js"; +import user from "models/user.js"; +import password from "models/password.js"; + +beforeAll(async () => { + await orchestrator.waitForAllServices(); + await orchestrator.clearDatabase(); + await orchestrator.runPendingMigrations(); +}); + +describe("POST /api/v1/users", () => { + describe("Anonymous user", () => { + test("With unique and valid data", async () => { + const response = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "filipedeschamps", + email: "contato@curso.dev", + password: "senha123", + }), + }); + + expect(response.status).toBe(201); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + id: responseBody.id, + username: "filipedeschamps", + email: "contato@curso.dev", + 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("filipedeschamps"); + const correctPasswordMatch = await password.compare( + "senha123", + userInDatabase.password, + ); + + const incorrectPasswordMatch = await password.compare( + "SenhaErrada", + userInDatabase.password, + ); + + expect(correctPasswordMatch).toBe(true); + expect(incorrectPasswordMatch).toBe(false); + }); + + test("With duplicated 'email'", async () => { + const response1 = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "emailduplicado1", + email: "duplicado@curso.dev", + password: "senha123", + }), + }); + + expect(response1.status).toBe(201); + + const response2 = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "emailduplicado2", + email: "Duplicado@curso.dev", + password: "senha123", + }), + }); + + expect(response2.status).toBe(400); + + const response2Body = await response2.json(); + + expect(response2Body).toEqual({ + name: "ValidationError", + message: "O email informado já está sendo utilizado.", + action: "Utilize outro email para realizar esta operação.", + status_code: 400, + }); + }); + + test("With duplicated 'username'", async () => { + const response1 = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "usernameduplicado", + email: "usernameduplicado1@curso.dev", + password: "senha123", + }), + }); + + expect(response1.status).toBe(201); + + const response2 = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "UsernameDuplicado", + email: "usernameduplicado2@curso.dev", + password: "senha123", + }), + }); + + expect(response2.status).toBe(400); + + const response2Body = await response2.json(); + + expect(response2Body).toEqual({ + name: "ValidationError", + message: "O username informado já está sendo utilizado.", + action: "Utilize outro username para realizar esta operação.", + status_code: 400, + }); + }); + }); +}); \ No newline at end of file