diff --git a/src/core/use-cases/bookmark/get-bookmarks/get-bookmarks.usecase.ts b/src/core/use-cases/bookmark/get-bookmarks/get-bookmarks.usecase.ts index 20e0b47..ed90562 100644 --- a/src/core/use-cases/bookmark/get-bookmarks/get-bookmarks.usecase.ts +++ b/src/core/use-cases/bookmark/get-bookmarks/get-bookmarks.usecase.ts @@ -1,37 +1,48 @@ /** - * Use case for retrieving a user's bookmarked posts and comments + * Use case for retrieving a user's bookmarked posts, comments and articles */ import type { IPostRepository } from "@core/ports/repositories/post.repository"; import type { ICommentBookmarkRepository } from "@core/ports/repositories/comment-bookmark.repository"; +import type { IArticleRepository } from "@core/ports/repositories/article.repository"; import type { GetBookmarksUseCaseInput } from "./get-bookmarks-usecase.input"; import type { Post } from "@core/domain/entities/post.entity"; import type { Comment } from "@core/domain/entities/comment.entity"; +import type { Article } from "@core/domain/entities/article.entity"; export class GetBookmarksUseCase { /** * @param postRepository - Repository for accessing posts, used to retrieve bookmarked posts * @param commentBookmarkRepository - Repository for accessing comment bookmarks, used to retrieve bookmarked comments + * @param articleRepository - Repository for accessing articles, used to retrieve bookmarked articles */ constructor( private readonly postRepository: IPostRepository, private readonly commentBookmarkRepository: ICommentBookmarkRepository, + private readonly articleRepository: IArticleRepository, ) {} /** - * Executes the use case to retrieve a user's bookmarked posts and comments based on the provided input + * Executes the use case to retrieve a user's bookmarked posts, comments and + * articles based on the provided input. + * + * Articles live in their own table rather than under `Post`, so a bookmarked + * article is invisible to the post query and has to be fetched separately. + * * @param input - The input containing the user ID and optional pagination parameters - * @returns An object containing arrays of bookmarked posts and comments, along with their respective total counts for pagination purposes + * @returns An object containing arrays of bookmarked posts, comments and articles, along with their respective total counts for pagination purposes */ async execute(input: GetBookmarksUseCaseInput): Promise<{ posts: Post[]; postTotal: number; comments: Comment[]; commentTotal: number; + articles: Article[]; + articleTotal: number; }> { const page = input.page || 1; const limit = input.limit || 10; const offset = (page - 1) * limit; - const [postResult, commentResult] = await Promise.all([ + const [postResult, commentResult, articleResult] = await Promise.all([ this.postRepository.findAll({ page, limit, @@ -43,6 +54,12 @@ export class GetBookmarksUseCase { limit, offset, ), + this.articleRepository.findAll({ + page, + limit, + savedByUserId: input.userId, + currentUserId: input.userId, + }), ]); return { @@ -50,6 +67,8 @@ export class GetBookmarksUseCase { postTotal: postResult.total, comments: commentResult.comments, commentTotal: commentResult.total, + articles: articleResult.articles, + articleTotal: articleResult.total, }; } } diff --git a/src/http/controllers/bookmark.controller.ts b/src/http/controllers/bookmark.controller.ts index a308687..6fff3ac 100644 --- a/src/http/controllers/bookmark.controller.ts +++ b/src/http/controllers/bookmark.controller.ts @@ -13,6 +13,7 @@ import type { RemoveCommentBookmarkUseCase } from "@core/use-cases/bookmark/remo import type { CommentActionParams } from "@typings/schemas/comment/like-comment.schema"; import { PostPrismaMapper } from "@infrastructure/persistence/mappers/post-prisma.mapper"; import { CommentPrismaMapper } from "@infrastructure/persistence/mappers/comment-prisma.mapper"; +import { ArticlePrismaMapper } from "@infrastructure/persistence/mappers/article-prisma.mapper"; export class BookmarkController { constructor( @@ -104,12 +105,18 @@ export class BookmarkController { cdnUrl, userId, ); + const articles = ArticlePrismaMapper.toListResponse( + result.articles, + cdnUrl, + userId, + ); return reply.status(200).send({ - data: { posts, comments }, + data: { posts, comments, articles }, meta: { postTotal: result.postTotal, commentTotal: result.commentTotal, + articleTotal: result.articleTotal, page: page ?? 1, timestamp: new Date().toISOString(), }, diff --git a/src/http/types/schemas/bookmark/get-bookmarks-query.schema.ts b/src/http/types/schemas/bookmark/get-bookmarks-query.schema.ts index 2bde250..0db1318 100644 --- a/src/http/types/schemas/bookmark/get-bookmarks-query.schema.ts +++ b/src/http/types/schemas/bookmark/get-bookmarks-query.schema.ts @@ -5,6 +5,7 @@ import { Type } from "@sinclair/typebox"; import { Type as FBType, type Static } from "@fastify/type-provider-typebox"; import { PostItemSchema } from "../post/get-post.schema"; import { CommentItemSchema } from "../comment/get-comment.schema"; +import { ArticleSummarySchema } from "../article/article-item.schema"; /** * Schema for retrieving bookmarks @@ -24,14 +25,20 @@ export const getBookmarksQuerySchema = Type.Object({ */ export type GetBookmarksQuery = Static; +/** + * Articles are summaries rather than full items: a saved list renders cards, + * and an article body can be 100 KB of markdown. + */ export const GetBookmarksResponseSchema = FBType.Object({ data: FBType.Object({ posts: FBType.Array(PostItemSchema), comments: FBType.Array(CommentItemSchema), + articles: FBType.Array(ArticleSummarySchema), }), meta: FBType.Object({ postTotal: FBType.Number(), commentTotal: FBType.Number(), + articleTotal: FBType.Number(), page: FBType.Number(), timestamp: FBType.String({ format: "date-time" }), }), diff --git a/tests/unit/core/use-cases/bookmark/get-bookmarks.usecase.test.ts b/tests/unit/core/use-cases/bookmark/get-bookmarks.usecase.test.ts index 6b5c5db..d309d2b 100644 --- a/tests/unit/core/use-cases/bookmark/get-bookmarks.usecase.test.ts +++ b/tests/unit/core/use-cases/bookmark/get-bookmarks.usecase.test.ts @@ -2,8 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { GetBookmarksUseCase } from "@core/use-cases/bookmark/get-bookmarks/get-bookmarks.usecase"; import type { IPostRepository } from "@core/ports/repositories/post.repository"; import type { ICommentBookmarkRepository } from "@core/ports/repositories/comment-bookmark.repository"; +import type { IArticleRepository } from "@core/ports/repositories/article.repository"; import type { Post } from "@core/domain/entities/post.entity"; import type { Comment } from "@core/domain/entities/comment.entity"; +import type { Article } from "@core/domain/entities/article.entity"; describe("GetBookmarksUseCase", () => { let useCase: GetBookmarksUseCase; @@ -12,22 +14,26 @@ describe("GetBookmarksUseCase", () => { ICommentBookmarkRepository, "findBookmarkedByUserId" >; + let articleRepo: Pick; const userId = "user-1"; beforeEach(() => { postRepo = { findAll: vi.fn() }; commentBookmarkRepo = { findBookmarkedByUserId: vi.fn() }; + articleRepo = { findAll: vi.fn() }; useCase = new GetBookmarksUseCase( postRepo as IPostRepository, commentBookmarkRepo as ICommentBookmarkRepository, + articleRepo as IArticleRepository, ); }); - it("should return bookmarked posts and comments", async () => { + it("should return bookmarked posts, comments and articles", async () => { const posts = [{} as Post]; const comments = [{} as Comment]; + const articles = [{} as Article]; vi.mocked(postRepo.findAll).mockResolvedValue({ posts, total: 1 }); vi.mocked(commentBookmarkRepo.findBookmarkedByUserId).mockResolvedValue( @@ -36,6 +42,10 @@ describe("GetBookmarksUseCase", () => { total: 1, }, ); + vi.mocked(articleRepo.findAll).mockResolvedValue({ + articles, + total: 1, + }); const result = await useCase.execute({ userId }); @@ -43,6 +53,8 @@ describe("GetBookmarksUseCase", () => { expect(result.postTotal).toBe(1); expect(result.comments).toBe(comments); expect(result.commentTotal).toBe(1); + expect(result.articles).toBe(articles); + expect(result.articleTotal).toBe(1); }); it("should use default page=1 and limit=10 when not provided", async () => { @@ -53,6 +65,10 @@ describe("GetBookmarksUseCase", () => { total: 0, }, ); + vi.mocked(articleRepo.findAll).mockResolvedValue({ + articles: [], + total: 0, + }); await useCase.execute({ userId }); @@ -74,6 +90,10 @@ describe("GetBookmarksUseCase", () => { total: 0, }, ); + vi.mocked(articleRepo.findAll).mockResolvedValue({ + articles: [], + total: 0, + }); await useCase.execute({ userId, page: 3, limit: 5 }); @@ -92,6 +112,10 @@ describe("GetBookmarksUseCase", () => { total: 0, }, ); + vi.mocked(articleRepo.findAll).mockResolvedValue({ + articles: [], + total: 0, + }); await useCase.execute({ userId, page: 2, limit: 20 }); @@ -105,7 +129,32 @@ describe("GetBookmarksUseCase", () => { ); }); - it("should fetch posts and comments in parallel", async () => { + it("should pass savedByUserId and currentUserId to article repository", async () => { + vi.mocked(postRepo.findAll).mockResolvedValue({ posts: [], total: 0 }); + vi.mocked(commentBookmarkRepo.findBookmarkedByUserId).mockResolvedValue( + { + comments: [], + total: 0, + }, + ); + vi.mocked(articleRepo.findAll).mockResolvedValue({ + articles: [], + total: 0, + }); + + await useCase.execute({ userId, page: 2, limit: 20 }); + + expect(articleRepo.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + savedByUserId: userId, + currentUserId: userId, + page: 2, + limit: 20, + }), + ); + }); + + it("should fetch posts, comments and articles in parallel", async () => { const callOrder: string[] = []; vi.mocked(postRepo.findAll).mockImplementation(async () => { @@ -118,14 +167,20 @@ describe("GetBookmarksUseCase", () => { callOrder.push("comments"); return { comments: [], total: 0 }; }); + vi.mocked(articleRepo.findAll).mockImplementation(async () => { + callOrder.push("articles"); + return { articles: [], total: 0 }; + }); await useCase.execute({ userId }); expect(callOrder).toContain("posts"); expect(callOrder).toContain("comments"); + expect(callOrder).toContain("articles"); expect(postRepo.findAll).toHaveBeenCalledOnce(); expect( commentBookmarkRepo.findBookmarkedByUserId, ).toHaveBeenCalledOnce(); + expect(articleRepo.findAll).toHaveBeenCalledOnce(); }); });