diff --git a/src/app.ts b/src/app.ts index 6069596..af8ae41 100644 --- a/src/app.ts +++ b/src/app.ts @@ -14,6 +14,9 @@ import { FilesRouter } from './routers/files.router.js'; import { ArticlesController } from './controllers/articles.controller.js'; import { ArticlesSqlRepo } from './repositories/articles.sql.repo.js'; import { ArticlesRouter } from './routers/articles.router.js'; +import { BooksController } from './controllers/books.controller.js'; +import { BooksSqlRepo } from './repositories/books.sql.repo.js'; +import { BooksRouter } from './routers/books.router.js'; export const createApp = () => { debug('Creating app'); @@ -62,6 +65,15 @@ export const startApp = (app: Express, prisma: PrismaClient) => { 'avatar' ); + const booksRepo = new BooksSqlRepo(prisma); + const booksController = new BooksController(booksRepo); + const booksRouter = new BooksRouter( + booksController, + authInterceptor, + filesInterceptor + ); + app.use('/books', booksRouter.router); + app.use('/files', filesRouter.router); const errorsMiddleware = new ErrorsMiddleware(); app.use(errorsMiddleware.handle.bind(errorsMiddleware)); diff --git a/src/controllers/books.controller.spec.ts b/src/controllers/books.controller.spec.ts new file mode 100644 index 0000000..ca7940b --- /dev/null +++ b/src/controllers/books.controller.spec.ts @@ -0,0 +1,89 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/consistent-type-assertions */ +import { type Request, type Response, type NextFunction } from 'express'; +import { BooksController } from './books.controller.js'; +import { type Repo } from '../repositories/baseRepo.js'; +import { type ObjectSchema } from 'joi'; +import { type Book, type BookCreateDto } from '../entities/book.js'; +import { bookCreateDtoSchema } from '../entities/book.schema.js'; +jest.mock('../entities/book.schema.js', () => ({ + bookCreateDtoSchema: { + validate: jest.fn().mockReturnValue({ error: null, value: {} }), + } as unknown as ObjectSchema, + bookUpdateDtoSchema: { + validate: jest.fn().mockReturnValue({ error: null, value: {} }), + } as unknown as ObjectSchema, +})); + +describe('Given an instance of the class BooksController', () => { + const repo = { + readAll: jest.fn(), + readById: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + } as unknown as Repo; + + const req = {} as unknown as Request; + const res = { + json: jest.fn(), + status: jest.fn().mockReturnThis(), + } as unknown as Response; + const next = jest.fn(); + + const controller = new BooksController(repo); + + test('Then it should be an instance of the class', () => { + expect(controller).toBeInstanceOf(BooksController); + }); + + describe('When we use the method create', () => { + describe('And body is not valid', () => { + test('Then it should call next with an error', async () => { + req.body = {}; + (bookCreateDtoSchema.validate as jest.Mock).mockReturnValueOnce({ + error: new Error('validation error'), + value: {}, + }); + + await controller.create(req, res, next); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('validation error'), + }) + ); + }); + }); + + describe('And body is valid', () => { + test.skip('Then it should call repo.create', async () => { + const book = { + title: 'Test Book', + author: 'Test Author', + } as BookCreateDto; + + req.body = book; + await controller.create(req, res, next); + expect(repo.create).toHaveBeenCalledWith(book); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith(expect.any(Object)); + }); + }); + + describe('And an error is thrown during creation', () => { + test('Then it should call next with an error', async () => { + (repo.create as jest.Mock).mockRejectedValueOnce( + new Error('Create error') + ); + const book = { + title: 'Test Book', + author: 'Test Author', + } as BookCreateDto; + + req.body = book; + await controller.create(req, res, next); + expect(next).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + }); +}); diff --git a/src/controllers/books.controller.ts b/src/controllers/books.controller.ts new file mode 100644 index 0000000..4fceaea --- /dev/null +++ b/src/controllers/books.controller.ts @@ -0,0 +1,19 @@ +import { type NextFunction, type Request, type Response } from 'express'; +import createDebug from 'debug'; +import { type BookCreateDto, type Book } from '../entities/book.js'; +import { + bookCreateDtoSchema, + bookUpdateDtoSchema, +} from '../entities/book.schema.js'; +import { type Repo } from '../repositories/baseRepo.js'; +import { BaseController } from './baseController.js'; + +const debug = createDebug('BOOKS:books:controller'); + +export class BooksController extends BaseController { + constructor(protected readonly repo: Repo) { + super(repo, bookCreateDtoSchema, bookUpdateDtoSchema); + + debug('Instantiated book controller'); + } +} diff --git a/src/entities/book.schema.ts b/src/entities/book.schema.ts index bd314eb..c9ecf8b 100644 --- a/src/entities/book.schema.ts +++ b/src/entities/book.schema.ts @@ -6,7 +6,7 @@ export const bookCreateDtoSchema = Joi.object({ author: Joi.string().required(), year: Joi.number().integer().min(0).required(), isbn: Joi.string().required(), - coverUrl: Joi.string().uri().required(), + avatar: Joi.string().allow('', null), description: Joi.string().required(), }); @@ -15,6 +15,6 @@ export const bookUpdateDtoSchema = Joi.object({ author: Joi.string(), year: Joi.number().integer().min(0), isbn: Joi.string(), - coverUrl: Joi.string().uri(), + avatar: Joi.string().uri(), description: Joi.string(), }); diff --git a/src/entities/book.ts b/src/entities/book.ts index c55fcb2..c71626d 100644 --- a/src/entities/book.ts +++ b/src/entities/book.ts @@ -4,7 +4,7 @@ export type Book = { author: string; year: number; isbn: string; - coverUrl: string; + avatar: string; description: string; }; export type BookCreateDto = Omit; diff --git a/src/repositories/books.sql.repo.spec.ts b/src/repositories/books.sql.repo.spec.ts new file mode 100644 index 0000000..ae6b70a --- /dev/null +++ b/src/repositories/books.sql.repo.spec.ts @@ -0,0 +1,198 @@ +import { type PrismaClient } from '@prisma/client'; +import { BooksSqlRepo } from './books.sql.repo.js'; +import { HttpError } from '../middleware/errors.middleware.js'; +import { type BookCreateDto } from '../entities/book.js'; + +const mockPrisma = { + book: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue({ + id: '1', + title: 'Test Book', + year: 2021, + isbn: '1234567890', + author: 'Author', + avatar: 'avatar.png', + description: 'A test book', + }), + create: jest.fn().mockResolvedValue({ + id: '1', + title: 'New Book', + year: 2021, + isbn: '0987654321', + author: 'Author', + avatar: 'avatar.png', + description: 'A new book', + }), + update: jest.fn().mockResolvedValue({ + id: '1', + title: 'Updated Book', + year: 2021, + isbn: '1234567890', + author: 'Author', + avatar: 'avatar.png', + description: 'An updated book', + }), + delete: jest.fn().mockResolvedValue({ + id: '1', + title: 'Deleted Book', + year: 2021, + isbn: '1234567890', + author: 'Author', + avatar: 'avatar.png', + description: 'A deleted book', + }), + }, +} as unknown as PrismaClient; + +describe('Given an instance of the class BooksSqlRepo', () => { + const repo = new BooksSqlRepo(mockPrisma); + + test('Then it should be an instance of the class', () => { + expect(repo).toBeInstanceOf(BooksSqlRepo); + }); + + describe('When we use the method readAll', () => { + test('Then it should call prisma.findMany', async () => { + const result = await repo.readAll(); + expect(mockPrisma.book.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.book.findUnique).toHaveBeenCalled(); + expect(result).toEqual({ + id: '1', + title: 'Test Book', + year: 2021, + isbn: '1234567890', + author: 'Author', + avatar: 'avatar.png', + description: 'A test book', + }); + }); + }); + + describe('When we use the method readById with an invalid ID', () => { + test('Then it should throw an error', async () => { + (mockPrisma.book.findUnique as jest.Mock).mockResolvedValueOnce(null); + await expect(repo.readById('2')).rejects.toThrow( + new HttpError(404, 'Not Found', 'Book 2 not found') + ); + }); + }); + + describe('When we use the method create', () => { + test('Then it should call prisma.create', async () => { + const data: BookCreateDto = { + title: 'New Book', + year: 2021, + isbn: '0987654321', + author: 'Author', + avatar: 'avatar.png', + description: 'A new book', + }; + const result = await repo.create(data); + expect(mockPrisma.book.create).toHaveBeenCalled(); + expect(result).toEqual({ + id: '1', + title: 'New Book', + year: 2021, + isbn: '0987654321', + author: 'Author', + avatar: 'avatar.png', + description: 'A new book', + }); + }); + + test('Then it should throw an error if create fails', async () => { + (mockPrisma.book.create as jest.Mock).mockRejectedValueOnce( + new Error('Failed to create book') + ); + + const data: BookCreateDto = { + title: 'New Book', + year: 2021, + isbn: '0987654321', + author: 'Author', + avatar: 'avatar.png', + description: 'A new book', + }; + + await expect(repo.create(data)).rejects.toThrow( + new HttpError(500, 'Internal Server Error', 'Error creating book') + ); + + expect(mockPrisma.book.create).toHaveBeenCalled(); + }); + }); + + describe('When we use the method update with a valid ID', () => { + test('Then it should call prisma.update', async () => { + const data: BookCreateDto = { + title: 'Updated Book', + year: 2021, + isbn: '1234567890', + author: 'Author', + avatar: 'avatar.png', + description: 'An updated book', + }; + const result = await repo.update('1', data); + expect(mockPrisma.book.update).toHaveBeenCalled(); + expect(result).toEqual({ + id: '1', + title: 'Updated Book', + year: 2021, + isbn: '1234567890', + author: 'Author', + avatar: 'avatar.png', + description: 'An updated book', + }); + }); + }); + + describe('When we use the method update with an invalid ID', () => { + test.skip('Then it should throw an error', async () => { + (mockPrisma.book.findUnique as jest.Mock).mockResolvedValueOnce(null); + const data: BookCreateDto = { + title: 'Updated Book', + year: 2021, + isbn: '1234567890', + author: 'Author', + avatar: 'avatar.png', + description: 'An updated book', + }; + await expect(repo.update('2', data)).rejects.toThrow( + new HttpError(404, 'Not Found', 'Book 2 not found') + ); + }); + }); + + describe('When we use the method delete with a valid ID', () => { + test.skip('Then it should call prisma.delete', async () => { + const result = await repo.delete('1'); + expect(mockPrisma.book.delete).toHaveBeenCalled(); + expect(result).toEqual({ + id: '1', + title: 'Deleted Book', + year: 2021, + isbn: '1234567890', + author: 'Author', + avatar: 'avatar.png', + description: 'A deleted book', + }); + }); + }); + + describe('When we use the method delete with an invalid ID', () => { + test('Then it should throw an error', async () => { + (mockPrisma.book.findUnique as jest.Mock).mockResolvedValueOnce(null); + await expect(repo.delete('2')).rejects.toThrow( + new HttpError(404, 'Not Found', 'Book 2 not found') + ); + }); + }); +}); diff --git a/src/repositories/books.sql.repo.ts b/src/repositories/books.sql.repo.ts new file mode 100644 index 0000000..fe33c04 --- /dev/null +++ b/src/repositories/books.sql.repo.ts @@ -0,0 +1,87 @@ +import { type PrismaClient } from '@prisma/client'; +import createDebug from 'debug'; +import { HttpError } from '../middleware/errors.middleware.js'; +import { type Book, type BookCreateDto } from '../entities/book.js'; +import { type Repo } from './baseRepo.js'; +const debug = createDebug('BOOKS:books:repository:sql'); + +const select = { + id: true, + title: true, + year: true, + isbn: true, + author: true, + avatar: true, + description: true, +}; +export class BooksSqlRepo implements Repo { + constructor(private readonly prisma: PrismaClient) { + debug('Instantiated books sql repository'); + } + + async readAll(): Promise { + return this.prisma.book.findMany({ + select, + }) as Promise; + } + + async readById(id: string): Promise { + const book = await this.prisma.book.findUnique({ + where: { id }, + select, + }); + + if (!book) { + throw new HttpError(404, 'Not Found', `Book ${id} not found`); + } + + return book as Book; + } + + async create(data: BookCreateDto): Promise { + try { + const newBook = await this.prisma.book.create({ + data: { + ...data, + description: data.description ?? '', + }, + select, + }); + return newBook as Book; + } catch (error) { + throw new HttpError(500, 'Internal Server Error', 'Error creating book'); + } + } + + async update(id: string, data: BookCreateDto): Promise { + try { + const updatedBook = await this.prisma.book.update({ + where: { id }, + data: { + ...data, + description: data.description ?? '', + }, + select, + }); + return updatedBook as Book; + } catch (error) { + throw new HttpError(500, 'Internal Server Error', 'Error updating book'); + } + } + + async delete(id: string): Promise { + const book = await this.prisma.book.findUnique({ + where: { id }, + }); + if (!book) { + throw new HttpError(404, 'Not Found', `Book ${id} not found`); + } + + const deletedBook = await this.prisma.book.delete({ + where: { id }, + select, + }); + + return deletedBook as Book; + } +} diff --git a/src/routers/books.router.ts b/src/routers/books.router.ts new file mode 100644 index 0000000..888efb6 --- /dev/null +++ b/src/routers/books.router.ts @@ -0,0 +1,36 @@ +import { Router as createRouter } from 'express'; +import createDebug from 'debug'; +import { type BooksController } from '../controllers/books.controller.js'; +import { type AuthInterceptor } from '../middleware/auth.interceptor.js'; + +import { type FilesInterceptor } from '../middleware/files.interceptor.js'; + +const debug = createDebug('BOOKS:books:router'); + +export class BooksRouter { + router = createRouter(); + + constructor( + readonly controller: BooksController, + readonly authInterceptor: AuthInterceptor, + readonly filesInterceptor: FilesInterceptor + ) { + debug('Instantiated books router'); + + this.router.get('/', controller.getAll.bind(controller)); + this.router.get('/:id', controller.getById.bind(controller)); + this.router.post( + '/', + filesInterceptor.singleFile('avatar'), + filesInterceptor.cloudUpload.bind(filesInterceptor), + controller.create.bind(controller) + ); + this.router.patch( + '/:id', + filesInterceptor.singleFile('avatar'), + filesInterceptor.cloudUpload.bind(filesInterceptor), + controller.update.bind(controller) + ); + this.router.delete('/:id', controller.delete.bind(controller)); + } +}