From 98ca5ea52bccfb477cdd40733bda4f6cf613b6c4 Mon Sep 17 00:00:00 2001 From: Lucas Martins Date: Wed, 26 Nov 2025 16:32:52 -0300 Subject: [PATCH 1/3] feat: create and use `NotFoundError` in `controller.js` --- infra/controller.js | 4 +++- infra/errors.js | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) 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..9e66998 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -85,3 +85,20 @@ 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, + }; + } +} \ No newline at end of file From 4adbad5146f6c105ad0d97bb905c5c4ba851ac42 Mon Sep 17 00:00:00 2001 From: Lucas Martins Date: Wed, 26 Nov 2025 16:34:44 -0300 Subject: [PATCH 2/3] feat: create `user.findOneByUsername` method and `/api/v1/users/[username]` endpoint --- models/user.js | 36 ++++++- pages/api/v1/users/[username]/index.js | 17 +++ .../api/v1/users/[username]/get.test.js | 100 ++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 pages/api/v1/users/[username]/index.js create mode 100644 tests/integration/api/v1/users/[username]/get.test.js diff --git a/models/user.js b/models/user.js index 6367f3c..8fd8541 100644 --- a/models/user.js +++ b/models/user.js @@ -1,5 +1,38 @@ 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 +106,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..cacfb6a --- /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..a250dd4 --- /dev/null +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -0,0 +1,100 @@ +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, + }); + + }); + }); +}); From 2ff704c3ad5e628f9e984815dcf2fd7039e87459 Mon Sep 17 00:00:00 2001 From: Lucas Martins Date: Sun, 30 Nov 2025 17:30:04 -0300 Subject: [PATCH 3/3] feat: create new endpoint `/api/v1/users/[username]` and tests --- infra/errors.js | 6 ++++-- infra/migrations/1755972262757_create-users.js | 16 ++++++++++++---- models/user.js | 2 -- pages/api/v1/users/[username]/index.js | 2 +- .../api/v1/users/[username]/get.test.js | 15 +++++++++------ 5 files changed, 26 insertions(+), 15 deletions(-) diff --git a/infra/errors.js b/infra/errors.js index 9e66998..0439c17 100644 --- a/infra/errors.js +++ b/infra/errors.js @@ -88,7 +88,9 @@ 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 }); + 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; @@ -101,4 +103,4 @@ export class NotFoundError extends Error { status_code: this.statusCode, }; } -} \ No newline at end of file +} 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 8fd8541..369b7c4 100644 --- a/models/user.js +++ b/models/user.js @@ -1,7 +1,6 @@ import database from "infra/database"; import { ValidationError, NotFoundError } from "infra/errors.js"; - async function findOneByUsername(username) { const userFound = await runSelectQuery(username); @@ -33,7 +32,6 @@ async function findOneByUsername(username) { } } - async function create(userInputValues) { await validateUniqueEmail(userInputValues.email); diff --git a/pages/api/v1/users/[username]/index.js b/pages/api/v1/users/[username]/index.js index cacfb6a..342f72f 100644 --- a/pages/api/v1/users/[username]/index.js +++ b/pages/api/v1/users/[username]/index.js @@ -9,7 +9,7 @@ router.get(getMigrationsHandler); export default router.handler(controller.errorHandlers); async function getMigrationsHandler(request, response) { - // api/v1/users/[username] + // api/v1/users/[username] const username = request.query.username; const userFound = await user.findOneByUsername(username); diff --git a/tests/integration/api/v1/users/[username]/get.test.js b/tests/integration/api/v1/users/[username]/get.test.js index a250dd4..07c6b1d 100644 --- a/tests/integration/api/v1/users/[username]/get.test.js +++ b/tests/integration/api/v1/users/[username]/get.test.js @@ -24,7 +24,9 @@ describe("GET /api/v1/users/[username]", () => { expect(response1.status).toBe(201); - const response2 = await fetch("http://localhost:3000/api/v1/users/CamelCaseUser"); + const response2 = await fetch( + "http://localhost:3000/api/v1/users/CamelCaseUser", + ); expect(response2.status).toBe(200); @@ -42,7 +44,6 @@ describe("GET /api/v1/users/[username]", () => { 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 () => { @@ -60,7 +61,9 @@ describe("GET /api/v1/users/[username]", () => { expect(response1.status).toBe(201); - const response2 = await fetch("http://localhost:3000/api/v1/users/differentcaseuser"); + const response2 = await fetch( + "http://localhost:3000/api/v1/users/differentcaseuser", + ); expect(response2.status).toBe(200); @@ -78,11 +81,12 @@ describe("GET /api/v1/users/[username]", () => { 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"); + const response = await fetch( + "http://localhost:3000/api/v1/users/NonExistentUser", + ); expect(response.status).toBe(404); @@ -94,7 +98,6 @@ describe("GET /api/v1/users/[username]", () => { action: "Please check the username and try again", status_code: 404, }); - }); }); });