From b41369323fdfb10e49402d1ee8023b983eb1f37b Mon Sep 17 00:00:00 2001 From: unknown Date: Wed, 26 Aug 2026 09:10:46 +0300 Subject: [PATCH] fix(bookmark): include bookmarked articles in the saved list Article bookmarks were write-only. POST /articles/:id/bookmark persisted a row to article_bookmarks and the article detail endpoint reported isBookmarked correctly, but GET /posts/bookmarks never queried that table, so a saved article was unreachable from every read path. Articles live in their own table rather than under Post, so the existing post query could not see them. GetArticlesParams.savedByUserId and its Prisma filter (where.bookmarks) already existed; nothing called them. - GetBookmarksUseCase takes articleRepository and fetches bookmarked articles alongside posts and comments - the response carries data.articles and meta.articleTotal; articles are summaries because a body can be 100 KB of markdown - unit tests cover the new dependency and the savedByUserId hand-off No DI change was needed: awilix runs in CLASSIC mode and resolves by constructor parameter name, which already matches the registry key. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019YbEuSAiGYLZmwvhcrov4g --- .../get-bookmarks/get-bookmarks.usecase.ts | 27 +++++++-- src/http/controllers/bookmark.controller.ts | 9 ++- .../bookmark/get-bookmarks-query.schema.ts | 7 +++ .../bookmark/get-bookmarks.usecase.test.ts | 59 ++++++++++++++++++- 4 files changed, 95 insertions(+), 7 deletions(-) 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(); }); });