diff --git a/jest.config.js b/jest.config.js index 1eb2e0f..15d2910 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,6 +4,7 @@ const config = { coverageDirectory: 'coverage', coveragePathIgnorePatterns: [ 'index.ts', + 'repo.ts', 'entities', 'interface', 'tools', diff --git a/package.json b/package.json index 4efff3e..d66ecf4 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "type": "module", "scripts": { "start:prod": "cross-env NODE_ENV=prod node dist/index.js", - "start:dev": "cross-env DEBUG=W7E* NODE_ENV=dev PORT=3400 node --watch --env-file=.env dist/index.js", - "start:mon": "cross-env DEBUG=W7E* NODE_ENV=dev PORT=3400 nodemon dist/index.js", + "start:dev": "cross-env DEBUG=BOOKS* NODE_ENV=dev PORT=3400 node --watch --env-file=.env dist/index.js", + "start:mon": "cross-env DEBUG=BOOKS* NODE_ENV=dev PORT=3400 nodemon dist/index.js", "build": "tsc", "dev": "tsc -w", "test": "jest", diff --git a/prisma/migrations/20240430083939_init/migration.sql b/prisma/migrations/20240430083939_init/migration.sql new file mode 100644 index 0000000..7911ce0 --- /dev/null +++ b/prisma/migrations/20240430083939_init/migration.sql @@ -0,0 +1,53 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('ADMIN', 'USER', 'GUEST'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "repeatPassword" TEXT NOT NULL, + "avatar" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "role" "Role" NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Book" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "author" TEXT NOT NULL, + "year" INTEGER NOT NULL, + "isbn" TEXT NOT NULL, + "coverUrl" TEXT NOT NULL, + "description" TEXT NOT NULL, + + CONSTRAINT "Book_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Article" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "subtitle" TEXT, + "imageUrl" TEXT, + "authorId" TEXT NOT NULL, + "content" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Article_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Book_isbn_key" ON "Book"("isbn"); + +-- AddForeignKey +ALTER TABLE "Article" ADD CONSTRAINT "Article_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20240430090046_update/migration.sql b/prisma/migrations/20240430090046_update/migration.sql new file mode 100644 index 0000000..a0aa65f --- /dev/null +++ b/prisma/migrations/20240430090046_update/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - The values [GUEST] on the enum `Role` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "Role_new" AS ENUM ('ADMIN', 'USER'); +ALTER TABLE "User" ALTER COLUMN "role" TYPE "Role_new" USING ("role"::text::"Role_new"); +ALTER TYPE "Role" RENAME TO "Role_old"; +ALTER TYPE "Role_new" RENAME TO "Role"; +DROP TYPE "Role_old"; +COMMIT; diff --git a/prisma/migrations/20240430094946_update/migration.sql b/prisma/migrations/20240430094946_update/migration.sql new file mode 100644 index 0000000..bdd5678 --- /dev/null +++ b/prisma/migrations/20240430094946_update/migration.sql @@ -0,0 +1,14 @@ +/* + Warnings: + + - The values [ADMIN,USER] on the enum `Role` will be removed. If these variants are still used in the database, this will fail. + +*/ +-- AlterEnum +BEGIN; +CREATE TYPE "Role_new" AS ENUM ('admin', 'user'); +ALTER TABLE "User" ALTER COLUMN "role" TYPE "Role_new" USING ("role"::text::"Role_new"); +ALTER TYPE "Role" RENAME TO "Role_old"; +ALTER TYPE "Role_new" RENAME TO "Role"; +DROP TYPE "Role_old"; +COMMIT; diff --git a/prisma/migrations/20240430113551_update/migration.sql b/prisma/migrations/20240430113551_update/migration.sql new file mode 100644 index 0000000..c835455 --- /dev/null +++ b/prisma/migrations/20240430113551_update/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ALTER COLUMN "avatar" DROP NOT NULL; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..7bfd03b --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,54 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions? +// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id String @id @default(cuid()) + name String + email String @unique + password String + repeatPassword String + avatar String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + role Role + articles Article[] +} + +enum Role { + admin + user +} + +model Book { + id String @id @default(cuid()) + title String + author String + year Int + isbn String @unique + coverUrl String + description String +} + +model Article { + id String @id @default(cuid()) + title String + subtitle String? + imageUrl String? + author User @relation(fields: [authorId], references: [id]) + authorId String + content String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/sonar-project.properties b/sonar-project.properties index 541cf37..52850d0 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -11,3 +11,7 @@ sonar.organization=isdi-coders-2023 # Encoding of the source code. Default is default system encoding #sonar.sourceEncoding=UTF-8 +sonar.sources=./src +sonar.test.inclusions=./src///.test., ./src///.spec.* +sonar.javascript.lcov.reportPaths=coverage/lcov.info +sonar.coverage.exclusions=**/src/**/*.spec.*, **/src/main.ts diff --git a/src/app.spec.ts b/src/app.spec.ts new file mode 100644 index 0000000..e689b74 --- /dev/null +++ b/src/app.spec.ts @@ -0,0 +1,8 @@ +import { createApp } from './app'; + +describe('Given the function createApp ', () => { + test('Then it should be call and return app', () => { + const app = createApp(); + expect(app).toBeDefined(); + }); +}); diff --git a/src/app.ts b/src/app.ts index 8b13789..0d04be5 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1 +1,7 @@ +import debug from 'debug'; +import express from 'express'; +export const createApp = () => { + debug('Creating app'); + return express(); +}; diff --git a/src/entities/article.schema.ts b/src/entities/article.schema.ts new file mode 100644 index 0000000..7e99446 --- /dev/null +++ b/src/entities/article.schema.ts @@ -0,0 +1,18 @@ +import Joi from 'joi'; +import { type ArticleCreateDto, type ArticleUpdateDto } from './article.js'; + +export const articleCreateDtoSchema = Joi.object({ + title: Joi.string().required(), + subtitle: Joi.string().allow('', null), + imageUrl: Joi.string().allow('', null), + authorId: Joi.string().required(), + content: Joi.string().required(), +}); + +export const articleUpdateDtoSchema = Joi.object({ + title: Joi.string(), + subtitle: Joi.string().allow('', null), + imageUrl: Joi.string().allow('', null), + authorId: Joi.string(), + content: Joi.string(), +}); diff --git a/src/entities/article.ts b/src/entities/article.ts new file mode 100644 index 0000000..a6f5a77 --- /dev/null +++ b/src/entities/article.ts @@ -0,0 +1,15 @@ +import { type User } from './user.js'; + +export type Article = { + id: string; + title: string; + subtitle: string | undefined; + imageUrl?: string | undefined; + author: User; + authorId: string; + content: string; + createdAt?: string; + updatedAt?: string; +}; +export type ArticleCreateDto = Omit; +export type ArticleUpdateDto = Partial; diff --git a/src/entities/book.schema.ts b/src/entities/book.schema.ts new file mode 100644 index 0000000..bd314eb --- /dev/null +++ b/src/entities/book.schema.ts @@ -0,0 +1,20 @@ +import Joi from 'joi'; +import { type BookCreateDto, type BookUpdateDto } from './book.js'; + +export const bookCreateDtoSchema = Joi.object({ + title: Joi.string().required(), + author: Joi.string().required(), + year: Joi.number().integer().min(0).required(), + isbn: Joi.string().required(), + coverUrl: Joi.string().uri().required(), + description: Joi.string().required(), +}); + +export const bookUpdateDtoSchema = Joi.object({ + title: Joi.string(), + author: Joi.string(), + year: Joi.number().integer().min(0), + isbn: Joi.string(), + coverUrl: Joi.string().uri(), + description: Joi.string(), +}); diff --git a/src/entities/book.ts b/src/entities/book.ts new file mode 100644 index 0000000..c55fcb2 --- /dev/null +++ b/src/entities/book.ts @@ -0,0 +1,11 @@ +export type Book = { + id: string; + title: string; + author: string; + year: number; + isbn: string; + coverUrl: string; + description: string; +}; +export type BookCreateDto = Omit; +export type BookUpdateDto = Partial; diff --git a/src/entities/user.schema.ts b/src/entities/user.schema.ts new file mode 100644 index 0000000..7c14335 --- /dev/null +++ b/src/entities/user.schema.ts @@ -0,0 +1,22 @@ +import Joi from 'joi'; +import { type UserCreateDto, type UserUpdateDto } from './user'; + +export const userCreateDtoSchema = Joi.object({ + name: Joi.string().required(), + email: Joi.string().email().required(), + password: Joi.string().required(), + repeatPassword: Joi.string().required(), + avatar: Joi.string().allow('', null), + + role: Joi.string().valid('admin', 'user').required(), +}); + +export const userUpdateDtoSchema = Joi.object({ + name: Joi.string(), + email: Joi.string().email(), + password: Joi.string(), + repeatPassword: Joi.string(), + avatar: Joi.string().allow('', null), + + role: Joi.string().valid('admin', 'user'), +}); diff --git a/src/entities/user.ts b/src/entities/user.ts new file mode 100644 index 0000000..82ff3bf --- /dev/null +++ b/src/entities/user.ts @@ -0,0 +1,30 @@ +import { type Article } from './article.js'; + +export type User = { + id: string; + name: string; + email: string; + password: string | undefined; + repeatPassword?: string | undefined; + avatar?: string | undefined; + createdAt: string; + updatedAt: string; + role: 'admin' | 'user'; + articles: Partial; +}; + +export type UserCreateDto = Omit< + User, + 'id' | 'createdAt' | 'updatedAt' | 'articles' +> & { + password: string; + repeatPassword: string; + avatar?: string | undefined; +}; + +export type UserUpdateDto = Partial; + +export type UserReadDto = Omit< + User, + 'password' | 'repeatPassword' | 'createdAt' | 'updatedAt' +>; diff --git a/src/middleware/errors.middleware.spec.ts b/src/middleware/errors.middleware.spec.ts new file mode 100644 index 0000000..769a6ad --- /dev/null +++ b/src/middleware/errors.middleware.spec.ts @@ -0,0 +1,45 @@ +import { type Request, type Response } from 'express'; +import { ErrorsMiddleware, HttpError } from './errors.middleware'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; + +const req = {} as unknown as Request; +const res = { + json: jest.fn(), + status: jest.fn(), +} as unknown as Response; +const next = jest.fn(); + +describe('Given a instance of the class ErrorsMiddleware', () => { + const middleware = new ErrorsMiddleware(); + test('Then it should be instance of the class', () => { + expect(middleware).toBeInstanceOf(ErrorsMiddleware); + }); + describe('When we use the method handle with a HttpError', () => { + test('Then it should call res.status 404', () => { + const error = new HttpError(404, 'Not Found', 'Article not found'); + middleware.handle(error, req, res, next); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalled(); + }); + }); + describe('When we use the method handle with a PrismaClientKnownRequestError', () => { + test('Then it should call res.status 404', () => { + const error = new PrismaClientKnownRequestError('error', { + code: 'P2025', + clientVersion: '3.0.0', + }); + middleware.handle(error, req, res, next); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalled(); + }); + }); + + describe('When we use the method handle with a Error', () => { + test('Then it should call res.status with 500', () => { + const error = new Error('Something went wrong'); + middleware.handle(error, req, res, next); + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/middleware/errors.middleware.ts b/src/middleware/errors.middleware.ts new file mode 100644 index 0000000..8b70185 --- /dev/null +++ b/src/middleware/errors.middleware.ts @@ -0,0 +1,48 @@ +import { type NextFunction, type Request, type Response } from 'express'; +import createDebug from 'debug'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/library'; +const debug = createDebug('BOOKS errors:middleware'); + +export class HttpError extends Error { + constructor( + public status: number, + public statusMessage: string, + message?: string, + options?: ErrorOptions + ) { + super(message, options); + } +} +export class ErrorsMiddleware { + constructor() { + debug('Instantiated errors middleware'); + } + + handle(error: Error, _req: Request, res: Response, _next: NextFunction) { + let status = 500; + let json = { + status: '500 Internal Server Error', + message: error.message, + }; + + if (error instanceof HttpError) { + debug('Error', error.message); + status = error.status; + json = { + status: `${error.status} ${error.statusMessage}`, + message: error.message, + }; + } else if (error instanceof PrismaClientKnownRequestError) { + debug('Prisma error', error.message); + status = 403; + json = { + status: '403 Forbidden', + message: error.message, + }; + } + + debug('Request received', error.message); + res.status(status); + res.json(json); + } +} diff --git a/src/repositories/repo.ts b/src/repositories/repo.ts new file mode 100644 index 0000000..d4cd52f --- /dev/null +++ b/src/repositories/repo.ts @@ -0,0 +1,12 @@ +export type Repo = { + readAll(): Promise; + readById(id: string): Promise; + create(data: C): Promise; + update(id: string, data: Partial): Promise; + delete(id: string): Promise; +}; + +export type WithLoginRepo = Repo & { + searchForLogin(key: 'email' | 'name', value: string): Promise>; + readById(id: string): Promise; +}; diff --git a/src/repositories/users.sql.repo.spec.ts b/src/repositories/users.sql.repo.spec.ts new file mode 100644 index 0000000..6f97c91 --- /dev/null +++ b/src/repositories/users.sql.repo.spec.ts @@ -0,0 +1,118 @@ +import { type PrismaClient } from '@prisma/client'; +import { UsersSqlRepo } from './users.sql.repo.js'; +import { HttpError } from '../middleware/errors.middleware.js'; +import { type UserCreateDto } from '../entities/user.js'; + +const mockPrisma = { + user: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue({ id: '1' }), + findFirst: jest.fn().mockResolvedValue({ id: '1' }), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + delete: jest.fn().mockResolvedValue({}), + }, +} as unknown as PrismaClient; + +describe('Given a instance of the class UsersSqlRepo', () => { + const repo = new UsersSqlRepo(mockPrisma); + + test('Then it should be instance of the class', () => { + expect(repo).toBeInstanceOf(UsersSqlRepo); + }); + + describe('When we use the method readAll', () => { + test('Then it should call prisma.findMany', async () => { + const result = await repo.readAll(); + expect(mockPrisma.user.findMany).toHaveBeenCalled(); + expect(result).toEqual([]); + }); + }); + + describe('When we use the method readById with a valid ID', () => { + test('Then it should call prisma.findUnique', async () => { + const result = await repo.readById('1'); + expect(mockPrisma.user.findUnique).toHaveBeenCalled(); + expect(result).toEqual({ id: '1' }); + }); + }); + + describe('When we use the method readById with an invalid ID', () => { + test('Then it should throw an error', async () => { + (mockPrisma.user.findUnique as jest.Mock).mockResolvedValueOnce(null); + await expect(repo.readById('2')).rejects.toThrow( + new HttpError(404, 'Not Found', 'User 2 not found') + ); + }); + }); + + describe('When we use the method searchForLogin with a valid key', () => { + test('Then it should call prisma.findFirst', async () => { + const result = await repo.searchForLogin('email', 'test@sample.com'); + expect(mockPrisma.user.findFirst).toHaveBeenCalled(); + expect(result).toEqual({ id: '1' }); + }); + }); + + describe('When we use the method searchForLogin with an invalid key', () => { + test('Then it should throw an error', async () => { + await expect( + repo.searchForLogin('invalid' as 'name', 'test') + ).rejects.toThrow( + new HttpError(400, 'Bad Request', 'Invalid query parameters') + ); + }); + }); + + describe('When we use the method searchForLogin with an invalid value', () => { + test('Then it should throw an error', async () => { + (mockPrisma.user.findFirst as jest.Mock).mockResolvedValueOnce(null); + await expect(repo.searchForLogin('email', 'test')).rejects.toThrow( + new HttpError(400, 'Bad Request', 'Invalid email or password') + ); + }); + }); + + describe('When we use the method create', () => { + test('Then it should call prisma.create', async () => { + const data = {} as unknown as UserCreateDto; + const result = await repo.create(data); + expect(mockPrisma.user.create).toHaveBeenCalled(); + expect(result).toEqual({}); + }); + }); + + describe('When we use the method update with a valid ID', () => { + test('Then it should call prisma.update', async () => { + const result = await repo.update('1', {}); + expect(mockPrisma.user.update).toHaveBeenCalled(); + expect(result).toEqual({}); + }); + }); + + describe('When we use the method update with an invalid ID', () => { + test('Then it should throw an error', async () => { + (mockPrisma.user.findUnique as jest.Mock).mockResolvedValueOnce(null); + await expect(repo.update('2', {})).rejects.toThrow( + new HttpError(404, 'Not Found', 'User 2 not found') + ); + }); + }); + + describe('When we use the method delete with a valid ID', () => { + test('Then it should call prisma.delete', async () => { + const result = await repo.delete('1'); + expect(mockPrisma.user.delete).toHaveBeenCalled(); + expect(result).toEqual({}); + }); + }); + + describe('When we use the method delete with an invalid ID', () => { + test('Then it should throw an error', async () => { + (mockPrisma.user.findUnique as jest.Mock).mockResolvedValueOnce(null); + await expect(repo.delete('2')).rejects.toThrow( + new HttpError(404, 'Not Found', 'User 2 not found') + ); + }); + }); +}); diff --git a/src/repositories/users.sql.repo.ts b/src/repositories/users.sql.repo.ts new file mode 100644 index 0000000..02aa352 --- /dev/null +++ b/src/repositories/users.sql.repo.ts @@ -0,0 +1,119 @@ +import { type PrismaClient } from '@prisma/client'; +import createDebug from 'debug'; +import { HttpError } from '../middleware/errors.middleware.js'; +import { type User, type UserCreateDto } from '../entities/user.js'; +import { type WithLoginRepo } from './repo.js'; + +const debug = createDebug('BOOKS:users:repository:sql'); + +const select = { + id: true, + name: true, + email: true, + avatar: true, + role: true, + articles: { + select: { + id: true, + title: true, + subtitle: true, + imageUrl: true, + content: true, + author: { + select: { + id: true, + name: true, + email: true, + avatar: true, + role: true, + }, + }, + }, + }, +}; + +export class UsersSqlRepo implements WithLoginRepo { + constructor(private readonly prisma: PrismaClient) { + debug('Instantiated users sql repository'); + } + + async readAll(): Promise { + return this.prisma.user.findMany({ select }) as Promise; + } + + async readById(id: string): Promise { + const user = await this.prisma.user.findUnique({ + where: { id }, + select, + }); + if (!user) { + throw new HttpError(404, 'Not Found', `User ${id} not found`); + } + + return user as User; + } + + async searchForLogin(key: 'email' | 'name', value: string) { + if (!['email', 'name'].includes(key)) { + throw new HttpError(404, 'Not Found', 'Invalid query parameters'); + } + + const userData = await this.prisma.user.findFirst({ + where: { [key]: value }, + select: { + id: true, + name: true, + email: true, + role: true, + password: true, + repeatPassword: true, + }, + }); + + if (!userData) { + throw new HttpError(404, 'Not Found', `Invalid ${key} or password`); + } + + return userData; + } + + async create(data: UserCreateDto): Promise { + try { + const newUser = await this.prisma.user.create({ data, select }); + return newUser as User; + } catch (error) { + throw new HttpError( + 500, + 'Internal Server Error', + 'Failed to create user' + ); + } + } + + async update(id: string, data: Partial): Promise { + const user = await this.prisma.user.findUnique({ where: { id } }); + if (!user) { + throw new HttpError(404, 'Not Found', `User ${id} not found`); + } + + const updatedUser = await this.prisma.user.update({ + where: { id }, + data, + select, + }); + return updatedUser as User; + } + + async delete(id: string): Promise { + const user = await this.prisma.user.findUnique({ where: { id } }); + if (!user) { + throw new HttpError(404, 'Not Found', `User ${id} not found`); + } + + const deletedUser = await this.prisma.user.delete({ + where: { id }, + select, + }); + return deletedUser as User; + } +}