From 56d9893bf2cd442c8344562de26a7aacb7993b78 Mon Sep 17 00:00:00 2001 From: Alisson Pereira Date: Sat, 23 Aug 2025 14:10:00 -0300 Subject: [PATCH 1/3] chore: remove test migration file --- infra/migrations/1755523095078_test-migration.js | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 infra/migrations/1755523095078_test-migration.js diff --git a/infra/migrations/1755523095078_test-migration.js b/infra/migrations/1755523095078_test-migration.js deleted file mode 100644 index d345e57..0000000 --- a/infra/migrations/1755523095078_test-migration.js +++ /dev/null @@ -1,8 +0,0 @@ -/* eslint-disable no-unused-vars */ -/* eslint-disable camelcase */ - -exports.shorthands = undefined; - -exports.up = (pgm) => {}; - -exports.down = (pgm) => {}; From 9fbb36cbb2f009c24f3c94f435d9b8c7a8fa29e3 Mon Sep 17 00:00:00 2001 From: Alisson Pereira Date: Sat, 23 Aug 2025 17:45:32 -0300 Subject: [PATCH 2/3] build: install uuid --- package-lock.json | 15 ++++++++++++++- package.json | 3 ++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index f475c59..0a0a609 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "pg": "8.11.3", "react": "18.2.0", "react-dom": "18.2.0", - "swr": "2.2.5" + "swr": "2.2.5", + "uuid": "11.1.0" }, "devDependencies": { "@commitlint/cli": "19.3.0", @@ -9593,6 +9594,18 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true }, + "node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", diff --git a/package.json b/package.json index c09d2ff..5f8417b 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "pg": "8.11.3", "react": "18.2.0", "react-dom": "18.2.0", - "swr": "2.2.5" + "swr": "2.2.5", + "uuid": "11.1.0" }, "devDependencies": { "@commitlint/cli": "19.3.0", From 3c18c492388f16e8b669719abd363688efd5b021 Mon Sep 17 00:00:00 2001 From: Alisson Pereira Date: Sat, 23 Aug 2025 17:46:46 -0300 Subject: [PATCH 3/3] feat: create and use ValidationErro --- infra/controller.js | 11 +- infra/errors.js | 41 ++++++ infra/migrations/1755978902703_users.js | 44 +++++++ models/migrator.js | 2 +- models/user.js | 109 ++++++++++++++++ pages/api/v1/users/[username]/index.js | 15 +++ pages/api/v1/users/index.js | 15 +++ .../api/v1/users/[username]/get.test.js | 103 +++++++++++++++ tests/integration/api/v1/users/post.test.js | 121 ++++++++++++++++++ tests/orchestrator.js | 6 + 10 files changed, 465 insertions(+), 2 deletions(-) create mode 100644 infra/migrations/1755978902703_users.js create mode 100644 models/user.js create mode 100644 pages/api/v1/users/[username]/index.js create mode 100644 pages/api/v1/users/index.js create mode 100644 tests/integration/api/v1/users/[username]/get.test.js create mode 100644 tests/integration/api/v1/users/post.test.js diff --git a/infra/controller.js b/infra/controller.js index ade6e18..c8ec44c 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -1,4 +1,9 @@ -import { InternalServerError, MethodNotAllowedError } from "infra/errors"; +import { + InternalServerError, + MethodNotAllowedError, + ValidationError, + NotFoundError, +} from "infra/errors"; function onNoMatchHandler(request, response) { const publicErrorObject = new MethodNotAllowedError(); @@ -6,6 +11,10 @@ function onNoMatchHandler(request, response) { } function onErrorHandler(error, request, response) { + if (error instanceof ValidationError || error instanceof NotFoundError) { + return response.status(error.statusCode).json(error); + } + const publicErrorObject = new InternalServerError({ statusCode: error.statusCode, cause: error, diff --git a/infra/errors.js b/infra/errors.js index c76a42e..fda9a8b 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -38,6 +38,47 @@ export class ServiceError extends Error { } } +export class ValidationError extends Error { + constructor({ cause, message, action }) { + super(message || "Um erro de validação ocorreu.", { + cause, + }); + this.name = "ValidationError"; + this.action = action || "Ajuste os dados enviados e tente novamente."; + this.statusCode = 400; + } + + toJSON() { + return { + name: this.name, + message: this.message, + action: this.action, + status_code: this.statusCode, + }; + } +} + +export class NotFoundError extends Error { + constructor({ cause, message, action }) { + super(message || "Não foi possível encontrar este recurso no sistema.", { + cause, + }); + this.name = "NotFoundError"; + this.action = + action || "Verifique se os parâmetros enviados na consulta estão certos."; + this.statusCode = 404; + } + + toJSON() { + return { + name: this.name, + message: this.message, + action: this.action, + status_code: this.statusCode, + }; + } +} + export class MethodNotAllowedError extends Error { constructor() { super("Método não permitido para este endpoint."); diff --git a/infra/migrations/1755978902703_users.js b/infra/migrations/1755978902703_users.js new file mode 100644 index 0000000..f7f62e8 --- /dev/null +++ b/infra/migrations/1755978902703_users.js @@ -0,0 +1,44 @@ +exports.up = (pgm) => { + pgm.createTable("users", { + id: { + type: "uuid", + primaryKey: true, + default: pgm.func("gen_random_uuid()"), + }, + + // For reference, GitHub limits usernames to 39 characters. + username: { + type: "varchar(30)", + notNull: true, + unique: true, + }, + + // Why 254 in length? https://stackoverflow.com/a/1199238 + email: { + type: "varchar(254)", + notNull: true, + unique: true, + }, + + // Why 60 in length? https://www.npmjs.com/package/bcrypt#hash-info + password: { + type: "varchar(60)", + notNull: true, + }, + + // Why timestamp with timezone? https://justatheory.com/2012/04/postgres-use-timestamptz/ + created_at: { + type: "timestamptz", + notNull: true, + default: pgm.func("timezone('utc', now())"), + }, + + updated_at: { + type: "timestamptz", + notNull: true, + default: pgm.func("timezone('utc', now())"), + }, + }); +}; + +exports.down = false; diff --git a/models/migrator.js b/models/migrator.js index ad0ca82..4b25edb 100644 --- a/models/migrator.js +++ b/models/migrator.js @@ -6,7 +6,7 @@ const defaultMigrationOptions = { dryRun: true, dir: resolve("infra", "migrations"), direction: "up", - verbose: true, + log: () => {}, migrationsTable: "pgmigrations", }; diff --git a/models/user.js b/models/user.js new file mode 100644 index 0000000..6779be7 --- /dev/null +++ b/models/user.js @@ -0,0 +1,109 @@ +import database from "infra/database.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 validateUniqueEmail(userInputValues.email); + await validateUniqueUsername(userInputValues.username); + + 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á está sendo utilizado.", + action: "Utilize outro email para realizar o cadastro.", + }); + } + } + + 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 o cadastro.", + }); + } + } + + 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]; + } +} + +const user = { + create, + findOneByUsername, +}; + +export default user; diff --git a/pages/api/v1/users/[username]/index.js b/pages/api/v1/users/[username]/index.js new file mode 100644 index 0000000..3181d03 --- /dev/null +++ b/pages/api/v1/users/[username]/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.get(getHandler); + +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); +} diff --git a/pages/api/v1/users/index.js b/pages/api/v1/users/index.js new file mode 100644 index 0000000..ce44816 --- /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); +} 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..d2b5d65 --- /dev/null +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -0,0 +1,103 @@ +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 response1 = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "MesmoCase", + email: "mesmo.case@curso.dev", + password: "senha123", + }), + }); + + expect(response1.status).toBe(201); + + const response2 = await fetch( + "http://localhost:3000/api/v1/users/MesmoCase" + ); + + expect(response2.status).toBe(200); + + const response2Body = await response2.json(); + + expect(response2Body).toEqual({ + id: response2Body.id, + username: "MesmoCase", + email: "mesmo.case@curso.dev", + password: "senha123", + created_at: response2Body.created_at, + updated_at: response2Body.updated_at, + }); + + expect(uuidVersion(response2Body.id)).toBe(4); + expect(Date.parse(response2Body.created_at)).not.toBeNaN(); + expect(Date.parse(response2Body.updated_at)).not.toBeNaN(); + }); + + test("With case mismatch", async () => { + const response1 = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "CaseDiferente", + email: "case.diferente@curso.dev", + password: "senha123", + }), + }); + + expect(response1.status).toBe(201); + + const response2 = await fetch( + "http://localhost:3000/api/v1/users/casediferente" + ); + + expect(response2.status).toBe(200); + + const response2Body = await response2.json(); + + expect(response2Body).toEqual({ + id: response2Body.id, + username: "CaseDiferente", + email: "case.diferente@curso.dev", + password: "senha123", + created_at: response2Body.created_at, + updated_at: response2Body.updated_at, + }); + + expect(uuidVersion(response2Body.id)).toBe(4); + expect(Date.parse(response2Body.created_at)).not.toBeNaN(); + expect(Date.parse(response2Body.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, + }); + }); + }); +}); 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..ea52a8a --- /dev/null +++ b/tests/integration/api/v1/users/post.test.js @@ -0,0 +1,121 @@ +import { version as uuidVersion } from "uuid"; +import orchestrator from "tests/orchestrator.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: "senha123", + 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 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 o cadastro.", + 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 o cadastro.", + status_code: 400, + }); + }); + }); +}); diff --git a/tests/orchestrator.js b/tests/orchestrator.js index c48b9e7..d9806f7 100644 --- a/tests/orchestrator.js +++ b/tests/orchestrator.js @@ -1,5 +1,6 @@ import retry from "async-retry"; import database from "infra/database.js"; +import migrator from "models/migrator.js"; async function waitForAllServices() { await waitForWebServer(); @@ -24,9 +25,14 @@ async function clearDatabase() { await database.query("drop schema public cascade; create schema public;"); } +async function runPendingMigrations() { + await migrator.runPendingMigrations(); +} + const orchestrator = { waitForAllServices, clearDatabase, + runPendingMigrations, }; export default orchestrator;