From d6f6e32e428dc57bf914763c529142d41b4a76df Mon Sep 17 00:00:00 2001 From: adetayo adewobi Date: Thu, 8 Aug 2024 16:40:12 +0100 Subject: [PATCH] feat: update blog --- src/controllers/BlogController.ts | 177 ++++++++++++++++++-- src/controllers/FaqController.ts | 6 +- src/controllers/updateBlogController.ts | 30 ---- src/models/category.ts | 2 +- src/models/tag.ts | 2 +- src/routes/blog.ts | 9 +- src/services/blog.services.ts | 53 +++++- src/services/updateBlog.services.ts | 40 ----- src/test/blog.spec.ts | 213 +++++++++++++++++++++++- src/utils/request-body-validator.ts | 1 + 10 files changed, 433 insertions(+), 100 deletions(-) delete mode 100644 src/controllers/updateBlogController.ts delete mode 100644 src/services/updateBlog.services.ts diff --git a/src/controllers/BlogController.ts b/src/controllers/BlogController.ts index 48d82ca8..70cf13b4 100644 --- a/src/controllers/BlogController.ts +++ b/src/controllers/BlogController.ts @@ -1,5 +1,6 @@ import { NextFunction, Request, Response } from "express"; import { BlogService } from "../services"; +import { ResourceNotFound, ServerError, Forbidden } from "../middleware"; export class BlogController { private blogService: BlogService; @@ -413,14 +414,171 @@ export class BlogController { }); } } + /** + * @swagger + * /blog/edit/{id}: + * patch: + * summary: Edit a blog post + * description: Update the details of a blog post including title, content, image URL, tags, categories, and publish date. Only the author can update their blog post. + * tags: [Blog] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The ID of the blog post to edit + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * title: + * type: string + * description: The title of the blog post + * content: + * type: string + * description: The content of the blog post + * image_url: + * type: string + * description: The URL of the blog post's image + * tags: + * type: string + * description: A comma-separated list of tags for the blog post + * categories: + * type: string + * description: A comma-separated list of categories for the blog post + * publish_date: + * type: string + * format: date-time + * description: The publish date of the blog post + * example: + * title: "Updated Blog Title" + * content: "This is the updated content of the blog post." + * image_url: "http://example.com/image.jpg" + * tags: "technology, AI" + * categories: "Tech News, Artificial Intelligence" + * publish_date: "2023-09-12T10:00:00Z" + * responses: + * 200: + * description: Blog post updated successfully. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: success + * status_code: + * type: integer + * example: 200 + * message: + * type: string + * example: Blog post updated successfully. + * post: + * type: object + * properties: + * blog_id: + * type: string + * example: "12345" + * title: + * type: string + * example: "Updated Blog Title" + * content: + * type: string + * example: "This is the updated content of the blog post." + * tags: + * type: array + * items: + * type: string + * categories: + * type: array + * items: + * type: string + * image_urls: + * type: string + * example: "http://example.com/image.jpg" + * author: + * type: string + * example: "Author Name" + * updated_at: + * type: string + * format: date-time + * example: "2023-09-12T10:00:00Z" + * 400: + * description: Bad Request - Invalid input data. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: unsuccessful + * status_code: + * type: integer + * example: 400 + * message: + * type: string + * example: Invalid request data. + * 403: + * description: Unauthorized - User is not allowed to update this blog post. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: unsuccessful + * status_code: + * type: integer + * example: 403 + * message: + * type: string + * example: Unauthorized access. + * 404: + * description: Blog post not found. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: unsuccessful + * status_code: + * type: integer + * example: 404 + * message: + * type: string + * example: Blog post not found. + * 500: + * description: An unexpected error occurred while processing the request. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * example: unsuccessful + * status_code: + * type: integer + * example: 500 + * message: + * type: string + * example: An unexpected error occurred. + */ async updateBlog(req: Request, res: Response, next: NextFunction) { try { const userId = req.user.id; const blogId = req.params.id; - const { title, content, image_url, tags, categories } = req.body; - if (!title || !content || image_url || tags || categories) { - } + const updatedBlog = await this.blogService.updateBlog( blogId, req.body, @@ -430,16 +588,13 @@ export class BlogController { status: "success", status_code: 200, message: "Blog post updated successfully.", - post: updatedBlog, + data: updatedBlog, }); } catch (error) { - // res.status(500).json({ - // status: "unsuccessful", - // status_code: 500, - // message: error.message, - // }); - console.log("err", error); - next(error); + if (error instanceof ResourceNotFound || error instanceof Forbidden) { + next(error); + } + next(new ServerError("Internal server error.")); } } } diff --git a/src/controllers/FaqController.ts b/src/controllers/FaqController.ts index b2cbbafd..12dba7d3 100644 --- a/src/controllers/FaqController.ts +++ b/src/controllers/FaqController.ts @@ -3,6 +3,7 @@ import { FAQService } from "../services"; import { UserRole } from "../enums/userRoles"; import isSuperAdmin from "../utils/isSuperAdmin"; import { Category } from "../models"; +import { ServerError, BadRequest } from "../middleware"; const faqService = new FAQService(); @@ -338,7 +339,10 @@ class FAQController { status_code: 200, }); } catch (error) { - next(error); + if (error instanceof BadRequest) { + next(error); + } + next(new ServerError("Internal server error.")); } } diff --git a/src/controllers/updateBlogController.ts b/src/controllers/updateBlogController.ts deleted file mode 100644 index a5b88741..00000000 --- a/src/controllers/updateBlogController.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Request, Response } from "express"; -import { updateBlogPost } from "../services/updateBlog.services"; - -export const updateBlogController = async (req: Request, res: Response) => { - const { id } = req.params; - const { title, content, published_at, image_url } = req.body; - - try { - const updatedBlog = await updateBlogPost( - id, - title, - content, - published_at, - image_url, - ); - - return res.status(200).json({ - status: "success", - status_code: 200, - message: "Blog post updated successfully", - data: updatedBlog, - }); - } catch (error) { - return res.status(500).json({ - status: "unsuccessful", - status_code: 500, - message: "Failed to update the blog post. Please try again later.", - }); - } -}; diff --git a/src/models/category.ts b/src/models/category.ts index da5bac78..b9600d74 100644 --- a/src/models/category.ts +++ b/src/models/category.ts @@ -3,7 +3,7 @@ import { Blog } from "./blog"; @Entity() export class Category { - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn("uuid") id: number; @Column() diff --git a/src/models/tag.ts b/src/models/tag.ts index a234bfd1..cb9842d9 100644 --- a/src/models/tag.ts +++ b/src/models/tag.ts @@ -3,7 +3,7 @@ import { Blog } from "./blog"; @Entity() export class Tag { - @PrimaryGeneratedColumn() + @PrimaryGeneratedColumn("uuid") id: number; @Column() diff --git a/src/routes/blog.ts b/src/routes/blog.ts index 8c1005fd..99e9d4a1 100644 --- a/src/routes/blog.ts +++ b/src/routes/blog.ts @@ -1,11 +1,10 @@ import { Router } from "express"; import { BlogCommentController } from "../controllers/blogCommentController"; import { BlogController } from "../controllers/BlogController"; -// import { createBlogController } from "../controllers/createBlogController" -import { updateBlogController } from "../controllers/updateBlogController"; -import { authMiddleware } from "../middleware"; +import { authMiddleware, checkPermissions } from "../middleware"; import { requestBodyValidator } from "../middleware/request-validation"; import { createBlogSchema } from "../utils/request-body-validator"; +import { UserRole } from "../enums/userRoles"; const blogRouter = Router(); const blogController = new BlogController(); @@ -26,10 +25,10 @@ blogRouter.get( ); blogRouter.patch( "/blog/edit/:id", + requestBodyValidator(createBlogSchema), authMiddleware, blogController.updateBlog.bind(blogController), ); -blogRouter.put("/:id", authMiddleware, updateBlogController); blogRouter.delete( "/blog/:id", @@ -37,14 +36,12 @@ blogRouter.delete( blogController.deleteBlogPost.bind(blogController), ); -//endpoint to create a comment on a blog post blogRouter.post( "/blog/:postId/comment", authMiddleware, blogCommentController.createComment.bind(blogCommentController), ); -//endpoint to edit a comment on a blog post blogRouter.patch( "/blog/:commentId/edit-comment", authMiddleware, diff --git a/src/services/blog.services.ts b/src/services/blog.services.ts index ee199882..7f56fbac 100644 --- a/src/services/blog.services.ts +++ b/src/services/blog.services.ts @@ -2,7 +2,7 @@ import { Repository } from "typeorm"; import AppDataSource from "../data-source"; import { Category, Tag, User } from "../models"; import { Blog } from "../models/blog"; -import { ResourceNotFound } from "../middleware"; +import { ResourceNotFound, Forbidden } from "../middleware"; export class BlogService { getAllComments(mockBlogId: string) { @@ -118,27 +118,70 @@ export class BlogService { throw error; } } - async updateBlog(blogId: string, payload: Blog, userId: string) { + async updateBlog(blogId: string, payload: any, userId: string) { const blog = await this.blogRepository.findOne({ where: { id: blogId }, - relations: ["author"], + relations: ["author", "tags", "categories"], }); if (!blog) { throw new ResourceNotFound("Blog post not found"); } + if (blog.author.id !== userId) { + throw new Forbidden("You are not authorized to edit this blog post"); + } const user = await this.userRepository.findOne({ where: { id: userId } }); - Object.assign(blog, payload, { author: user }); + + blog.title = payload.title; + blog.content = payload.content; + blog.image_url = payload.image_url; + blog.author = user; + blog.published_at = payload.publish_date; + if (payload.tags) { + const tagsContent = payload.tags.split(","); + const tagEntities = await Promise.all( + tagsContent.map(async (tagName: string) => { + let tag = await this.tagRepository.findOne({ + where: { name: tagName }, + }); + if (!tag) { + tag = this.tagRepository.create({ name: tagName }); + await this.tagRepository.save(tag); + } + return tag; + }), + ); + blog.tags = tagEntities; + } + + if (payload.categories) { + const categoriesContent = payload.categories.split(","); + const categoryEntities = await Promise.all( + categoriesContent.map(async (categoryName: string) => { + let category = await this.categoryRepository.findOne({ + where: { name: categoryName }, + }); + if (!category) { + category = this.categoryRepository.create({ name: categoryName }); + await this.categoryRepository.save(category); + } + return category; + }), + ); + blog.categories = categoryEntities; + } const updatedBlog = await this.blogRepository.save(blog); + return { blog_id: updatedBlog.id, title: updatedBlog.title, content: updatedBlog.content, tags: updatedBlog.tags, + categories: updatedBlog.categories, image_urls: updatedBlog.image_url, author: updatedBlog.author.name, - updatedBlog_at: updatedBlog.created_at, + updated_at: updatedBlog.updated_at, }; } } diff --git a/src/services/updateBlog.services.ts b/src/services/updateBlog.services.ts deleted file mode 100644 index a587e987..00000000 --- a/src/services/updateBlog.services.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Blog } from "../models/blog"; -import AppDataSource from "../data-source"; - -export const updateBlogPost = async ( - id: string, - title: string, - content: string, - published_at?: Date, - image_url?: string, -) => { - const blogRepository = AppDataSource.getRepository(Blog); - - let blog; - try { - blog = await blogRepository.findOne({ where: { id } }); - } catch (error) { - throw new Error("Error finding blog post."); - } - - if (!blog) { - throw new Error("Blog post not found."); - } - - blog.title = title; - blog.content = content; - - if (published_at) { - blog.published_at = published_at; - } - - if (image_url) { - blog.image_url = image_url; - } - - try { - await blogRepository.save(blog); - } catch (error) {} - - return blog; -}; diff --git a/src/test/blog.spec.ts b/src/test/blog.spec.ts index 6231b9b1..a06a6b7d 100644 --- a/src/test/blog.spec.ts +++ b/src/test/blog.spec.ts @@ -4,6 +4,7 @@ import AppDataSource from "../data-source"; import { Category, Tag, User } from "../models"; import { Blog } from "../models/blog"; import { BlogService } from "../services"; +import { Forbidden, ResourceNotFound } from "../middleware"; jest.mock("../data-source", () => ({ __esModule: true, @@ -27,8 +28,8 @@ describe("BlogService", () => { blogRepositoryMock = { delete: jest.fn(), save: jest.fn(), - // Add other methods if needed - } as any; // Casting to any to match the mocked repository + findOne: jest.fn(), + } as any; tagRepositoryMock = { findOne: jest.fn(), create: jest.fn(), @@ -49,7 +50,6 @@ describe("BlogService", () => { if (entity === Category) return categoryRepositoryMock; }); - // Initialize the BlogService after setting up the mock blogService = new BlogService(); }); @@ -62,7 +62,7 @@ describe("BlogService", () => { const id = "some-id"; const deleteResult: DeleteResult = { affected: 1, - raw: [], // Provide an empty array or appropriate mock value + raw: [], }; blogRepositoryMock.delete.mockResolvedValue(deleteResult); @@ -77,7 +77,7 @@ describe("BlogService", () => { const id = "non-existing-id"; const deleteResult: DeleteResult = { affected: 0, - raw: [], // Provide an empty array or appropriate mock value + raw: [], }; blogRepositoryMock.delete.mockResolvedValue(deleteResult); @@ -171,4 +171,207 @@ describe("BlogService", () => { expect(categoryRepositoryMock.save).toHaveBeenCalledTimes(2); }); }); + + describe("updateBlog", () => { + it("should update a blog post with new data, tags, and categories", async () => { + const blogId = "blog-123"; + const userId = "user-456"; + const payload = { + title: "Updated Blog Title", + content: "Updated Blog Content", + image_url: "updated-image.jpg", + tags: "tag1,tag2", + categories: "category1,category2", + }; + + const mockUser = { id: userId, name: "User Name" } as User; + const existingBlog = { + id: blogId, + title: "Old Title", + content: "Old Content", + image_url: "old-image.jpg", + author: { id: "user-456", name: "User Name" }, + tags: [], + categories: [], + comments: [], + likes: 0, + } as unknown as Blog; + + const tag1 = { id: "tag-1", name: "tag1" } as unknown as Tag; + const tag2 = { id: "tag-2", name: "tag2" } as unknown as Tag; + const category1 = { + id: "category-1", + name: "category1", + } as unknown as Category; + const category2 = { + id: "category-2", + name: "category2", + } as unknown as Category; + + blogRepositoryMock.findOne.mockResolvedValue(existingBlog); + userRepositoryMock.findOne.mockResolvedValue(mockUser); + tagRepositoryMock.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + categoryRepositoryMock.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + tagRepositoryMock.create + .mockReturnValueOnce(tag1) + .mockReturnValueOnce(tag2); + categoryRepositoryMock.create + .mockReturnValueOnce(category1) + .mockReturnValueOnce(category2); + tagRepositoryMock.save + .mockResolvedValueOnce(tag1) + .mockResolvedValueOnce(tag2); + categoryRepositoryMock.save + .mockResolvedValueOnce(category1) + .mockResolvedValueOnce(category2); + blogRepositoryMock.save.mockResolvedValue({ + ...existingBlog, + ...payload, + tags: [tag1, tag2], + categories: [category1, category2], + }); + + const result = await blogService.updateBlog(blogId, payload, userId); + + expect(blogRepositoryMock.findOne).toHaveBeenCalledWith({ + where: { id: blogId }, + relations: ["author", "tags", "categories"], + }); + expect(userRepositoryMock.findOne).toHaveBeenCalledWith({ + where: { id: userId }, + }); + expect(tagRepositoryMock.findOne).toHaveBeenCalledTimes(2); + expect(categoryRepositoryMock.findOne).toHaveBeenCalledTimes(2); + expect(blogRepositoryMock.save).toHaveBeenCalledWith( + expect.objectContaining({ + ...existingBlog, + ...payload, + tags: [tag1, tag2], + categories: [category1, category2], + author: mockUser, + }), + ); + + expect(result).toEqual({ + blog_id: blogId, + title: payload.title, + content: payload.content, + tags: [tag1, tag2], + categories: [category1, category2], + image_urls: payload.image_url, + author: mockUser.name, + }); + }); + + it("should throw Forbidden if the user is not authorized to update the blog post", async () => { + const blogId = "blog-123"; + const userId = "user-789"; + const payload = { + title: "Updated Blog Title", + content: "Updated Blog Content", + image_url: "updated-image.jpg", + tags: "tag1,tag2", + categories: "category1,category2", + }; + + const existingBlog = { + id: blogId, + title: "Old Title", + content: "Old Content", + image_url: "old-image.jpg", + author: { id: "user-456" }, + tags: [], + categories: [], + comments: [], + likes: 0, + } as unknown as Blog; + + blogRepositoryMock.findOne.mockResolvedValue(existingBlog); + + await expect( + blogService.updateBlog(blogId, payload, userId), + ).rejects.toThrow(Forbidden); + }); + + it("should throw ResourceNotFound if the blog post does not exist", async () => { + const blogId = "non-existent-blog"; + const userId = "user-456"; + const payload = {}; + + blogRepositoryMock.findOne.mockResolvedValue(null); + + await expect( + blogService.updateBlog(blogId, payload, userId), + ).rejects.toThrow(ResourceNotFound); + }); + + it("should create new tags and categories if they do not exist", async () => { + const blogId = "blog-123"; + const userId = "user-456"; + const payload = { + title: "Updated Blog Title", + content: "Updated Blog Content", + image_url: "updated-image.jpg", + tags: "newTag1,newTag2", + categories: "newCategory1,newCategory2", + }; + + const mockUser = { id: userId, name: "User Name" } as User; + const existingBlog = { + id: blogId, + tags: [], + categories: [], + author: { id: userId }, + } as unknown as Blog; + const newTag1 = { id: "new-tag-1", name: "newTag1" } as unknown as Tag; + const newTag2 = { id: "new-tag-2", name: "newTag2" } as unknown as Tag; + const newCategory1 = { + id: "new-category-1", + name: "newCategory1", + } as unknown as Category; + const newCategory2 = { + id: "new-category-2", + name: "newCategory2", + } as unknown as Category; + + blogRepositoryMock.findOne.mockResolvedValue(existingBlog); + userRepositoryMock.findOne.mockResolvedValue(mockUser); + tagRepositoryMock.findOne.mockResolvedValue(null); + tagRepositoryMock.create + .mockReturnValueOnce(newTag1) + .mockReturnValueOnce(newTag2); + categoryRepositoryMock.findOne.mockResolvedValue(null); + categoryRepositoryMock.create + .mockReturnValueOnce(newCategory1) + .mockReturnValueOnce(newCategory2); + tagRepositoryMock.save + .mockResolvedValueOnce(newTag1) + .mockResolvedValueOnce(newTag2); + categoryRepositoryMock.save + .mockResolvedValueOnce(newCategory1) + .mockResolvedValueOnce(newCategory2); + blogRepositoryMock.save.mockResolvedValue({ + ...existingBlog, + ...payload, + tags: [newTag1, newTag2], + categories: [newCategory1, newCategory2], + author: mockUser, + }); + + const result = await blogService.updateBlog(blogId, payload, userId); + + expect(tagRepositoryMock.save).toHaveBeenCalledWith(newTag1); + expect(tagRepositoryMock.save).toHaveBeenCalledWith(newTag2); + expect(categoryRepositoryMock.save).toHaveBeenCalledWith(newCategory1); + expect(categoryRepositoryMock.save).toHaveBeenCalledWith(newCategory2); + expect(result.tags).toEqual(expect.arrayContaining([newTag1, newTag2])); + expect(result.categories).toEqual( + expect.arrayContaining([newCategory1, newCategory2]), + ); + }); + }); }); diff --git a/src/utils/request-body-validator.ts b/src/utils/request-body-validator.ts index 36aee7e8..da038cf2 100644 --- a/src/utils/request-body-validator.ts +++ b/src/utils/request-body-validator.ts @@ -10,6 +10,7 @@ const createBlogSchema = z.object({ image_url: z.string(), tags: z.string().optional(), categories: z.string().optional(), + publish_date: z.string().optional(), }); export { createBlogSchema, emailSchema };