Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion infra/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
MethodNotAllowedError,
ForbiddenError,
ValidationError,
NotFoundError,
} from "infra/errors";

function onNoMatchHandler(request, response) {
Expand All @@ -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);
}
Expand Down
19 changes: 19 additions & 0 deletions infra/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
}
16 changes: 12 additions & 4 deletions infra/migrations/1755972262757_create-users.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())"),
},
});
};

Expand Down
34 changes: 33 additions & 1 deletion models/user.js
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -73,6 +104,7 @@ async function create(userInputValues) {

const user = {
create,
findOneByUsername,
};

export default user;
17 changes: 17 additions & 0 deletions pages/api/v1/users/[username]/index.js
Original file line number Diff line number Diff line change
@@ -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);
}
103 changes: 103 additions & 0 deletions tests/integration/api/v1/users/[username]/get.test.js
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
});