diff --git a/infra/controller.js b/infra/controller.js index d1eaa03..c25c93b 100644 --- a/infra/controller.js +++ b/infra/controller.js @@ -3,6 +3,7 @@ import { MethodNotAllowedError, ForbiddenError, ValidationError, + NotFoundError, } from "infra/errors"; function onNoMatchHandler(request, response) { @@ -14,7 +15,8 @@ function onErrorHandler(error, request, response) { if ( error instanceof MethodNotAllowedError || error instanceof ForbiddenError || - error instanceof ValidationError + error instanceof ValidationError || + error instanceof NotFoundError ) { return response.status(error.statusCode).json(error); } diff --git a/infra/errors.js b/infra/errors.js index 71cb19b..0439c17 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -85,3 +85,22 @@ export class ValidationError extends Error { }; } } + +export class NotFoundError extends Error { + constructor({ cause, message, action }) { + super(message || "This feature could not be found in the system.", { + cause, + }); + this.name = "NotFoundError"; + this.action = action || "Please check the data and try again."; + this.statusCode = 404; + } + toJSON() { + return { + name: this.name, + message: this.message, + action: this.action, + status_code: this.statusCode, + }; + } +} diff --git a/infra/migrations/1755972262757_create-users.js b/infra/migrations/1755972262757_create-users.js index b90fa64..a5450e2 100644 --- a/infra/migrations/1755972262757_create-users.js +++ b/infra/migrations/1755972262757_create-users.js @@ -13,12 +13,20 @@ exports.up = (pgm) => { // For reference varchar 254 - https://stackoverflow.com/a/1199238 email: { type: "varchar(254)", notNull: true, unique: true }, - // bcrypt hash has 60 characters, but we use 72 to be future-proof - https://stackoverflow.com/a/39849 - password: { type: "varchar(72)", notNull: true }, + // bcrypt hash has 60 characters, but we use 72 to be future-proof - https://www.npmjs.com/package/bcrypt#hash-info + password: { type: "varchar(60)", notNull: true }, // Timestamp with time zone - https://justatheory.com/2012/04/postgres-use-timestamptz/ - created_at: { type: "timestamptz", default: pgm.func("now()") }, - updated_at: { type: "timestamptz", default: pgm.func("now()") }, + created_at: { + type: "timestamptz", + notNull: true, + default: pgm.func("timezone('utc', now())"), + }, + updated_at: { + type: "timestamptz", + notNull: true, + default: pgm.func("timezone('utc', now())"), + }, }); }; diff --git a/models/user.js b/models/user.js index 6367f3c..369b7c4 100644 --- a/models/user.js +++ b/models/user.js @@ -1,5 +1,36 @@ import database from "infra/database"; -import { ValidationError } from "infra/errors.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: "User not found", + action: "Please check the username and try again", + }); + } + + return results.rows[0]; + } +} async function create(userInputValues) { await validateUniqueEmail(userInputValues.email); @@ -73,6 +104,7 @@ async function create(userInputValues) { 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..342f72f --- /dev/null +++ b/pages/api/v1/users/[username]/index.js @@ -0,0 +1,17 @@ +import { createRouter } from "next-connect"; +import controller from "infra/controller"; +import user from "models/user.js"; + +const router = createRouter(); + +router.get(getMigrationsHandler); + +export default router.handler(controller.errorHandlers); + +async function getMigrationsHandler(request, response) { + // api/v1/users/[username] + const username = request.query.username; + const userFound = await user.findOneByUsername(username); + + return response.status(200).json(userFound); +} 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..07c6b1d --- /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: "CamelCaseUser", + email: "snake_case@curso.dev", + password: "senha123", + }), + }); + + expect(response1.status).toBe(201); + + const response2 = await fetch( + "http://localhost:3000/api/v1/users/CamelCaseUser", + ); + + expect(response2.status).toBe(200); + + const response2Body = await response2.json(); + + expect(response2Body).toEqual({ + id: response2Body.id, + username: "CamelCaseUser", + email: "snake_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 missmatch", async () => { + const response1 = await fetch("http://localhost:3000/api/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: "DifferentCaseUser", + email: "different_case@curso.dev", + password: "senha123", + }), + }); + + expect(response1.status).toBe(201); + + const response2 = await fetch( + "http://localhost:3000/api/v1/users/differentcaseuser", + ); + + expect(response2.status).toBe(200); + + const response2Body = await response2.json(); + + expect(response2Body).toEqual({ + id: response2Body.id, + username: "DifferentCaseUser", + email: "different_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 nonexistent username", async () => { + const response = await fetch( + "http://localhost:3000/api/v1/users/NonExistentUser", + ); + + expect(response.status).toBe(404); + + const responseBody = await response.json(); + + expect(responseBody).toEqual({ + name: "NotFoundError", + message: "User not found", + action: "Please check the username and try again", + status_code: 404, + }); + }); + }); +});