From 7e9fd104fdcf50ba089511647195f4dc2127cbb3 Mon Sep 17 00:00:00 2001 From: K Odedele Date: Sun, 2 Mar 2025 10:23:54 -0600 Subject: [PATCH] refactor(blog-service): extract helper methods for reusability and consistency --- src/modules/blogs/blogs.service.ts | 238 ++++--------- src/modules/blogs/tests/blogs.service.spec.ts | 325 +++++++++++------- 2 files changed, 271 insertions(+), 292 deletions(-) diff --git a/src/modules/blogs/blogs.service.ts b/src/modules/blogs/blogs.service.ts index c5ee2f6d6..ba66d715e 100644 --- a/src/modules/blogs/blogs.service.ts +++ b/src/modules/blogs/blogs.service.ts @@ -12,10 +12,8 @@ import { User } from '@modules/user/entities/user.entity'; @Injectable() export class BlogService { constructor( - @InjectRepository(Blog) - private blogRepository: Repository, - @InjectRepository(User) - private userRepository: Repository + @InjectRepository(Blog) private blogRepository: Repository, + @InjectRepository(User) private userRepository: Repository ) {} private async fetchUserById(userId: string): Promise { @@ -27,130 +25,64 @@ export class BlogService { if (!user) { throw new CustomHttpException('User not found.', HttpStatus.NOT_FOUND); } - return user; } + private async findBlogById(id: string, relations: string[] = []): Promise { + const blog = await this.blogRepository.findOne({ where: { id }, relations }); + if (!blog) { + throw new CustomHttpException(SYS_MSG.BLOG_NOT_FOUND, HttpStatus.NOT_FOUND); + } + return blog; + } + async createBlog(createBlogDto: CreateBlogDto, user: User): Promise { const fullUser = await this.fetchUserById(user.id); - const blog = this.blogRepository.create({ - ...createBlogDto, - author: fullUser, - }); - + const blog = this.blogRepository.create({ ...createBlogDto, author: fullUser }); const savedBlog = await this.blogRepository.save(blog); - return { - blog_id: savedBlog.id, - title: savedBlog.title, - content: savedBlog.content, - tags: savedBlog.tags, - image_urls: savedBlog.image_urls, - author: `${fullUser.first_name} ${fullUser.last_name}`, - created_at: savedBlog.created_at, - }; + return this.formatBlogResponse(savedBlog); } - async getSingleBlog(blogId: string, user: User): Promise { - const singleBlog = await this.blogRepository.findOneBy({ id: blogId }); - const fullName = await this.fetchUserById(user.id); - - if (!singleBlog) { - throw new CustomHttpException(SYS_MSG.BLOG_NOT_FOUND, HttpStatus.NOT_FOUND); - } - - const { id, created_at, updated_at, ...rest } = singleBlog; - const author = `${fullName.first_name} ${fullName.last_name}`; + async getSingleBlog(blogId: string): Promise { + const blog = await this.findBlogById(blogId, ['author']); return { - status_code: 200, + status_code: HttpStatus.OK, message: SYS_MSG.BLOG_FETCHED_SUCCESSFUL, - data: { blog_id: id, ...rest, author, published_date: created_at }, + data: this.formatBlogResponse(blog), }; } async updateBlog(id: string, updateBlogDto: UpdateBlogDto, user: User): Promise { - const blog = await this.blogRepository.findOne({ - where: { id }, - relations: ['author'], - }); - - if (!blog) { - throw new CustomHttpException('Blog post not found.', HttpStatus.NOT_FOUND); - } - + const blog = await this.findBlogById(id, ['author']); const fullUser = await this.fetchUserById(user.id); Object.assign(blog, updateBlogDto, { author: fullUser }); - const updatedBlog = await this.blogRepository.save(blog); - return { - blog_id: updatedBlog.id, - title: updatedBlog.title, - content: updatedBlog.content, - tags: updatedBlog.tags, - image_urls: updatedBlog.image_urls, - author: `${updatedBlog.author.first_name} ${updatedBlog.author.last_name}`, - created_at: updatedBlog.created_at, - }; + return this.formatBlogResponse(updatedBlog); } + async deleteBlogPost(id: string): Promise { - const blog = await this.blogRepository.findOne({ where: { id } }); - if (!blog) { - throw new CustomHttpException('Blog post with this id does not exist.', HttpStatus.NOT_FOUND); - } else await this.blogRepository.remove(blog); + const blog = await this.findBlogById(id); + await this.blogRepository.remove(blog); } - async getAllBlogs( - page: number, - pageSize: number - ): Promise<{ - status_code: number; - message: string; - data: { currentPage: number; totalPages: number; totalResults: number; blogs: BlogResponseDto[]; meta: any }; - }> { + async getAllBlogs(page: number, pageSize: number) { const skip = (page - 1) * pageSize; + const [result, total] = await this.blogRepository.findAndCount({ skip, take: pageSize, relations: ['author'] }); - const [result, total] = await this.blogRepository.findAndCount({ - skip, - take: pageSize, - relations: ['author'], - }); - - const data = this.mapBlogResults(result); - const totalPages = Math.ceil(total / pageSize); - - return { - status_code: HttpStatus.OK, - message: SYS_MSG.BLOG_FETCHED_SUCCESSFUL, - data: { - currentPage: page, - totalPages, - totalResults: total, - blogs: data, - meta: { - hasNext: page < totalPages, - total, - nextPage: page < totalPages ? page + 1 : null, - prevPage: page > 1 ? page - 1 : null, - }, - }, - }; + return this.formatPaginatedResponse(result, total, page, pageSize, SYS_MSG.BLOG_FETCHED_SUCCESSFUL); } - async searchBlogs(query: any): Promise<{ - status_code: number; - message: string; - data: { current_page: number; total_pages: number; total_results: number; blogs: BlogResponseDto[]; meta: any }; - }> { + async searchBlogs(query: any) { const { page = 1, page_size = 10 } = query; const skip = (page - 1) * page_size; this.validateEmptyValues(query); - - const where: FindOptionsWhere = this.buildWhereClause(query); + const where = this.buildWhereClause(query); const [result, total] = await this.blogRepository.findAndCount({ where: Object.keys(where).length ? where : undefined, @@ -159,36 +91,56 @@ export class BlogService { relations: ['author'], }); - if (!result || result.length === 0) { - return { - status_code: HttpStatus.NOT_FOUND, - message: 'no_results_found_for_the_provided_search_criteria', - data: { - current_page: page, - total_pages: 0, - total_results: 0, - blogs: [], - meta: { - has_next: false, - total: 0, - next_page: null, - prev_page: null, - }, - }, - }; + return this.formatPaginatedResponse( + result, + total, + page, + page_size, + result.length ? SYS_MSG.BLOG_FETCHED_SUCCESSFUL : 'No results found.' + ); + } + + private buildWhereClause(query: any): FindOptionsWhere { + const where: FindOptionsWhere = {}; + if (query.author) where.author = { first_name: Like(`%${query.author}%`), last_name: Like(`%${query.author}%`) }; + if (query.title) where.title = Like(`%${query.title}%`); + if (query.content) where.content = Like(`%${query.content}%`); + if (query.tags) where.tags = Like(`%${query.tags}%`); + if (query.created_date) where.created_at = MoreThanOrEqual(new Date(query.created_date)); + return where; + } + + private validateEmptyValues(query: any): void { + for (const key in query) { + if (query[key] !== undefined && typeof query[key] === 'string' && !query[key].trim()) { + throw new CustomHttpException(`${key.replace(/_/g, ' ')} value is empty`, HttpStatus.BAD_REQUEST); + } } + } + + private formatBlogResponse(blog: Blog): BlogResponseDto { + return { + blog_id: blog.id, + title: blog.title, + content: blog.content, + tags: blog.tags, + image_urls: blog.image_urls, + author: blog.author ? `${blog.author.first_name} ${blog.author.last_name}` : 'Unknown', + created_at: blog.created_at, + }; + } - const data = this.mapBlogResults(result); - const totalPages = Math.ceil(total / page_size); + private formatPaginatedResponse(result: Blog[], total: number, page: number, pageSize: number, message: string) { + const totalPages = Math.ceil(total / pageSize); return { - status_code: HttpStatus.OK, - message: SYS_MSG.BLOG_FETCHED_SUCCESSFUL, + status_code: total > 0 ? HttpStatus.OK : HttpStatus.NOT_FOUND, + message, data: { current_page: page, total_pages: totalPages, total_results: total, - blogs: data, + blogs: result.map(blog => this.formatBlogResponse(blog)), meta: { has_next: page < totalPages, total, @@ -198,58 +150,4 @@ export class BlogService { }, }; } - - private buildWhereClause(query: any): FindOptionsWhere { - const where: FindOptionsWhere = {}; - - if (query.author !== undefined) { - where.author = { - first_name: Like(`%${query.author}%`), - last_name: Like(`%${query.author}%`), - }; - } - if (query.title !== undefined) { - where.title = Like(`%${query.title}%`); - } - if (query.content !== undefined) { - where.content = Like(`%${query.content}%`); - } - if (query.tags !== undefined) { - where.tags = Like(`%${query.tags}%`); - } - if (query.created_date !== undefined) { - where.created_at = MoreThanOrEqual(new Date(query.created_date)); - } - - return where; - } - - private validateEmptyValues(query: any): void { - for (const key in query) { - if (Object.prototype.hasOwnProperty.call(query, key) && query[key] !== undefined) { - const value = query[key]; - if (typeof value === 'string' && !value.trim()) { - throw new CustomHttpException(`${key.replace(/_/g, ' ')} value is empty`, HttpStatus.BAD_REQUEST); - } - } - } - } - - private mapBlogResults(result: Blog[]): BlogResponseDto[] { - return result.map(blog => { - if (!blog.author) { - throw new CustomHttpException('author_not_found', HttpStatus.INTERNAL_SERVER_ERROR); - } - const author_name = blog.author ? `${blog.author.first_name} ${blog.author.last_name}` : 'Unknown'; - return { - blog_id: blog.id, - title: blog.title, - content: blog.content, - tags: blog.tags, - image_urls: blog.image_urls, - author: author_name, - created_at: blog.created_at, - }; - }); - } } diff --git a/src/modules/blogs/tests/blogs.service.spec.ts b/src/modules/blogs/tests/blogs.service.spec.ts index c11bf770a..5bf4b1d9a 100644 --- a/src/modules/blogs/tests/blogs.service.spec.ts +++ b/src/modules/blogs/tests/blogs.service.spec.ts @@ -7,6 +7,10 @@ import { Repository, Like, MoreThanOrEqual } from 'typeorm'; import { Blog } from '../entities/blog.entity'; import { User } from '@modules/user/entities/user.entity'; import { BlogService } from '../blogs.service'; +import { CreateBlogDto } from '../dtos/create-blog.dto'; +import { UpdateBlogDto } from '../dtos/update-blog.dto'; +import { HttpStatus } from '@nestjs/common'; +import { CustomHttpException } from '@shared/helpers/custom-http-filter'; describe('BlogService', () => { let service: BlogService; @@ -21,7 +25,6 @@ describe('BlogService', () => { create: jest.fn(), save: jest.fn(), findAndCount: jest.fn(), - findOneBy: jest.fn(), findOne: jest.fn(), remove: jest.fn(), }); @@ -42,7 +45,7 @@ describe('BlogService', () => { describe('createBlog', () => { it('should successfully create a blog', async () => { - const createBlogDto = { + const createBlogDto: CreateBlogDto = { title: 'Test Blog', content: 'Test Content', tags: ['test'], @@ -54,19 +57,22 @@ describe('BlogService', () => { user.first_name = 'John'; user.last_name = 'Doe'; - const fullUser = { first_name: 'John', last_name: 'Doe' }; - const blog = new Blog(); blog.id = 'blog-id'; blog.title = 'Test Blog'; blog.content = 'Test Content'; blog.tags = ['test']; blog.image_urls = ['http://example.com/image.jpg']; - blog.author = fullUser as unknown as User; + blog.author = user; blog.created_at = new Date(); - blog.updated_at = new Date(); - const expectedResponse = { + jest.spyOn(userRepository, 'findOne').mockResolvedValue(user); + jest.spyOn(blogRepository, 'create').mockReturnValue(blog); + jest.spyOn(blogRepository, 'save').mockResolvedValue(blog); + + const result = await service.createBlog(createBlogDto, user); + + expect(result).toEqual({ blog_id: 'blog-id', title: 'Test Blog', content: 'Test Content', @@ -74,28 +80,20 @@ describe('BlogService', () => { image_urls: ['http://example.com/image.jpg'], author: 'John Doe', created_at: blog.created_at, - }; - - jest.spyOn(userRepository, 'findOne').mockResolvedValue(fullUser as unknown as User); - jest.spyOn(blogRepository, 'create').mockReturnValue(blog); - jest.spyOn(blogRepository, 'save').mockResolvedValue(blog); - - const result = await service.createBlog(createBlogDto, user); - - expect(result).toEqual(expectedResponse); + }); expect(userRepository.findOne).toHaveBeenCalledWith({ - where: { id: user.id }, + where: { id: 'user-id' }, select: ['first_name', 'last_name'], }); expect(blogRepository.create).toHaveBeenCalledWith({ ...createBlogDto, - author: fullUser, + author: user, }); expect(blogRepository.save).toHaveBeenCalledWith(blog); }); it('should throw an error if user not found', async () => { - const createBlogDto = { + const createBlogDto: CreateBlogDto = { title: 'Test Blog', content: 'Test Content', tags: ['test'], @@ -107,7 +105,174 @@ describe('BlogService', () => { jest.spyOn(userRepository, 'findOne').mockResolvedValue(null); - await expect(service.createBlog(createBlogDto, user)).rejects.toThrow('User not found'); + await expect(service.createBlog(createBlogDto, user)).rejects.toThrow('User not found.'); + }); + }); + + describe('getSingleBlog', () => { + it('should successfully retrieve a blog', async () => { + const user = new User(); + user.id = 'user-id'; + user.first_name = 'John'; + user.last_name = 'Doe'; + + const blog = new Blog(); + blog.id = 'blog-id'; + blog.title = 'Test Blog'; + blog.content = 'Test Content'; + blog.tags = ['test']; + blog.image_urls = ['http://example.com/image.jpg']; + blog.author = user; + blog.created_at = new Date(); + + jest.spyOn(blogRepository, 'findOne').mockResolvedValue(blog); + + const result = await service.getSingleBlog('blog-id'); + + expect(result).toEqual({ + status_code: HttpStatus.OK, + message: SYS_MSG.BLOG_FETCHED_SUCCESSFUL, + data: { + blog_id: 'blog-id', + title: 'Test Blog', + content: 'Test Content', + tags: ['test'], + image_urls: ['http://example.com/image.jpg'], + author: 'John Doe', + created_at: blog.created_at, + }, + }); + expect(blogRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'blog-id' }, + relations: ['author'], + }); + }); + + it('should throw an error if blog not found', async () => { + jest.spyOn(blogRepository, 'findOne').mockResolvedValue(null); + + await expect(service.getSingleBlog('non-existent-blog-id')).rejects.toThrow(SYS_MSG.BLOG_NOT_FOUND); + }); + }); + + describe('updateBlog', () => { + it('should successfully update a blog', async () => { + const updateBlogDto: UpdateBlogDto = { + title: 'Updated Blog Title', + }; + + const user = new User(); + user.id = 'user-id'; + user.first_name = 'John'; + user.last_name = 'Doe'; + + const blog = new Blog(); + blog.id = 'blog-id'; + blog.title = 'Test Blog'; + blog.content = 'Test Content'; + blog.tags = ['test']; + blog.image_urls = ['http://example.com/image.jpg']; + blog.author = user; + blog.created_at = new Date(); + + jest.spyOn(blogRepository, 'findOne').mockResolvedValue(blog); + jest.spyOn(userRepository, 'findOne').mockResolvedValue(user); + jest.spyOn(blogRepository, 'save').mockResolvedValue(blog); + + const result = await service.updateBlog('blog-id', updateBlogDto, user); + + expect(result).toEqual({ + blog_id: 'blog-id', + title: 'Updated Blog Title', + content: 'Test Content', + tags: ['test'], + image_urls: ['http://example.com/image.jpg'], + author: 'John Doe', + created_at: blog.created_at, + }); + expect(blogRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'blog-id' }, + relations: ['author'], + }); + expect(blogRepository.save).toHaveBeenCalledWith(blog); + }); + }); + + describe('deleteBlogPost', () => { + it('should successfully delete a blog post', async () => { + const blog = new Blog(); + blog.id = 'blog-id'; + + jest.spyOn(blogRepository, 'findOne').mockResolvedValue(blog); + jest.spyOn(blogRepository, 'remove').mockResolvedValue(undefined); + + await service.deleteBlogPost('blog-id'); + + expect(blogRepository.findOne).toHaveBeenCalledWith({ + where: { id: 'blog-id' }, + relations: [], + }); + expect(blogRepository.remove).toHaveBeenCalledWith(blog); + }); + + it('should throw a 404 error if blog not found', async () => { + jest.spyOn(blogRepository, 'findOne').mockResolvedValue(null); + + await expect(service.deleteBlogPost('non-existent-blog-id')).rejects.toThrow(SYS_MSG.BLOG_NOT_FOUND); + }); + }); + + describe('getAllBlogs', () => { + it('should return paginated blog results', async () => { + const user = new User(); + user.id = 'user-id'; + user.first_name = 'John'; + user.last_name = 'Doe'; + + const blog = new Blog(); + blog.id = 'blog-id'; + blog.title = 'Test Blog'; + blog.content = 'Test Content'; + blog.tags = ['test']; + blog.image_urls = ['http://example.com/image.jpg']; + blog.author = user; + blog.created_at = new Date(); + + jest.spyOn(blogRepository, 'findAndCount').mockResolvedValue([[blog], 1]); + + const result = await service.getAllBlogs(1, 10); + + expect(result).toEqual({ + status_code: HttpStatus.OK, + message: SYS_MSG.BLOG_FETCHED_SUCCESSFUL, + data: { + current_page: 1, + total_pages: 1, + total_results: 1, + blogs: [ + { + blog_id: 'blog-id', + title: 'Test Blog', + content: 'Test Content', + tags: ['test'], + image_urls: ['http://example.com/image.jpg'], + author: 'John Doe', + created_at: blog.created_at, + }, + ], + meta: { + has_next: false, + total: 1, + next_page: null, + prev_page: null, + }, + }, + }); + expect(blogRepository.findAndCount).toHaveBeenCalledWith({ + skip: 0, + take: 10, + relations: ['author'], + }); }); }); @@ -124,6 +289,7 @@ describe('BlogService', () => { }; const user = new User(); + user.id = 'user-id'; user.first_name = 'John'; user.last_name = 'Doe'; @@ -135,10 +301,13 @@ describe('BlogService', () => { blog.image_urls = ['http://example.com/image.jpg']; blog.author = user; blog.created_at = new Date('2023-01-01'); - blog.updated_at = new Date(); - const expectedResponse = { - status_code: 200, + jest.spyOn(blogRepository, 'findAndCount').mockResolvedValue([[blog], 1]); + + const result = await service.searchBlogs(query); + + expect(result).toEqual({ + status_code: HttpStatus.OK, message: SYS_MSG.BLOG_FETCHED_SUCCESSFUL, data: { current_page: 1, @@ -152,7 +321,7 @@ describe('BlogService', () => { tags: ['test'], image_urls: ['http://example.com/image.jpg'], author: 'John Doe', - created_at: new Date('2023-01-01'), + created_at: blog.created_at, }, ], meta: { @@ -162,19 +331,10 @@ describe('BlogService', () => { prev_page: null, }, }, - }; - - jest.spyOn(blogRepository, 'findAndCount').mockResolvedValue([[blog], 1]); - - const result = await service.searchBlogs(query); - - expect(result).toEqual(expectedResponse); + }); expect(blogRepository.findAndCount).toHaveBeenCalledWith({ where: { - author: { - first_name: Like('%John%'), - last_name: Like('%John%'), - }, + author: { first_name: Like('%John%'), last_name: Like('%John%') }, title: Like('%Test%'), content: Like('%Content%'), tags: Like('%test%'), @@ -193,9 +353,13 @@ describe('BlogService', () => { page_size: 10, }; - const expectedResponse = { - status_code: 404, - message: 'no_results_found_for_the_provided_search_criteria', + jest.spyOn(blogRepository, 'findAndCount').mockResolvedValue([[], 0]); + + const result = await service.searchBlogs(query); + + expect(result).toEqual({ + status_code: HttpStatus.NOT_FOUND, + message: 'No results found.', data: { current_page: 1, total_pages: 0, @@ -208,13 +372,7 @@ describe('BlogService', () => { prev_page: null, }, }, - }; - - jest.spyOn(blogRepository, 'findAndCount').mockResolvedValue([[], 0]); - - const result = await service.searchBlogs(query); - - expect(result).toEqual(expectedResponse); + }); }); it('should validate empty query values and throw an error', async () => { @@ -227,81 +385,4 @@ describe('BlogService', () => { await expect(service.searchBlogs(query)).rejects.toThrow('author value is empty'); }); }); - - describe('getSingleBlog', () => { - it('should successfully retrieve a blog', async () => { - const user = new User(); - user.id = 'user-id'; - user.first_name = 'John'; - user.last_name = 'Doe'; - - const blogId = 'blog-id'; - const blog = new Blog(); - blog.id = 'blog-id'; - blog.title = 'Test Blog'; - blog.content = 'Test Content'; - blog.tags = ['test']; - blog.image_urls = ['http://example.com/image.jpg']; - blog.created_at = new Date(); - blog.updated_at = new Date(); - - jest.spyOn(blogRepository, 'findOneBy').mockResolvedValue(blog); - jest.spyOn(userRepository, 'findOne').mockResolvedValue(user); - - const result = await service.getSingleBlog(blogId, user); - - expect(result).toEqual({ - status_code: 200, - message: SYS_MSG.BLOG_FETCHED_SUCCESSFUL, - data: { - blog_id: blog.id, - title: blog.title, - content: blog.content, - tags: blog.tags, - image_urls: blog.image_urls, - published_date: blog.created_at, - author: 'John Doe', - }, - }); - expect(blogRepository.findOneBy).toHaveBeenCalledWith({ id: blogId }); - expect(userRepository.findOne).toHaveBeenCalledWith({ - where: { id: user.id }, - select: ['first_name', 'last_name'], - }); - }); - - it('should throw an error if blog not found', async () => { - const blogId = 'non-existent-blog-id'; - const user = new User(); - user.id = 'user-id-is-here'; - user.first_name = 'John'; - user.last_name = 'Doe'; - - jest.spyOn(userRepository, 'findOne').mockResolvedValue(user); - jest.spyOn(blogRepository, 'findOneBy').mockResolvedValue(null); - - await expect(service.getSingleBlog(blogId, user)).rejects.toThrow(SYS_MSG.BLOG_NOT_FOUND); - }); - }); - - describe('deleteBlogPost', () => { - it('should successfully delete a blog post', async () => { - const blog = new Blog(); - blog.id = 'blog-id'; - - jest.spyOn(blogRepository, 'findOne').mockResolvedValue(blog); - jest.spyOn(blogRepository, 'remove').mockResolvedValue(undefined); - - await service.deleteBlogPost('blog-id'); - - expect(blogRepository.findOne).toHaveBeenCalledWith({ where: { id: 'blog-id' } }); - expect(blogRepository.remove).toHaveBeenCalledWith(blog); - }); - - it('should throw a 404 error if blog not found', async () => { - jest.spyOn(blogRepository, 'findOne').mockResolvedValue(null); - - await expect(service.deleteBlogPost('blog-id')).rejects.toThrow('Blog post with this id does not exist'); - }); - }); });