Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions src/core/use-cases/bookmark/get-bookmarks/get-bookmarks.usecase.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -43,13 +54,21 @@ export class GetBookmarksUseCase {
limit,
offset,
),
this.articleRepository.findAll({
page,
limit,
savedByUserId: input.userId,
currentUserId: input.userId,
}),
]);

return {
posts: postResult.posts,
postTotal: postResult.total,
comments: commentResult.comments,
commentTotal: commentResult.total,
articles: articleResult.articles,
articleTotal: articleResult.total,
};
}
}
9 changes: 8 additions & 1 deletion src/http/controllers/bookmark.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(),
},
Expand Down
7 changes: 7 additions & 0 deletions src/http/types/schemas/bookmark/get-bookmarks-query.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,14 +25,20 @@ export const getBookmarksQuerySchema = Type.Object({
*/
export type GetBookmarksQuery = Static<typeof getBookmarksQuerySchema>;

/**
* 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" }),
}),
Expand Down
59 changes: 57 additions & 2 deletions tests/unit/core/use-cases/bookmark/get-bookmarks.usecase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -12,22 +14,26 @@ describe("GetBookmarksUseCase", () => {
ICommentBookmarkRepository,
"findBookmarkedByUserId"
>;
let articleRepo: Pick<IArticleRepository, "findAll">;

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(
Expand All @@ -36,13 +42,19 @@ describe("GetBookmarksUseCase", () => {
total: 1,
},
);
vi.mocked(articleRepo.findAll).mockResolvedValue({
articles,
total: 1,
});

const result = await useCase.execute({ userId });

expect(result.posts).toBe(posts);
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 () => {
Expand All @@ -53,6 +65,10 @@ describe("GetBookmarksUseCase", () => {
total: 0,
},
);
vi.mocked(articleRepo.findAll).mockResolvedValue({
articles: [],
total: 0,
});

await useCase.execute({ userId });

Expand All @@ -74,6 +90,10 @@ describe("GetBookmarksUseCase", () => {
total: 0,
},
);
vi.mocked(articleRepo.findAll).mockResolvedValue({
articles: [],
total: 0,
});

await useCase.execute({ userId, page: 3, limit: 5 });

Expand All @@ -92,6 +112,10 @@ describe("GetBookmarksUseCase", () => {
total: 0,
},
);
vi.mocked(articleRepo.findAll).mockResolvedValue({
articles: [],
total: 0,
});

await useCase.execute({ userId, page: 2, limit: 20 });

Expand All @@ -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 () => {
Expand All @@ -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();
});
});