From ae6e92e9673799e9fb7b8479992728086ea5940b Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 25 Aug 2026 03:24:20 +0300 Subject: [PATCH 1/2] feat(article): add likes, bookmarks and tag integration Final stage of the article feature. POST /api/v1/articles/:id/like DELETE /api/v1/articles/:id/like POST /api/v1/articles/:id/bookmark DELETE /api/v1/articles/:id/bookmark All four are idempotent, so a retried request cannot double-count or drive a counter negative. Liking an unpublished article answers 404 rather than a distinct status, keeping the draft invisible. Removing a bookmark deliberately does not load the article: a bookmark is the user's own row, so they must be able to drop one even after the article was archived. The tag repository is rewritten. findTrending previously loaded every tag matching the window together with every one of its post rows, then sorted in JavaScript; adding a second relation to that shape would have made it worse. Both counts now come from the database as filtered relation counts, and what is fetched per tag is a name and two integers. Only published articles count toward trends and search. A draft contributing to a public trend list would leak its existence, and would also let anyone push a tag into the trends by writing an article they never publish. Ordering still happens in memory because no single orderBy can express "posts plus articles"; the candidate set is bounded by the time window or the search term, so this is a different order of magnitude from before. TrendItem and TagSearchItem gain articleCount, and postCount keeps its post-only meaning so existing clients are unaffected. The trend and tag search response schemas gain the field in the same commit - fast-json-stringify drops properties a schema does not declare, so a response type without it would have silently omitted the count. Cleanups the plan deferred to this stage: - findTopLevelByPostId is gone from the port and the repository; every caller moved to findTopLevelByTarget in the previous stage. - GetPostCommentsUseCase now serves both posts and articles, so it is renamed to GetCommentsUseCase along with its folder, input and DI key. Co-Authored-By: Claude Opus 5 --- src/app.ts | 5 + .../article-bookmark.repository.ts | 26 ++ .../repositories/article-like.repository.ts | 38 ++ .../ports/repositories/comment.repository.ts | 19 - src/core/ports/repositories/tag.repository.ts | 26 +- src/core/ports/services/transaction.port.ts | 4 + .../use-cases/article/like-article/index.ts | 6 + .../like-article-usecase.input.ts | 10 + .../like-article/like-article.usecase.ts | 75 ++++ .../article/remove-article-bookmark/index.ts | 6 + .../remove-article-bookmark-usecase.input.ts | 10 + .../remove-article-bookmark.usecase.ts | 37 ++ .../article/save-article-bookmark/index.ts | 6 + .../save-article-bookmark-usecase.input.ts | 10 + .../save-article-bookmark.usecase.ts | 46 +++ .../use-cases/article/unlike-article/index.ts | 6 + .../unlike-article-usecase.input.ts | 10 + .../unlike-article/unlike-article.usecase.ts | 47 +++ .../get-comments.input.ts} | 2 +- .../get-comments.usecase.ts} | 8 +- .../use-cases/comment/get-comments/index.ts | 8 + .../comment/get-post-comments/index.ts | 8 - src/http/controllers/article.controller.ts | 96 +++++ src/http/controllers/comment.controller.ts | 8 +- src/http/plugins/di/persistence.di.ts | 14 + src/http/plugins/di/use-cases.di.ts | 30 +- .../article/article-interaction.routes.ts | 57 +++ .../types/schemas/tag/search-tag.schema.ts | 1 + .../types/schemas/trends/get-trends.schema.ts | 1 + .../database/transaction.service.ts | 2 + .../prisma-article-bookmark.repository.ts | 49 +++ .../prisma-article-like.repository.ts | 69 ++++ .../repositories/prisma-comment.repository.ts | 47 --- .../repositories/prisma-tag.repository.ts | 100 ++++-- tests/e2e/article/interactions.test.ts | 334 ++++++++++++++++++ .../prisma-tag-articles.repository.test.ts | 215 +++++++++++ .../article-interactions.usecase.test.ts | 237 +++++++++++++ ...e.test.ts => get-comments.usecase.test.ts} | 8 +- 38 files changed, 1561 insertions(+), 120 deletions(-) create mode 100644 src/core/ports/repositories/article-bookmark.repository.ts create mode 100644 src/core/ports/repositories/article-like.repository.ts create mode 100644 src/core/use-cases/article/like-article/index.ts create mode 100644 src/core/use-cases/article/like-article/like-article-usecase.input.ts create mode 100644 src/core/use-cases/article/like-article/like-article.usecase.ts create mode 100644 src/core/use-cases/article/remove-article-bookmark/index.ts create mode 100644 src/core/use-cases/article/remove-article-bookmark/remove-article-bookmark-usecase.input.ts create mode 100644 src/core/use-cases/article/remove-article-bookmark/remove-article-bookmark.usecase.ts create mode 100644 src/core/use-cases/article/save-article-bookmark/index.ts create mode 100644 src/core/use-cases/article/save-article-bookmark/save-article-bookmark-usecase.input.ts create mode 100644 src/core/use-cases/article/save-article-bookmark/save-article-bookmark.usecase.ts create mode 100644 src/core/use-cases/article/unlike-article/index.ts create mode 100644 src/core/use-cases/article/unlike-article/unlike-article-usecase.input.ts create mode 100644 src/core/use-cases/article/unlike-article/unlike-article.usecase.ts rename src/core/use-cases/comment/{get-post-comments/get-post-comments.input.ts => get-comments/get-comments.input.ts} (89%) rename src/core/use-cases/comment/{get-post-comments/get-post-comments.usecase.ts => get-comments/get-comments.usecase.ts} (91%) create mode 100644 src/core/use-cases/comment/get-comments/index.ts delete mode 100644 src/core/use-cases/comment/get-post-comments/index.ts create mode 100644 src/http/routes/article/article-interaction.routes.ts create mode 100644 src/infrastructure/persistence/repositories/prisma-article-bookmark.repository.ts create mode 100644 src/infrastructure/persistence/repositories/prisma-article-like.repository.ts create mode 100644 tests/e2e/article/interactions.test.ts create mode 100644 tests/integration/persistence/repositories/prisma-tag-articles.repository.test.ts create mode 100644 tests/unit/core/use-cases/article/article-interactions.usecase.test.ts rename tests/unit/core/use-cases/comment/{get-post-comments.usecase.test.ts => get-comments.usecase.test.ts} (96%) diff --git a/src/app.ts b/src/app.ts index 1d944aa..9a4a650 100644 --- a/src/app.ts +++ b/src/app.ts @@ -32,6 +32,7 @@ import { tagRoutes } from "@routes/tags.routes"; import { translateRoutes } from "@routes/translate.routes"; import { articleRoutes } from "@routes/article/article.routes"; import { articleCommentRoutes } from "@routes/article/article-comment.routes"; +import { articleInteractionRoutes } from "@routes/article/article-interaction.routes"; /** * Main Application class responsible for orchestrating the Fastify server lifecycle. @@ -167,6 +168,10 @@ export class App { this.server.register(articleCommentRoutes, { prefix: "/api/v1", }); + + this.server.register(articleInteractionRoutes, { + prefix: "/api/v1", + }); } /** diff --git a/src/core/ports/repositories/article-bookmark.repository.ts b/src/core/ports/repositories/article-bookmark.repository.ts new file mode 100644 index 0000000..2e731be --- /dev/null +++ b/src/core/ports/repositories/article-bookmark.repository.ts @@ -0,0 +1,26 @@ +/** + * Repository interface for article bookmark operations. + */ +export interface IArticleBookmarkRepository { + /** + * Saves a bookmark for an article + * @param articleId - The article being bookmarked + * @param userId - The user creating the bookmark + */ + save(articleId: string, userId: string): Promise; + + /** + * Removes a bookmark for an article + * @param articleId - The article being unbookmarked + * @param userId - The user removing the bookmark + */ + remove(articleId: string, userId: string): Promise; + + /** + * Checks whether an article is bookmarked by a user + * @param articleId - The article to check + * @param userId - The user to check for + * @returns True when the article is bookmarked + */ + isBookmarked(articleId: string, userId: string): Promise; +} diff --git a/src/core/ports/repositories/article-like.repository.ts b/src/core/ports/repositories/article-like.repository.ts new file mode 100644 index 0000000..ced04fa --- /dev/null +++ b/src/core/ports/repositories/article-like.repository.ts @@ -0,0 +1,38 @@ +/** + * Repository interface for managing article like relationships. + */ +export interface IArticleLikeRepository { + /** + * Creates a like relationship between a user and an article + * @param articleId - The unique identifier of the article + * @param userId - The unique identifier of the user + */ + like(articleId: string, userId: string): Promise; + + /** + * Checks whether a user has already liked an article + * @param articleId - The unique identifier of the article + * @param userId - The unique identifier of the user + * @returns True when the user has liked the article + */ + isLiked(articleId: string, userId: string): Promise; + + /** + * Removes a like relationship between a user and an article + * @param articleId - The unique identifier of the article + * @param userId - The unique identifier of the user + */ + unlike(articleId: string, userId: string): Promise; + + /** + * Increments the cached like count of an article + * @param articleId - The article to update + */ + incrementLikeCount(articleId: string): Promise; + + /** + * Decrements the cached like count of an article + * @param articleId - The article to update + */ + decrementLikeCount(articleId: string): Promise; +} diff --git a/src/core/ports/repositories/comment.repository.ts b/src/core/ports/repositories/comment.repository.ts index 3f055cc..f432595 100644 --- a/src/core/ports/repositories/comment.repository.ts +++ b/src/core/ports/repositories/comment.repository.ts @@ -37,25 +37,6 @@ export interface ICommentRepository { */ findById(id: string, currentUserId?: string): Promise; - /** - * Retrieves top-level comments for a post (where parentId is null) - * - * @deprecated Prefer findTopLevelByTarget; kept so the post comment path - * is untouched by the polymorphic change, and removed once that path moves - * over. - * @param postId - The ID of the post to get comments for - * @param limit - Maximum number of comments to return - * @param offset - Number of comments to skip for pagination - * @param currentUserId - Optional ID of the current user for like/bookmark status - * @returns Promise that resolves to an array of top-level comments - */ - findTopLevelByPostId( - postId: string, - limit: number, - offset: number, - currentUserId?: string, - ): Promise; - /** * Retrieves top-level comments for a post or an article * @param target - What the comments are attached to diff --git a/src/core/ports/repositories/tag.repository.ts b/src/core/ports/repositories/tag.repository.ts index 40b7ba2..606d46e 100644 --- a/src/core/ports/repositories/tag.repository.ts +++ b/src/core/ports/repositories/tag.repository.ts @@ -3,7 +3,18 @@ */ export interface TrendItem { tag: string; + + /** + * Posts carrying this tag. + * + * Keeps its original post-only meaning so existing clients reading it are + * unaffected; articles are reported separately. + */ postCount: number; + + /** Published articles carrying this tag */ + articleCount: number; + category: string | null; } @@ -12,7 +23,13 @@ export interface TrendItem { */ export interface TagSearchItem { name: string; + + /** Posts carrying this tag */ postCount: number; + + /** Published articles carrying this tag */ + articleCount: number; + category: string | null; } @@ -31,13 +48,18 @@ export interface TrendingParams { */ export interface ITagRepository { /** - * Returns the most-used tags within the given time window, ordered by post count. + * Returns the most-used tags within the given time window. + * + * Ordered by posts and published articles combined. Drafts never count: + * an unpublished article must not be able to push its tag into a public + * trend list. + * * @param params - Limit and window size in days. */ findTrending(params: TrendingParams): Promise; /** - * Searches tags by name prefix/substring, ordered by post count descending. + * Searches tags by name prefix/substring, ordered by combined usage. * @param query - The search string to match against tag names. * @param limit - Maximum number of results to return. Defaults to 10. */ diff --git a/src/core/ports/services/transaction.port.ts b/src/core/ports/services/transaction.port.ts index 23acac2..ae33a34 100644 --- a/src/core/ports/services/transaction.port.ts +++ b/src/core/ports/services/transaction.port.ts @@ -7,6 +7,7 @@ import type { INotificationRepository } from "@core/ports/repositories/notificat import type { IBookmarkRepository } from "../repositories/bookmark.repository"; import type { IVerificationTokenRepository } from "@core/ports/repositories/verification-token.repository"; import type { IArticleRepository } from "@core/ports/repositories/article.repository"; +import type { IArticleLikeRepository } from "@core/ports/repositories/article-like.repository"; /** * Provides transactional access to repositories within a single atomic operation. @@ -36,6 +37,9 @@ export interface TransactionContext { /** Repository for article-related data operations within the transaction. */ readonly articleRepository: IArticleRepository; + + /** Repository for article like operations within the transaction. */ + readonly articleLikeRepository: IArticleLikeRepository; } /** diff --git a/src/core/use-cases/article/like-article/index.ts b/src/core/use-cases/article/like-article/index.ts new file mode 100644 index 0000000..bc82ee0 --- /dev/null +++ b/src/core/use-cases/article/like-article/index.ts @@ -0,0 +1,6 @@ +/** + * Article like module exports. + */ + +export * from "./like-article.usecase"; +export * from "./like-article-usecase.input"; diff --git a/src/core/use-cases/article/like-article/like-article-usecase.input.ts b/src/core/use-cases/article/like-article/like-article-usecase.input.ts new file mode 100644 index 0000000..294eaeb --- /dev/null +++ b/src/core/use-cases/article/like-article/like-article-usecase.input.ts @@ -0,0 +1,10 @@ +/** + * Input for liking an article. + */ +export interface LikeArticleUseCaseInput { + /** The article being liked */ + articleId: string; + + /** The authenticated user */ + userId: string; +} diff --git a/src/core/use-cases/article/like-article/like-article.usecase.ts b/src/core/use-cases/article/like-article/like-article.usecase.ts new file mode 100644 index 0000000..4fa06e3 --- /dev/null +++ b/src/core/use-cases/article/like-article/like-article.usecase.ts @@ -0,0 +1,75 @@ +import type { TransactionPort } from "@core/ports/services/transaction.port"; +import type { RealtimePort } from "@core/ports/services/realtime.port"; +import { Notification } from "@core/domain/entities/notification.entity"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; +import { NotFoundError } from "@core/errors"; +import type { LikeArticleUseCaseInput } from "./like-article-usecase.input"; + +/** + * Use case for liking an article. + * + * Liking is idempotent: a second like from the same user is a no-op rather + * than an error, so a retried request cannot inflate the count. + */ +export class LikeArticleUseCase { + /** + * @param transactionService - Service for handling database transactions + * @param realtimeService - Service for real-time notifications + */ + constructor( + private readonly transactionService: TransactionPort, + private readonly realtimeService: RealtimePort, + ) {} + + /** + * Executes the like. + * + * @param input - The article and the user liking it + * @throws NotFoundError - When the article does not exist or is not visible + */ + async execute(input: LikeArticleUseCaseInput): Promise { + await this.transactionService.runInTransaction(async (ctx) => { + const article = await ctx.articleRepository.findById( + input.articleId, + ); + + // Unpublished articles are invisible to everyone but their author, + // and answering anything other than 404 would confirm one exists. + if (!article || !article.isPublished()) { + throw new NotFoundError("Article not found."); + } + + const alreadyLiked = await ctx.articleLikeRepository.isLiked( + input.articleId, + input.userId, + ); + + if (alreadyLiked) return; + + await ctx.articleLikeRepository.like(input.articleId, input.userId); + await ctx.articleLikeRepository.incrementLikeCount(input.articleId); + + if (article.author.id !== input.userId) { + const notification = Notification.create( + article.author.id, + input.userId, + NotificationType.LIKE, + input.articleId, + ); + + await ctx.notificationRepository.create(notification); + + this.realtimeService.emitToUser( + article.author.id, + "new-notification", + { + type: NotificationType.LIKE, + issuerId: input.userId, + articleId: input.articleId, + referenceId: input.articleId, + }, + ); + } + }); + } +} diff --git a/src/core/use-cases/article/remove-article-bookmark/index.ts b/src/core/use-cases/article/remove-article-bookmark/index.ts new file mode 100644 index 0000000..9b6bf24 --- /dev/null +++ b/src/core/use-cases/article/remove-article-bookmark/index.ts @@ -0,0 +1,6 @@ +/** + * Article bookmark removal module exports. + */ + +export * from "./remove-article-bookmark.usecase"; +export * from "./remove-article-bookmark-usecase.input"; diff --git a/src/core/use-cases/article/remove-article-bookmark/remove-article-bookmark-usecase.input.ts b/src/core/use-cases/article/remove-article-bookmark/remove-article-bookmark-usecase.input.ts new file mode 100644 index 0000000..1c5a855 --- /dev/null +++ b/src/core/use-cases/article/remove-article-bookmark/remove-article-bookmark-usecase.input.ts @@ -0,0 +1,10 @@ +/** + * Input for removing an article bookmark. + */ +export interface RemoveArticleBookmarkUseCaseInput { + /** The article being unbookmarked */ + articleId: string; + + /** The authenticated user */ + userId: string; +} diff --git a/src/core/use-cases/article/remove-article-bookmark/remove-article-bookmark.usecase.ts b/src/core/use-cases/article/remove-article-bookmark/remove-article-bookmark.usecase.ts new file mode 100644 index 0000000..92e7b17 --- /dev/null +++ b/src/core/use-cases/article/remove-article-bookmark/remove-article-bookmark.usecase.ts @@ -0,0 +1,37 @@ +import type { IArticleBookmarkRepository } from "@core/ports/repositories/article-bookmark.repository"; +import type { RemoveArticleBookmarkUseCaseInput } from "./remove-article-bookmark-usecase.input"; + +/** + * Use case for removing an article bookmark. + * + * The article itself is not loaded: a bookmark is the user's own row, so + * removing one they hold must keep working even if the article has since been + * archived. Removing a bookmark that does not exist is a no-op. + */ +export class RemoveArticleBookmarkUseCase { + /** + * @param articleBookmarkRepository - Repository for bookmark rows + */ + constructor( + private readonly articleBookmarkRepository: IArticleBookmarkRepository, + ) {} + + /** + * Executes the removal. + * + * @param input - The article and the user removing their bookmark + */ + async execute(input: RemoveArticleBookmarkUseCaseInput): Promise { + const bookmarked = await this.articleBookmarkRepository.isBookmarked( + input.articleId, + input.userId, + ); + + if (!bookmarked) return; + + await this.articleBookmarkRepository.remove( + input.articleId, + input.userId, + ); + } +} diff --git a/src/core/use-cases/article/save-article-bookmark/index.ts b/src/core/use-cases/article/save-article-bookmark/index.ts new file mode 100644 index 0000000..6b39173 --- /dev/null +++ b/src/core/use-cases/article/save-article-bookmark/index.ts @@ -0,0 +1,6 @@ +/** + * Article bookmark module exports. + */ + +export * from "./save-article-bookmark.usecase"; +export * from "./save-article-bookmark-usecase.input"; diff --git a/src/core/use-cases/article/save-article-bookmark/save-article-bookmark-usecase.input.ts b/src/core/use-cases/article/save-article-bookmark/save-article-bookmark-usecase.input.ts new file mode 100644 index 0000000..fe58d73 --- /dev/null +++ b/src/core/use-cases/article/save-article-bookmark/save-article-bookmark-usecase.input.ts @@ -0,0 +1,10 @@ +/** + * Input for bookmarking an article. + */ +export interface SaveArticleBookmarkUseCaseInput { + /** The article being bookmarked */ + articleId: string; + + /** The authenticated user */ + userId: string; +} diff --git a/src/core/use-cases/article/save-article-bookmark/save-article-bookmark.usecase.ts b/src/core/use-cases/article/save-article-bookmark/save-article-bookmark.usecase.ts new file mode 100644 index 0000000..8fda576 --- /dev/null +++ b/src/core/use-cases/article/save-article-bookmark/save-article-bookmark.usecase.ts @@ -0,0 +1,46 @@ +import type { IArticleRepository } from "@core/ports/repositories/article.repository"; +import type { IArticleBookmarkRepository } from "@core/ports/repositories/article-bookmark.repository"; +import { NotFoundError } from "@core/errors"; +import type { SaveArticleBookmarkUseCaseInput } from "./save-article-bookmark-usecase.input"; + +/** + * Use case for bookmarking an article. + * + * Idempotent: bookmarking twice leaves one bookmark. + */ +export class SaveArticleBookmarkUseCase { + /** + * @param articleRepository - Repository for reading articles + * @param articleBookmarkRepository - Repository for bookmark rows + */ + constructor( + private readonly articleRepository: IArticleRepository, + private readonly articleBookmarkRepository: IArticleBookmarkRepository, + ) {} + + /** + * Executes the bookmark. + * + * @param input - The article and the user bookmarking it + * @throws NotFoundError - When the article does not exist or is not visible + */ + async execute(input: SaveArticleBookmarkUseCaseInput): Promise { + const article = await this.articleRepository.findById(input.articleId); + + if (!article || !article.isPublished()) { + throw new NotFoundError("Article not found."); + } + + const already = await this.articleBookmarkRepository.isBookmarked( + input.articleId, + input.userId, + ); + + if (already) return; + + await this.articleBookmarkRepository.save( + input.articleId, + input.userId, + ); + } +} diff --git a/src/core/use-cases/article/unlike-article/index.ts b/src/core/use-cases/article/unlike-article/index.ts new file mode 100644 index 0000000..dec0a50 --- /dev/null +++ b/src/core/use-cases/article/unlike-article/index.ts @@ -0,0 +1,6 @@ +/** + * Article unlike module exports. + */ + +export * from "./unlike-article.usecase"; +export * from "./unlike-article-usecase.input"; diff --git a/src/core/use-cases/article/unlike-article/unlike-article-usecase.input.ts b/src/core/use-cases/article/unlike-article/unlike-article-usecase.input.ts new file mode 100644 index 0000000..f7ae448 --- /dev/null +++ b/src/core/use-cases/article/unlike-article/unlike-article-usecase.input.ts @@ -0,0 +1,10 @@ +/** + * Input for removing a like from an article. + */ +export interface UnlikeArticleUseCaseInput { + /** The article being unliked */ + articleId: string; + + /** The authenticated user */ + userId: string; +} diff --git a/src/core/use-cases/article/unlike-article/unlike-article.usecase.ts b/src/core/use-cases/article/unlike-article/unlike-article.usecase.ts new file mode 100644 index 0000000..b2f7290 --- /dev/null +++ b/src/core/use-cases/article/unlike-article/unlike-article.usecase.ts @@ -0,0 +1,47 @@ +import type { TransactionPort } from "@core/ports/services/transaction.port"; +import { NotFoundError } from "@core/errors"; +import type { UnlikeArticleUseCaseInput } from "./unlike-article-usecase.input"; + +/** + * Use case for removing a like from an article. + * + * Idempotent in the same way liking is: unliking something that was never + * liked does nothing rather than failing, so the count cannot go negative. + */ +export class UnlikeArticleUseCase { + /** + * @param transactionService - Service for handling database transactions + */ + constructor(private readonly transactionService: TransactionPort) {} + + /** + * Executes the unlike. + * + * @param input - The article and the user removing their like + * @throws NotFoundError - When the article does not exist or is not visible + */ + async execute(input: UnlikeArticleUseCaseInput): Promise { + await this.transactionService.runInTransaction(async (ctx) => { + const article = await ctx.articleRepository.findById( + input.articleId, + ); + + if (!article || !article.isPublished()) { + throw new NotFoundError("Article not found."); + } + + const liked = await ctx.articleLikeRepository.isLiked( + input.articleId, + input.userId, + ); + + if (!liked) return; + + await ctx.articleLikeRepository.unlike( + input.articleId, + input.userId, + ); + await ctx.articleLikeRepository.decrementLikeCount(input.articleId); + }); + } +} diff --git a/src/core/use-cases/comment/get-post-comments/get-post-comments.input.ts b/src/core/use-cases/comment/get-comments/get-comments.input.ts similarity index 89% rename from src/core/use-cases/comment/get-post-comments/get-post-comments.input.ts rename to src/core/use-cases/comment/get-comments/get-comments.input.ts index 78e95a9..43c9d9b 100644 --- a/src/core/use-cases/comment/get-post-comments/get-post-comments.input.ts +++ b/src/core/use-cases/comment/get-comments/get-comments.input.ts @@ -3,7 +3,7 @@ import type { CommentTarget } from "@core/ports/repositories/comment.repository" /** * Input for listing the top-level comments of a post or an article. */ -export interface GetPostCommentsUseCaseInput { +export interface GetCommentsUseCaseInput { /** What the comments are attached to */ target: CommentTarget; diff --git a/src/core/use-cases/comment/get-post-comments/get-post-comments.usecase.ts b/src/core/use-cases/comment/get-comments/get-comments.usecase.ts similarity index 91% rename from src/core/use-cases/comment/get-post-comments/get-post-comments.usecase.ts rename to src/core/use-cases/comment/get-comments/get-comments.usecase.ts index ae82e07..1c93d8b 100644 --- a/src/core/use-cases/comment/get-post-comments/get-post-comments.usecase.ts +++ b/src/core/use-cases/comment/get-comments/get-comments.usecase.ts @@ -3,7 +3,7 @@ import type { IArticleRepository } from "@core/ports/repositories/article.reposi import type { IPostRepository } from "@core/ports/repositories/post.repository"; import { NotFoundError } from "@core/errors"; import type { Comment } from "@core/domain/entities/comment.entity"; -import type { GetPostCommentsUseCaseInput } from "./get-post-comments.input"; +import type { GetCommentsUseCaseInput } from "./get-comments.input"; /** * Use case for listing the top-level comments of a post or an article. @@ -12,7 +12,7 @@ import type { GetPostCommentsUseCaseInput } from "./get-post-comments.input"; * be used to probe for content the caller could not otherwise see: an * unpublished article answers 404 here exactly as it does on its own endpoint. */ -export class GetPostCommentsUseCase { +export class GetCommentsUseCase { /** * @param commentRepository - Repository for reading comments * @param postRepository - Repository used to verify a post target @@ -31,7 +31,7 @@ export class GetPostCommentsUseCase { * @throws NotFoundError - When the target is missing or not visible */ private async assertTargetVisible( - input: GetPostCommentsUseCaseInput, + input: GetCommentsUseCaseInput, ): Promise { if (input.target.type === "POST") { const post = await this.postRepository.findById(input.target.id); @@ -56,7 +56,7 @@ export class GetPostCommentsUseCase { * @returns The page of top-level comments * @throws NotFoundError if the target does not exist or is not visible */ - async execute(input: GetPostCommentsUseCaseInput): Promise { + async execute(input: GetCommentsUseCaseInput): Promise { const page = input.page || 1; const limit = input.limit || 10; const offset = (page - 1) * limit; diff --git a/src/core/use-cases/comment/get-comments/index.ts b/src/core/use-cases/comment/get-comments/index.ts new file mode 100644 index 0000000..6566af8 --- /dev/null +++ b/src/core/use-cases/comment/get-comments/index.ts @@ -0,0 +1,8 @@ +/** + * Barrel export for the GetPostComments use case and its related types + */ +export { GetCommentsUseCaseInput } from "./get-comments.input"; +/** + * Barrel export for the GetPostComments use case + */ +export { GetCommentsUseCase } from "./get-comments.usecase"; diff --git a/src/core/use-cases/comment/get-post-comments/index.ts b/src/core/use-cases/comment/get-post-comments/index.ts deleted file mode 100644 index eb5cd1e..0000000 --- a/src/core/use-cases/comment/get-post-comments/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Barrel export for the GetPostComments use case and its related types - */ -export { GetPostCommentsUseCaseInput } from "./get-post-comments.input"; -/** - * Barrel export for the GetPostComments use case - */ -export { GetPostCommentsUseCase } from "./get-post-comments.usecase"; diff --git a/src/http/controllers/article.controller.ts b/src/http/controllers/article.controller.ts index 62cf230..6cf87d6 100644 --- a/src/http/controllers/article.controller.ts +++ b/src/http/controllers/article.controller.ts @@ -8,6 +8,10 @@ import type { GetArticlesUseCase } from "@core/use-cases/article/get-articles"; import type { GetArticleUseCase } from "@core/use-cases/article/get-article"; import type { GetMyArticlesUseCase } from "@core/use-cases/article/get-my-articles"; import type { UploadArticleCoverUseCase } from "@core/use-cases/article/upload-article-cover"; +import type { LikeArticleUseCase } from "@core/use-cases/article/like-article"; +import type { UnlikeArticleUseCase } from "@core/use-cases/article/unlike-article"; +import type { SaveArticleBookmarkUseCase } from "@core/use-cases/article/save-article-bookmark"; +import type { RemoveArticleBookmarkUseCase } from "@core/use-cases/article/remove-article-bookmark"; import { NoMediaProvidedError } from "@core/errors"; import { ArticlePrismaMapper } from "@infrastructure/persistence/mappers/article-prisma.mapper"; import type { CreateArticleBody } from "@typings/schemas/article/create-article.schema"; @@ -36,6 +40,10 @@ export class ArticleController { * @param getArticleUseCase - Use case for reading one article by slug * @param getMyArticlesUseCase - Use case for an author's own articles * @param uploadArticleCoverUseCase - Use case for storing a cover image + * @param likeArticleUseCase - Use case for liking an article + * @param unlikeArticleUseCase - Use case for removing a like + * @param saveArticleBookmarkUseCase - Use case for bookmarking an article + * @param removeArticleBookmarkUseCase - Use case for removing a bookmark */ constructor( private readonly createArticleUseCase: CreateArticleUseCase, @@ -47,6 +55,10 @@ export class ArticleController { private readonly getArticleUseCase: GetArticleUseCase, private readonly getMyArticlesUseCase: GetMyArticlesUseCase, private readonly uploadArticleCoverUseCase: UploadArticleCoverUseCase, + private readonly likeArticleUseCase: LikeArticleUseCase, + private readonly unlikeArticleUseCase: UnlikeArticleUseCase, + private readonly saveArticleBookmarkUseCase: SaveArticleBookmarkUseCase, + private readonly removeArticleBookmarkUseCase: RemoveArticleBookmarkUseCase, ) {} /** @@ -330,6 +342,90 @@ export class ArticleController { }); } + /** + * Likes an article. + * + * @param request - Request identifying the article + * @param reply - The Fastify reply object + * @returns A 200 response with only a timestamp + */ + async like( + request: FastifyRequest<{ Params: ArticleIdParams }>, + reply: FastifyReply, + ): Promise { + await this.likeArticleUseCase.execute({ + articleId: request.params.id, + userId: request.user.id, + }); + + return reply + .status(200) + .send({ meta: { timestamp: new Date().toISOString() } }); + } + + /** + * Removes the caller's like from an article. + * + * @param request - Request identifying the article + * @param reply - The Fastify reply object + * @returns A 200 response with only a timestamp + */ + async unlike( + request: FastifyRequest<{ Params: ArticleIdParams }>, + reply: FastifyReply, + ): Promise { + await this.unlikeArticleUseCase.execute({ + articleId: request.params.id, + userId: request.user.id, + }); + + return reply + .status(200) + .send({ meta: { timestamp: new Date().toISOString() } }); + } + + /** + * Bookmarks an article for the caller. + * + * @param request - Request identifying the article + * @param reply - The Fastify reply object + * @returns A 200 response with only a timestamp + */ + async bookmark( + request: FastifyRequest<{ Params: ArticleIdParams }>, + reply: FastifyReply, + ): Promise { + await this.saveArticleBookmarkUseCase.execute({ + articleId: request.params.id, + userId: request.user.id, + }); + + return reply + .status(200) + .send({ meta: { timestamp: new Date().toISOString() } }); + } + + /** + * Removes the caller's bookmark from an article. + * + * @param request - Request identifying the article + * @param reply - The Fastify reply object + * @returns A 200 response with only a timestamp + */ + async removeBookmark( + request: FastifyRequest<{ Params: ArticleIdParams }>, + reply: FastifyReply, + ): Promise { + await this.removeArticleBookmarkUseCase.execute({ + articleId: request.params.id, + userId: request.user.id, + }); + + return reply + .status(200) + .send({ meta: { timestamp: new Date().toISOString() } }); + } + /** * Resolves the CDN base URL, without a trailing slash. * diff --git a/src/http/controllers/comment.controller.ts b/src/http/controllers/comment.controller.ts index 00fbfbd..15ad693 100644 --- a/src/http/controllers/comment.controller.ts +++ b/src/http/controllers/comment.controller.ts @@ -4,7 +4,7 @@ */ import type { CreateCommentUseCase } from "@core/use-cases/comment/create-comment/create-comment.usecase"; import type { DeleteCommentUseCase } from "@core/use-cases/comment/delete-comment/delete-comment.usecase"; -import type { GetPostCommentsUseCase } from "@core/use-cases/comment/get-post-comments/get-post-comments.usecase"; +import type { GetCommentsUseCase } from "@core/use-cases/comment/get-comments/get-comments.usecase"; import type { GetCommentUseCase } from "@core/use-cases/comment/get-comment/get-comment.usecase"; import type { GetCommentRepliesUseCase } from "@core/use-cases/comment/get-comment-replies/get-comment-replies.usecase"; import type { LikeCommentUseCase } from "@core/use-cases/comment/like-comment/like-comment.usecase"; @@ -36,7 +36,7 @@ export class CommentController { constructor( private readonly createCommentUseCase: CreateCommentUseCase, private readonly deleteCommentUseCase: DeleteCommentUseCase, - private readonly getPostCommentsUseCase: GetPostCommentsUseCase, + private readonly getCommentsUseCase: GetCommentsUseCase, private readonly getCommentUseCase: GetCommentUseCase, private readonly getCommentRepliesUseCase: GetCommentRepliesUseCase, private readonly likeCommentUseCase: LikeCommentUseCase, @@ -130,7 +130,7 @@ export class CommentController { const { page = 1, limit = 10 } = request.query; const currentUserId = request.user?.id; - const comments = await this.getPostCommentsUseCase.execute({ + const comments = await this.getCommentsUseCase.execute({ target: { type: "ARTICLE", id: articleId }, page, limit, @@ -175,7 +175,7 @@ export class CommentController { const currentUserId = request.user?.id; const cdnUrl = request.server.config.R2_PUBLIC_URL; - const comments = await this.getPostCommentsUseCase.execute({ + const comments = await this.getCommentsUseCase.execute({ target: { type: "POST", id: postId }, page, limit, diff --git a/src/http/plugins/di/persistence.di.ts b/src/http/plugins/di/persistence.di.ts index ba01995..5372d7e 100644 --- a/src/http/plugins/di/persistence.di.ts +++ b/src/http/plugins/di/persistence.di.ts @@ -14,6 +14,8 @@ import { PrismaCommentRepository } from "@infrastructure/persistence/repositorie import { PrismaCommentBookmarkRepository } from "@infrastructure/persistence/repositories/prisma-comment-bookmark.repository"; import { PrismaTagRepository } from "@infrastructure/persistence/repositories/prisma-tag.repository"; import { PrismaArticleRepository } from "@infrastructure/persistence/repositories/prisma-article.repository"; +import { PrismaArticleLikeRepository } from "@infrastructure/persistence/repositories/prisma-article-like.repository"; +import { PrismaArticleBookmarkRepository } from "@infrastructure/persistence/repositories/prisma-article-bookmark.repository"; /** * Dependency injection module for persistence layer @@ -110,4 +112,16 @@ export const persistenceModule = { * Article repository for managing long-form article persistence */ articleRepository: asClass(PrismaArticleRepository).singleton(), + + /** + * Article like repository for managing article like relationships + */ + articleLikeRepository: asClass(PrismaArticleLikeRepository).singleton(), + + /** + * Article bookmark repository for managing article bookmarks + */ + articleBookmarkRepository: asClass( + PrismaArticleBookmarkRepository, + ).singleton(), }; diff --git a/src/http/plugins/di/use-cases.di.ts b/src/http/plugins/di/use-cases.di.ts index c9c0a2e..92d4d13 100644 --- a/src/http/plugins/di/use-cases.di.ts +++ b/src/http/plugins/di/use-cases.di.ts @@ -44,7 +44,7 @@ import { GetBookmarksUseCase } from "@core/use-cases/bookmark/get-bookmarks/get- import { DeleteCommentUseCase } from "@core/use-cases/comment/delete-comment/delete-comment.usecase"; import { GetUserPostsUseCase } from "@core/use-cases/post/get-user-posts/get-user.posts.usecase"; import { GetPostDetailUseCase } from "@core/use-cases/post/get-post-detail/get-post-detail.usecase"; -import { GetPostCommentsUseCase } from "@core/use-cases/comment/get-post-comments/get-post-comments.usecase"; +import { GetCommentsUseCase } from "@core/use-cases/comment/get-comments/get-comments.usecase"; import { GetCommentUseCase } from "@core/use-cases/comment/get-comment/get-comment.usecase"; import { GetCommentRepliesUseCase } from "@core/use-cases/comment/get-comment-replies/get-comment-replies.usecase"; import { LikeCommentUseCase } from "@core/use-cases/comment/like-comment/like-comment.usecase"; @@ -65,6 +65,10 @@ import { GetArticlesUseCase } from "@core/use-cases/article/get-articles"; import { GetArticleUseCase } from "@core/use-cases/article/get-article"; import { GetMyArticlesUseCase } from "@core/use-cases/article/get-my-articles"; import { UploadArticleCoverUseCase } from "@core/use-cases/article/upload-article-cover"; +import { LikeArticleUseCase } from "@core/use-cases/article/like-article"; +import { UnlikeArticleUseCase } from "@core/use-cases/article/unlike-article"; +import { SaveArticleBookmarkUseCase } from "@core/use-cases/article/save-article-bookmark"; +import { RemoveArticleBookmarkUseCase } from "@core/use-cases/article/remove-article-bookmark"; /** * Dependency injection module for use cases @@ -383,7 +387,7 @@ export const useCasesModule = { /** * */ - getPostCommentsUseCase: asClass(GetPostCommentsUseCase).singleton(), + getCommentsUseCase: asClass(GetCommentsUseCase).singleton(), /** * */ @@ -458,4 +462,26 @@ export const useCasesModule = { * Use case for storing an article cover image */ uploadArticleCoverUseCase: asClass(UploadArticleCoverUseCase).singleton(), + + /** + * Use case for liking an article + */ + likeArticleUseCase: asClass(LikeArticleUseCase).singleton(), + + /** + * Use case for removing a like from an article + */ + unlikeArticleUseCase: asClass(UnlikeArticleUseCase).singleton(), + + /** + * Use case for bookmarking an article + */ + saveArticleBookmarkUseCase: asClass(SaveArticleBookmarkUseCase).singleton(), + + /** + * Use case for removing an article bookmark + */ + removeArticleBookmarkUseCase: asClass( + RemoveArticleBookmarkUseCase, + ).singleton(), }; diff --git a/src/http/routes/article/article-interaction.routes.ts b/src/http/routes/article/article-interaction.routes.ts new file mode 100644 index 0000000..fc5c4f9 --- /dev/null +++ b/src/http/routes/article/article-interaction.routes.ts @@ -0,0 +1,57 @@ +/** + * @module ArticleInteractionRoutes + * Like and bookmark routes for articles. + */ + +import type { FastifyInstance } from "fastify"; +import { RateLimitPolicies } from "@plugins/rate-limit.plugin"; +import { MetaOnlyResponseSchema } from "@typings/schemas/create-response-schema"; +import { + articleIdParamsSchema, + type ArticleIdParams, +} from "@typings/schemas/article/article-params.schema"; + +/** + * Registers the article like and bookmark endpoints. + * + * All four are idempotent, so a retried request cannot double-count. + * + * @param fastify - The Fastify application instance + */ +export function articleInteractionRoutes(fastify: FastifyInstance): void { + const { articleController } = fastify.diContainer.cradle; + + const options = { + onRequest: [fastify.authenticate], + schema: { + params: articleIdParamsSchema, + response: { 200: MetaOnlyResponseSchema }, + tags: ["Article", "Interaction"], + }, + config: { rateLimit: RateLimitPolicies.STANDARD }, + }; + + fastify.post<{ Params: ArticleIdParams }>( + "/articles/:id/like", + options, + articleController.like.bind(articleController), + ); + + fastify.delete<{ Params: ArticleIdParams }>( + "/articles/:id/like", + options, + articleController.unlike.bind(articleController), + ); + + fastify.post<{ Params: ArticleIdParams }>( + "/articles/:id/bookmark", + options, + articleController.bookmark.bind(articleController), + ); + + fastify.delete<{ Params: ArticleIdParams }>( + "/articles/:id/bookmark", + options, + articleController.removeBookmark.bind(articleController), + ); +} diff --git a/src/http/types/schemas/tag/search-tag.schema.ts b/src/http/types/schemas/tag/search-tag.schema.ts index 4437320..4fd49f5 100644 --- a/src/http/types/schemas/tag/search-tag.schema.ts +++ b/src/http/types/schemas/tag/search-tag.schema.ts @@ -16,6 +16,7 @@ export const SearchTagsResponseSchema = Type.Object({ Type.Object({ name: Type.String(), postCount: Type.Number(), + articleCount: Type.Number(), category: Type.Union([Type.String(), Type.Null()]), }), ), diff --git a/src/http/types/schemas/trends/get-trends.schema.ts b/src/http/types/schemas/trends/get-trends.schema.ts index fbc35fe..b98fa8f 100644 --- a/src/http/types/schemas/trends/get-trends.schema.ts +++ b/src/http/types/schemas/trends/get-trends.schema.ts @@ -18,6 +18,7 @@ export const GetTrendsResponseSchema = Type.Object({ Type.Object({ tag: Type.String(), postCount: Type.Number(), + articleCount: Type.Number(), category: Type.Union([Type.String(), Type.Null()]), }), ), diff --git a/src/infrastructure/persistence/database/transaction.service.ts b/src/infrastructure/persistence/database/transaction.service.ts index 31a5d78..f759b99 100644 --- a/src/infrastructure/persistence/database/transaction.service.ts +++ b/src/infrastructure/persistence/database/transaction.service.ts @@ -13,6 +13,7 @@ import { PrismaLikeRepository } from "../repositories/prisma-like.repository"; import { PrismaBookmarkRepository } from "../repositories/prisma-bookmark.repository"; import { PrismaVerificationTokenRepository } from "../repositories/prisma-verification-token.repository"; import { PrismaArticleRepository } from "../repositories/prisma-article.repository"; +import { PrismaArticleLikeRepository } from "../repositories/prisma-article-like.repository"; /** * Transaction service implementation for managing database transactions @@ -55,6 +56,7 @@ export class TransactionService implements TransactionPort { verificationTokenRepository: new PrismaVerificationTokenRepository(tx), articleRepository: new PrismaArticleRepository(tx), + articleLikeRepository: new PrismaArticleLikeRepository(tx), }; return await work(context); diff --git a/src/infrastructure/persistence/repositories/prisma-article-bookmark.repository.ts b/src/infrastructure/persistence/repositories/prisma-article-bookmark.repository.ts new file mode 100644 index 0000000..61297de --- /dev/null +++ b/src/infrastructure/persistence/repositories/prisma-article-bookmark.repository.ts @@ -0,0 +1,49 @@ +import type { IArticleBookmarkRepository } from "@core/ports/repositories/article-bookmark.repository"; +import type { PrismaTransactionalClient } from "@infrastructure/persistence/database/prisma-client.type"; + +/** + * Prisma implementation of the article bookmark repository. + */ +export class PrismaArticleBookmarkRepository implements IArticleBookmarkRepository { + /** + * @param prisma - Prisma client, possibly scoped to a transaction + */ + constructor(private readonly prisma: PrismaTransactionalClient) {} + + /** + * Saves a bookmark for an article + * @param articleId - The article being bookmarked + * @param userId - The user creating the bookmark + */ + async save(articleId: string, userId: string): Promise { + await this.prisma.articleBookmark.create({ + data: { articleId, userId }, + }); + } + + /** + * Removes a bookmark for an article + * @param articleId - The article being unbookmarked + * @param userId - The user removing the bookmark + */ + async remove(articleId: string, userId: string): Promise { + await this.prisma.articleBookmark.delete({ + where: { articleId_userId: { articleId, userId } }, + }); + } + + /** + * Checks whether an article is bookmarked by a user + * @param articleId - The article to check + * @param userId - The user to check for + * @returns True when a bookmark row exists + */ + async isBookmarked(articleId: string, userId: string): Promise { + const existing = await this.prisma.articleBookmark.findUnique({ + where: { articleId_userId: { articleId, userId } }, + select: { id: true }, + }); + + return existing !== null; + } +} diff --git a/src/infrastructure/persistence/repositories/prisma-article-like.repository.ts b/src/infrastructure/persistence/repositories/prisma-article-like.repository.ts new file mode 100644 index 0000000..ef037a2 --- /dev/null +++ b/src/infrastructure/persistence/repositories/prisma-article-like.repository.ts @@ -0,0 +1,69 @@ +import type { IArticleLikeRepository } from "@core/ports/repositories/article-like.repository"; +import type { PrismaTransactionalClient } from "@infrastructure/persistence/database/prisma-client.type"; + +/** + * Prisma implementation of the article like repository. + */ +export class PrismaArticleLikeRepository implements IArticleLikeRepository { + /** + * @param prisma - Prisma client, possibly scoped to a transaction + */ + constructor(private readonly prisma: PrismaTransactionalClient) {} + + /** + * Creates a like relationship between a user and an article + * @param articleId - The article being liked + * @param userId - The user liking it + */ + async like(articleId: string, userId: string): Promise { + await this.prisma.articleLike.create({ data: { articleId, userId } }); + } + + /** + * Checks whether a user has already liked an article + * @param articleId - The article to check + * @param userId - The user to check for + * @returns True when a like row exists + */ + async isLiked(articleId: string, userId: string): Promise { + const existing = await this.prisma.articleLike.findUnique({ + where: { articleId_userId: { articleId, userId } }, + select: { id: true }, + }); + + return existing !== null; + } + + /** + * Removes a like relationship between a user and an article + * @param articleId - The article being unliked + * @param userId - The user removing their like + */ + async unlike(articleId: string, userId: string): Promise { + await this.prisma.articleLike.delete({ + where: { articleId_userId: { articleId, userId } }, + }); + } + + /** + * Increments the cached like count of an article + * @param articleId - The article to update + */ + async incrementLikeCount(articleId: string): Promise { + await this.prisma.article.update({ + where: { id: articleId }, + data: { likeCount: { increment: 1 } }, + }); + } + + /** + * Decrements the cached like count of an article + * @param articleId - The article to update + */ + async decrementLikeCount(articleId: string): Promise { + await this.prisma.article.update({ + where: { id: articleId }, + data: { likeCount: { decrement: 1 } }, + }); + } +} diff --git a/src/infrastructure/persistence/repositories/prisma-comment.repository.ts b/src/infrastructure/persistence/repositories/prisma-comment.repository.ts index 3a69612..c8aa164 100644 --- a/src/infrastructure/persistence/repositories/prisma-comment.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-comment.repository.ts @@ -89,13 +89,6 @@ export class PrismaCommentRepository implements ICommentRepository { ); } - /** - * Retrieves top-level comments for a post (where parentId is null) - * @param postId - The ID of the post to get comments for - * @param limit - Maximum number of comments to return - * @param offset - Number of comments to skip for pagination - * @returns Promise that resolves to an array of top-level comments - */ /** * Translates a comment target into the matching where clause * @param target - What the comments are attached to @@ -166,46 +159,6 @@ export class PrismaCommentRepository implements ICommentRepository { }); } - async findTopLevelByPostId( - postId: string, - limit: number, - offset: number, - currentUserId?: string, - ): Promise { - const rawComments = await this.prisma.comment.findMany({ - where: { - postId: postId, - parentId: null, - }, - skip: offset, - take: limit, - orderBy: { createdAt: "desc" }, - include: { - author: { - select: { - id: true, - username: true, - profile: { - select: { avatarUrl: true, fullName: true }, - }, - }, - }, - likes: currentUserId - ? { where: { userId: currentUserId } } - : false, - bookmarks: currentUserId - ? { where: { userId: currentUserId } } - : false, - }, - }); - - return rawComments.map((raw) => - CommentPrismaMapper.toDomainComment( - raw as unknown as CommentWithRelations, - ), - ); - } - /** * Retrieves replies for a specific parent comment * @param parentId - The ID of the parent comment diff --git a/src/infrastructure/persistence/repositories/prisma-tag.repository.ts b/src/infrastructure/persistence/repositories/prisma-tag.repository.ts index 6536937..a267f79 100644 --- a/src/infrastructure/persistence/repositories/prisma-tag.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-tag.repository.ts @@ -4,14 +4,15 @@ import type { TrendItem, TrendingParams, } from "@core/ports/repositories/tag.repository"; +import { ArticleStatus } from "@core/domain/enums"; import type { PrismaTransactionalClient } from "@infrastructure/persistence/database/prisma-client.type"; /** * Prisma implementation of the Tag repository. * - * Provides database operations for Tag entities using Prisma ORM. - * Implements the ITagRepository interface to ensure consistent - * data access patterns across different persistence implementations. + * Tags are shared between posts and articles: one vocabulary, one autocomplete, + * one trend list. Both counts are computed by the database rather than by + * loading the related rows. */ export class PrismaTagRepository implements ITagRepository { /** @@ -22,11 +23,20 @@ export class PrismaTagRepository implements ITagRepository { constructor(private readonly prisma: PrismaTransactionalClient) {} /** - * Retrieves the most frequently used tags (trending) within a specified time window. - * Uses a "Twitter-style" trend algorithm where the tag itself represents the category. + * Retrieves the most frequently used tags within a time window. * - * @param params - The parameters containing the limit of tags to retrieve and the time window in days. - * @returns A promise that resolves to an array of trending tags (TrendItem). + * Only published articles are counted. A draft contributing to a public + * trend list would leak its existence, and it would also let anyone push a + * tag into the trends by writing an article they never publish. + * + * The counts are computed by the database. Ordering happens in memory + * because no single orderBy can express "posts plus articles"; what is + * fetched is two integers and a name per tag, and the candidate set is + * bounded by the window, so this is a different order of magnitude from + * the previous version which loaded every matching post row. + * + * @param params - The limit of tags to retrieve and the time window in days. + * @returns A promise that resolves to an array of trending tags. */ async findTrending(params: TrendingParams): Promise { const { limit, windowDays } = params; @@ -35,16 +45,26 @@ export class PrismaTagRepository implements ITagRepository { Date.now() - windowDays * 24 * 60 * 60 * 1000, ); + const postWindow = { createdAt: { gte: windowStart } }; + const articleWindow = { + status: ArticleStatus.PUBLISHED, + publishedAt: { gte: windowStart }, + }; + const rawTags = await this.prisma.tag.findMany({ where: { - posts: { - some: { createdAt: { gte: windowStart } }, - }, + OR: [ + { posts: { some: postWindow } }, + { articles: { some: articleWindow } }, + ], }, - include: { - posts: { - where: { createdAt: { gte: windowStart } }, - select: { id: true }, + select: { + name: true, + _count: { + select: { + posts: { where: postWindow }, + articles: { where: articleWindow }, + }, }, }, }); @@ -52,39 +72,61 @@ export class PrismaTagRepository implements ITagRepository { return rawTags .map((tag): TrendItem => ({ tag: tag.name, - postCount: tag.posts.length, + postCount: tag._count.posts, + articleCount: tag._count.articles, category: null, })) - .sort((a, b) => b.postCount - a.postCount) + .sort( + (a, b) => + b.postCount + + b.articleCount - + (a.postCount + a.articleCount), + ) .slice(0, limit); } /** * Searches for tags by name using a case-insensitive substring match. - * Results are ordered by the total number of posts associated with the tag, descending. + * + * Results are ordered by posts and published articles combined, which no + * single Prisma orderBy can express, so the ranking is applied to the + * matching set. That set is bounded by the search term. * * @param query - The search string to match against tag names. * @param limit - The maximum number of results to return (defaults to 10). - * @returns A promise that resolves to an array of matching tags (TagSearchItem). + * @returns A promise that resolves to an array of matching tags. */ async search(query: string, limit = 10): Promise { const rawTags = await this.prisma.tag.findMany({ where: { name: { contains: query, mode: "insensitive" }, }, - include: { - _count: { select: { posts: true } }, - }, - orderBy: { - posts: { _count: "desc" }, + select: { + name: true, + _count: { + select: { + posts: true, + articles: { + where: { status: ArticleStatus.PUBLISHED }, + }, + }, + }, }, - take: limit, }); - return rawTags.map((tag): TagSearchItem => ({ - name: tag.name, - postCount: tag._count.posts, - category: null, - })); + return rawTags + .map((tag): TagSearchItem => ({ + name: tag.name, + postCount: tag._count.posts, + articleCount: tag._count.articles, + category: null, + })) + .sort( + (a, b) => + b.postCount + + b.articleCount - + (a.postCount + a.articleCount), + ) + .slice(0, limit); } } diff --git a/tests/e2e/article/interactions.test.ts b/tests/e2e/article/interactions.test.ts new file mode 100644 index 0000000..ed96d3b --- /dev/null +++ b/tests/e2e/article/interactions.test.ts @@ -0,0 +1,334 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { request, authRequest, parseBody } from "../setup"; + +interface ArticleData { + id: string; + slug: string; + likeCount: number; + isLiked: boolean; + isBookmarked: boolean; +} + +type ArticleEnvelope = { data: ArticleData }; +type ListEnvelope = { data: ArticleData[]; meta: { total: number } }; + +const ts = Date.now(); +const author = { + email: `int-author-${ts}@article-interactions-test.com`, + password: "password123", + username: `ia${ts}`, +}; +const reader = { + email: `int-reader-${ts}@article-interactions-test.com`, + password: "password123", + username: `ir${ts}`, +}; + +let authorToken: string; +let readerToken: string; +let articleId: string; +let articleSlug: string; +let draftId: string; + +/** + * Registers a user and returns their access token. + */ +async function login(user: { + email: string; + password: string; + username: string; +}): Promise { + await request({ method: "POST", url: "/auth/register", payload: user }); + const response = await request({ + method: "POST", + url: "/auth/login", + payload: { identifier: user.email, password: user.password }, + }); + return parseBody<{ data: { accessToken: string } }>(response).data + .accessToken; +} + +/** + * Reads the article back as the given viewer. + */ +async function readArticle(token?: string): Promise { + const response = token + ? await authRequest(token, { + method: "GET", + url: `/articles/${articleSlug}`, + }) + : await request({ method: "GET", url: `/articles/${articleSlug}` }); + return parseBody(response).data; +} + +beforeAll(async () => { + authorToken = await login(author); + readerToken = await login(reader); + + const created = parseBody( + await authRequest(authorToken, { + method: "POST", + url: "/articles", + payload: { + title: `Interactive article ${ts}`, + body: "Body prose for the interaction tests.", + }, + }), + ).data; + articleId = created.id; + articleSlug = created.slug; + + await authRequest(authorToken, { + method: "POST", + url: `/articles/${articleId}/publish`, + }); + + draftId = parseBody( + await authRequest(authorToken, { + method: "POST", + url: "/articles", + payload: { + title: `Draft for interactions ${ts}`, + body: "Body prose.", + }, + }), + ).data.id; +}); + +describe("POST and DELETE /articles/:id/like", () => { + it("should like an article and reflect it for that viewer only", async () => { + const response = await authRequest(readerToken, { + method: "POST", + url: `/articles/${articleId}/like`, + }); + + expect(response.statusCode).toBe(200); + + const asReader = await readArticle(readerToken); + const asGuest = await readArticle(); + + expect(asReader.isLiked).toBe(true); + expect(asReader.likeCount).toBe(1); + expect(asGuest.isLiked).toBe(false); + expect(asGuest.likeCount).toBe(1); + }); + + it("should be idempotent", async () => { + await authRequest(readerToken, { + method: "POST", + url: `/articles/${articleId}/like`, + }); + await authRequest(readerToken, { + method: "POST", + url: `/articles/${articleId}/like`, + }); + + expect((await readArticle(readerToken)).likeCount).toBe(1); + }); + + it("should remove the like", async () => { + const response = await authRequest(readerToken, { + method: "DELETE", + url: `/articles/${articleId}/like`, + }); + + expect(response.statusCode).toBe(200); + + const after = await readArticle(readerToken); + expect(after.isLiked).toBe(false); + expect(after.likeCount).toBe(0); + }); + + it("should not drive the count negative when unliking twice", async () => { + await authRequest(readerToken, { + method: "DELETE", + url: `/articles/${articleId}/like`, + }); + + expect((await readArticle()).likeCount).toBe(0); + }); + + it("should hide an unpublished article behind a 404", async () => { + const response = await authRequest(readerToken, { + method: "POST", + url: `/articles/${draftId}/like`, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should require authentication", async () => { + const response = await request({ + method: "POST", + url: `/articles/${articleId}/like`, + }); + + expect(response.statusCode).toBe(401); + }); +}); + +describe("POST and DELETE /articles/:id/bookmark", () => { + it("should bookmark an article for that viewer only", async () => { + const response = await authRequest(readerToken, { + method: "POST", + url: `/articles/${articleId}/bookmark`, + }); + + expect(response.statusCode).toBe(200); + expect((await readArticle(readerToken)).isBookmarked).toBe(true); + expect((await readArticle(authorToken)).isBookmarked).toBe(false); + }); + + it("should be idempotent", async () => { + const response = await authRequest(readerToken, { + method: "POST", + url: `/articles/${articleId}/bookmark`, + }); + + expect(response.statusCode).toBe(200); + expect((await readArticle(readerToken)).isBookmarked).toBe(true); + }); + + it("should remove the bookmark", async () => { + const response = await authRequest(readerToken, { + method: "DELETE", + url: `/articles/${articleId}/bookmark`, + }); + + expect(response.statusCode).toBe(200); + expect((await readArticle(readerToken)).isBookmarked).toBe(false); + }); + + it("should do nothing when removing a bookmark that was never made", async () => { + const response = await authRequest(authorToken, { + method: "DELETE", + url: `/articles/${articleId}/bookmark`, + }); + + expect(response.statusCode).toBe(200); + }); + + it("should refuse to bookmark an unpublished article", async () => { + const response = await authRequest(readerToken, { + method: "POST", + url: `/articles/${draftId}/bookmark`, + }); + + expect(response.statusCode).toBe(404); + }); +}); + +describe("tag trends with articles", () => { + it("should count a published article's tag and ignore a draft's", async () => { + const publishedTag = `trendpub${ts}`.slice(0, 30); + const draftTag = `trenddraft${ts}`.slice(0, 30); + + const published = parseBody( + await authRequest(authorToken, { + method: "POST", + url: "/articles", + payload: { + title: `Trending article ${ts}`, + body: "Body prose.", + tags: [publishedTag], + }, + }), + ).data; + await authRequest(authorToken, { + method: "POST", + url: `/articles/${published.id}/publish`, + }); + + await authRequest(authorToken, { + method: "POST", + url: "/articles", + payload: { + title: `Draft trending article ${ts}`, + body: "Body prose.", + tags: [draftTag], + }, + }); + + const response = await request({ + method: "GET", + url: "/tags/trends?limit=50", + }); + const body = parseBody<{ + data: { + trends: Array<{ + tag: string; + postCount: number; + articleCount: number; + }>; + }; + }>(response); + + expect(response.statusCode).toBe(200); + + const entry = body.data.trends.find((t) => t.tag === publishedTag); + expect(entry).toBeDefined(); + expect(entry?.articleCount).toBe(1); + expect(entry?.postCount).toBe(0); + + expect(body.data.trends.map((t) => t.tag)).not.toContain(draftTag); + }); + + it("should expose articleCount from tag search", async () => { + const searchTag = `searchpub${ts}`.slice(0, 30); + + const article = parseBody( + await authRequest(authorToken, { + method: "POST", + url: "/articles", + payload: { + title: `Searchable article ${ts}`, + body: "Body prose.", + tags: [searchTag], + }, + }), + ).data; + await authRequest(authorToken, { + method: "POST", + url: `/articles/${article.id}/publish`, + }); + + const response = await request({ + method: "GET", + url: `/tags/search?q=${searchTag}`, + }); + const body = parseBody<{ + data: Array<{ name: string; articleCount: number }>; + }>(response); + + expect(response.statusCode).toBe(200); + expect(body.data[0]?.name).toBe(searchTag); + expect(body.data[0]?.articleCount).toBe(1); + }); +}); + +describe("bookmarked article listing", () => { + it("should not expose bookmarks across users", async () => { + await authRequest(readerToken, { + method: "POST", + url: `/articles/${articleId}/bookmark`, + }); + + const forReader = await authRequest(readerToken, { + method: "GET", + url: "/articles?limit=50", + }); + const entry = parseBody(forReader).data.find( + (a) => a.id === articleId, + ); + expect(entry?.isBookmarked).toBe(true); + + const forAuthor = await authRequest(authorToken, { + method: "GET", + url: "/articles?limit=50", + }); + const sameForAuthor = parseBody(forAuthor).data.find( + (a) => a.id === articleId, + ); + expect(sameForAuthor?.isBookmarked).toBe(false); + }); +}); diff --git a/tests/integration/persistence/repositories/prisma-tag-articles.repository.test.ts b/tests/integration/persistence/repositories/prisma-tag-articles.repository.test.ts new file mode 100644 index 0000000..490acf9 --- /dev/null +++ b/tests/integration/persistence/repositories/prisma-tag-articles.repository.test.ts @@ -0,0 +1,215 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { PrismaClient } from "../../../../src/generated/prisma/client"; +import { PrismaTagRepository } from "../../../../src/infrastructure/persistence/repositories/prisma-tag.repository"; +import { PrismaArticleRepository } from "../../../../src/infrastructure/persistence/repositories/prisma-article.repository"; +import { PrismaUserRepository } from "../../../../src/infrastructure/persistence/repositories/prisma-user.repository"; +import { PrismaPostRepository } from "../../../../src/infrastructure/persistence/repositories/prisma-post.repository"; +import { Article } from "../../../../src/core/domain/entities/article.entity"; +import { Post } from "../../../../src/core/domain/entities/post.entity"; +import { PostType } from "../../../../src/core/domain/enums/post-type.enum"; +import { createPrismaClient } from "../../helpers/setup"; + +const EMAIL_DOMAIN = "@tag-article-test.com"; + +const PUBLISHED_TAG = "artpublishedtag"; +const DRAFT_TAG = "artdrafttag"; +const ARCHIVED_TAG = "artarchivedtag"; +const SHARED_TAG = "artsharedtag"; + +/** + * The article half of the tag repository. The post-only suite in + * prisma-tag.repository.test.ts is left untouched so it doubles as the + * regression gate for the rewrite. + */ +describe("PrismaTagRepository with articles (integration)", () => { + let prisma: PrismaClient; + let tagRepo: PrismaTagRepository; + let articleRepo: PrismaArticleRepository; + let userId: string; + let suffix = 0; + + const nextSuffix = (): string => (++suffix).toString(16).padStart(8, "0"); + + /** + * Creates an article carrying the given tags, optionally publishing it. + */ + const makeArticle = async ( + title: string, + tags: string[], + state: "draft" | "published" | "archived", + ): Promise => { + const article = await articleRepo.create( + Article.create({ + title, + body: "Body prose for the tag tests.", + authorId: userId, + slugSuffix: nextSuffix(), + tags, + }), + ); + + if (state === "draft") return; + + article.publish(); + if (state === "archived") article.archive(); + await articleRepo.update(article); + }; + + beforeAll(async () => { + prisma = createPrismaClient(); + tagRepo = new PrismaTagRepository(prisma); + articleRepo = new PrismaArticleRepository(prisma); + + const user = await new PrismaUserRepository(prisma, { + gracePeriodDays: 30, + }).create({ + email: `tagarticle${EMAIL_DOMAIN}`, + username: "tagarticle_user", + passwordHash: "hashed", + }); + userId = user.id; + + await makeArticle("Published tagged article", [PUBLISHED_TAG], "published"); + await makeArticle("Draft tagged article", [DRAFT_TAG], "draft"); + await makeArticle("Archived tagged article", [ARCHIVED_TAG], "archived"); + + // One tag used by both a post and a published article, to prove the + // two counts are reported separately and ranked together. + await makeArticle("Shared tag article", [SHARED_TAG], "published"); + await new PrismaPostRepository(prisma).create( + Post.create( + `A post about #${SHARED_TAG}`, + PostType.COMMUNITY, + userId, + ), + ); + }); + + afterAll(async () => { + await prisma.article.deleteMany({ where: { authorId: userId } }); + await prisma.post.deleteMany({ where: { authorId: userId } }); + await prisma.tag.deleteMany({ + where: { + name: { + in: [PUBLISHED_TAG, DRAFT_TAG, ARCHIVED_TAG, SHARED_TAG], + }, + }, + }); + await prisma.user.deleteMany({ + where: { email: { contains: EMAIL_DOMAIN } }, + }); + await prisma.$disconnect(); + }); + + describe("findTrending()", () => { + it("should include a tag used only by a published article", async () => { + const trends = await tagRepo.findTrending({ + limit: 100, + windowDays: 7, + }); + const entry = trends.find((t) => t.tag === PUBLISHED_TAG); + + expect(entry).toBeDefined(); + expect(entry?.articleCount).toBe(1); + expect(entry?.postCount).toBe(0); + }); + + it("should never let a draft push its tag into the trends", async () => { + const trends = await tagRepo.findTrending({ + limit: 100, + windowDays: 7, + }); + + expect(trends.map((t) => t.tag)).not.toContain(DRAFT_TAG); + }); + + it("should exclude an archived article", async () => { + const trends = await tagRepo.findTrending({ + limit: 100, + windowDays: 7, + }); + + expect(trends.map((t) => t.tag)).not.toContain(ARCHIVED_TAG); + }); + + it("should report post and article counts separately", async () => { + const trends = await tagRepo.findTrending({ + limit: 100, + windowDays: 7, + }); + const entry = trends.find((t) => t.tag === SHARED_TAG); + + expect(entry?.postCount).toBe(1); + expect(entry?.articleCount).toBe(1); + }); + + it("should rank by the two counts combined", async () => { + const trends = await tagRepo.findTrending({ + limit: 100, + windowDays: 7, + }); + const shared = trends.findIndex((t) => t.tag === SHARED_TAG); + const publishedOnly = trends.findIndex( + (t) => t.tag === PUBLISHED_TAG, + ); + + expect(shared).toBeGreaterThanOrEqual(0); + expect(publishedOnly).toBeGreaterThanOrEqual(0); + expect(shared).toBeLessThan(publishedOnly); + }); + + it("should honour the limit", async () => { + const trends = await tagRepo.findTrending({ + limit: 1, + windowDays: 7, + }); + + expect(trends).toHaveLength(1); + }); + + it("should exclude articles published before the window", async () => { + const trends = await tagRepo.findTrending({ + limit: 100, + windowDays: 0, + }); + + expect(trends.map((t) => t.tag)).not.toContain(PUBLISHED_TAG); + }); + }); + + describe("search()", () => { + it("should report the article count for a matching tag", async () => { + const results = await tagRepo.search(PUBLISHED_TAG, 10); + + expect(results[0]?.name).toBe(PUBLISHED_TAG); + expect(results[0]?.articleCount).toBe(1); + expect(results[0]?.postCount).toBe(0); + }); + + it("should not count a draft article", async () => { + const results = await tagRepo.search(DRAFT_TAG, 10); + const entry = results.find((t) => t.name === DRAFT_TAG); + + // The tag row exists because the draft created it, but it must not + // be presented as used. + expect(entry?.articleCount ?? 0).toBe(0); + }); + + it("should rank by posts and articles combined", async () => { + const results = await tagRepo.search("art", 50); + const shared = results.findIndex((t) => t.name === SHARED_TAG); + const publishedOnly = results.findIndex( + (t) => t.name === PUBLISHED_TAG, + ); + + expect(shared).toBeGreaterThanOrEqual(0); + expect(shared).toBeLessThan(publishedOnly); + }); + + it("should honour the limit", async () => { + const results = await tagRepo.search("art", 1); + + expect(results).toHaveLength(1); + }); + }); +}); diff --git a/tests/unit/core/use-cases/article/article-interactions.usecase.test.ts b/tests/unit/core/use-cases/article/article-interactions.usecase.test.ts new file mode 100644 index 0000000..55ba582 --- /dev/null +++ b/tests/unit/core/use-cases/article/article-interactions.usecase.test.ts @@ -0,0 +1,237 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LikeArticleUseCase } from "@core/use-cases/article/like-article"; +import { UnlikeArticleUseCase } from "@core/use-cases/article/unlike-article"; +import { SaveArticleBookmarkUseCase } from "@core/use-cases/article/save-article-bookmark"; +import { RemoveArticleBookmarkUseCase } from "@core/use-cases/article/remove-article-bookmark"; +import type { + TransactionPort, + TransactionContext, +} from "@core/ports/services/transaction.port"; +import type { RealtimePort } from "@core/ports/services/realtime.port"; +import type { IArticleRepository } from "@core/ports/repositories/article.repository"; +import type { IArticleLikeRepository } from "@core/ports/repositories/article-like.repository"; +import type { IArticleBookmarkRepository } from "@core/ports/repositories/article-bookmark.repository"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import { ArticleStatus } from "@core/domain/enums/article-status.enum"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; +import { NotFoundError } from "@core/errors"; +import { buildArticle } from "../../../helpers/mock-factories"; + +const AUTHOR = "article-author-1"; +const READER = "reader-1"; +const ARTICLE = "article-1"; + +describe("article like and bookmark use cases", () => { + let articleRepo: Pick; + let likeRepo: IArticleLikeRepository; + let bookmarkRepo: IArticleBookmarkRepository; + let notificationRepo: Pick; + let realtimeSvc: Pick; + let transactionSvc: Pick; + + beforeEach(() => { + articleRepo = { + findById: vi.fn().mockResolvedValue( + buildArticle({ + status: ArticleStatus.PUBLISHED, + author: { id: AUTHOR }, + }), + ), + }; + likeRepo = { + like: vi.fn().mockResolvedValue(undefined), + unlike: vi.fn().mockResolvedValue(undefined), + isLiked: vi.fn().mockResolvedValue(false), + incrementLikeCount: vi.fn().mockResolvedValue(undefined), + decrementLikeCount: vi.fn().mockResolvedValue(undefined), + }; + bookmarkRepo = { + save: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + isBookmarked: vi.fn().mockResolvedValue(false), + }; + notificationRepo = { create: vi.fn() }; + realtimeSvc = { emitToUser: vi.fn() }; + transactionSvc = { + runInTransaction: vi.fn().mockImplementation(async (work) => + work({ + articleRepository: articleRepo as IArticleRepository, + articleLikeRepository: likeRepo, + notificationRepository: + notificationRepo as INotificationRepository, + } as TransactionContext), + ), + }; + }); + + describe("LikeArticleUseCase", () => { + let useCase: LikeArticleUseCase; + + beforeEach(() => { + useCase = new LikeArticleUseCase( + transactionSvc as TransactionPort, + realtimeSvc as RealtimePort, + ); + }); + + it("should record the like and bump the count", async () => { + await useCase.execute({ articleId: ARTICLE, userId: READER }); + + expect(likeRepo.like).toHaveBeenCalledWith(ARTICLE, READER); + expect(likeRepo.incrementLikeCount).toHaveBeenCalledWith(ARTICLE); + }); + + it("should be idempotent when already liked", async () => { + vi.mocked(likeRepo.isLiked).mockResolvedValue(true); + + await useCase.execute({ articleId: ARTICLE, userId: READER }); + + expect(likeRepo.like).not.toHaveBeenCalled(); + expect(likeRepo.incrementLikeCount).not.toHaveBeenCalled(); + }); + + it("should notify the author with a deep-linkable reference", async () => { + await useCase.execute({ articleId: ARTICLE, userId: READER }); + + expect(notificationRepo.create).toHaveBeenCalledTimes(1); + expect(realtimeSvc.emitToUser).toHaveBeenCalledWith( + AUTHOR, + "new-notification", + expect.objectContaining({ + type: NotificationType.LIKE, + issuerId: READER, + articleId: ARTICLE, + referenceId: ARTICLE, + }), + ); + }); + + it("should not notify when the author likes their own article", async () => { + await useCase.execute({ articleId: ARTICLE, userId: AUTHOR }); + + expect(notificationRepo.create).not.toHaveBeenCalled(); + }); + + it("should hide an unpublished article behind a 404", async () => { + for (const status of [ + ArticleStatus.DRAFT, + ArticleStatus.ARCHIVED, + ]) { + vi.mocked(articleRepo.findById).mockResolvedValue( + buildArticle({ status, author: { id: AUTHOR } }), + ); + + await expect( + useCase.execute({ articleId: ARTICLE, userId: READER }), + ).rejects.toThrow(NotFoundError); + } + + expect(likeRepo.like).not.toHaveBeenCalled(); + }); + + it("should throw NotFoundError when the article does not exist", async () => { + vi.mocked(articleRepo.findById).mockResolvedValue(null); + + await expect( + useCase.execute({ articleId: ARTICLE, userId: READER }), + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("UnlikeArticleUseCase", () => { + let useCase: UnlikeArticleUseCase; + + beforeEach(() => { + useCase = new UnlikeArticleUseCase( + transactionSvc as TransactionPort, + ); + }); + + it("should remove the like and drop the count", async () => { + vi.mocked(likeRepo.isLiked).mockResolvedValue(true); + + await useCase.execute({ articleId: ARTICLE, userId: READER }); + + expect(likeRepo.unlike).toHaveBeenCalledWith(ARTICLE, READER); + expect(likeRepo.decrementLikeCount).toHaveBeenCalledWith(ARTICLE); + }); + + it("should not let the count go negative when never liked", async () => { + vi.mocked(likeRepo.isLiked).mockResolvedValue(false); + + await useCase.execute({ articleId: ARTICLE, userId: READER }); + + expect(likeRepo.unlike).not.toHaveBeenCalled(); + expect(likeRepo.decrementLikeCount).not.toHaveBeenCalled(); + }); + }); + + describe("SaveArticleBookmarkUseCase", () => { + let useCase: SaveArticleBookmarkUseCase; + + beforeEach(() => { + useCase = new SaveArticleBookmarkUseCase( + articleRepo as IArticleRepository, + bookmarkRepo, + ); + }); + + it("should store the bookmark", async () => { + await useCase.execute({ articleId: ARTICLE, userId: READER }); + + expect(bookmarkRepo.save).toHaveBeenCalledWith(ARTICLE, READER); + }); + + it("should be idempotent", async () => { + vi.mocked(bookmarkRepo.isBookmarked).mockResolvedValue(true); + + await useCase.execute({ articleId: ARTICLE, userId: READER }); + + expect(bookmarkRepo.save).not.toHaveBeenCalled(); + }); + + it("should refuse to bookmark an unpublished article", async () => { + vi.mocked(articleRepo.findById).mockResolvedValue( + buildArticle({ + status: ArticleStatus.DRAFT, + author: { id: AUTHOR }, + }), + ); + + await expect( + useCase.execute({ articleId: ARTICLE, userId: READER }), + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("RemoveArticleBookmarkUseCase", () => { + let useCase: RemoveArticleBookmarkUseCase; + + beforeEach(() => { + useCase = new RemoveArticleBookmarkUseCase(bookmarkRepo); + }); + + it("should remove an existing bookmark", async () => { + vi.mocked(bookmarkRepo.isBookmarked).mockResolvedValue(true); + + await useCase.execute({ articleId: ARTICLE, userId: READER }); + + expect(bookmarkRepo.remove).toHaveBeenCalledWith(ARTICLE, READER); + }); + + it("should do nothing when there is no bookmark", async () => { + await useCase.execute({ articleId: ARTICLE, userId: READER }); + + expect(bookmarkRepo.remove).not.toHaveBeenCalled(); + }); + + it("should not load the article, so an archived one can still be unbookmarked", async () => { + vi.mocked(bookmarkRepo.isBookmarked).mockResolvedValue(true); + + await useCase.execute({ articleId: ARTICLE, userId: READER }); + + expect(articleRepo.findById).not.toHaveBeenCalled(); + expect(bookmarkRepo.remove).toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/core/use-cases/comment/get-post-comments.usecase.test.ts b/tests/unit/core/use-cases/comment/get-comments.usecase.test.ts similarity index 96% rename from tests/unit/core/use-cases/comment/get-post-comments.usecase.test.ts rename to tests/unit/core/use-cases/comment/get-comments.usecase.test.ts index b18c6be..3a027f6 100644 --- a/tests/unit/core/use-cases/comment/get-post-comments.usecase.test.ts +++ b/tests/unit/core/use-cases/comment/get-comments.usecase.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { GetPostCommentsUseCase } from "@core/use-cases/comment/get-post-comments/get-post-comments.usecase"; +import { GetCommentsUseCase } from "@core/use-cases/comment/get-comments/get-comments.usecase"; import { NotFoundError } from "@core/errors"; import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; import type { IPostRepository } from "@core/ports/repositories/post.repository"; @@ -13,8 +13,8 @@ const buildPost = (): Post => ({ id: "post-1" }) as unknown as Post; const POST_TARGET = { type: "POST" as const, id: "post-1" }; const ARTICLE_TARGET = { type: "ARTICLE" as const, id: "article-1" }; -describe("GetPostCommentsUseCase", () => { - let useCase: GetPostCommentsUseCase; +describe("GetCommentsUseCase", () => { + let useCase: GetCommentsUseCase; let postRepo: Pick; let articleRepo: Pick; let commentRepo: Pick; @@ -24,7 +24,7 @@ describe("GetPostCommentsUseCase", () => { articleRepo = { findById: vi.fn() }; commentRepo = { findTopLevelByTarget: vi.fn().mockResolvedValue([]) }; - useCase = new GetPostCommentsUseCase( + useCase = new GetCommentsUseCase( commentRepo as ICommentRepository, postRepo as IPostRepository, articleRepo as IArticleRepository, From 30e5e6f0fa852c117f9aca06ea402e9eea025d69 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 25 Aug 2026 03:36:27 +0300 Subject: [PATCH 2/2] fix(tag): carry articleCount through the search use case E2E caught this: /tags/search answered 500 for any query that matched something, while an empty result stayed 200. SearchTagsUseCase remaps repository rows into its own DTO, and that DTO was not updated alongside the repository and the response schema, so articleCount was dropped between them. The schema declares it required, and fast-json-stringify fails serialization on a missing required property rather than omitting it - hence a 500 only when there was a row to serialize. GetTrendsUseCase passes TrendItem straight through, which is why trends was unaffected. The existing unit test did not catch it because its fixtures predate the field: toEqual ignores properties whose value is undefined, so tag.articleCount being undefined compared equal. The fixtures now carry the field and a test asserts the exact key set of each returned row, so a dropped field fails here instead of in CI. Also versions the trends cache key. Entries written before articleCount existed would otherwise be served for five minutes after a deploy and fail the same way, since the key was unchanged. Co-Authored-By: Claude Opus 5 --- .../post/get-trends/get-trends.usecase.ts | 5 +++- .../search-tag/search-tag-usecase.output.ts | 6 ++++ .../tag/search-tag/search-tag.usecase.ts | 1 + .../use-cases/post/get-trends.usecase.test.ts | 6 ++-- .../use-cases/tag/search-tag.usecase.test.ts | 29 ++++++++++++++++--- 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/core/use-cases/post/get-trends/get-trends.usecase.ts b/src/core/use-cases/post/get-trends/get-trends.usecase.ts index fdfab35..062c09b 100644 --- a/src/core/use-cases/post/get-trends/get-trends.usecase.ts +++ b/src/core/use-cases/post/get-trends/get-trends.usecase.ts @@ -17,7 +17,10 @@ export class GetTrendsUseCase { async execute(input: GetTrendsInput): Promise { const limit = input.limit ?? 10; - const cacheKey = `trends:top:limit:${limit}:window:${TREND_WINDOW_DAYS}`; + // Versioned so entries cached before articleCount existed are not + // served after a deploy: the response schema requires the field, and a + // stale entry without it fails serialization rather than degrading. + const cacheKey = `trends:v2:top:limit:${limit}:window:${TREND_WINDOW_DAYS}`; const cached = await this.cacheService.get(cacheKey); if (cached) { diff --git a/src/core/use-cases/tag/search-tag/search-tag-usecase.output.ts b/src/core/use-cases/tag/search-tag/search-tag-usecase.output.ts index 4e57c38..04b9b2d 100644 --- a/src/core/use-cases/tag/search-tag/search-tag-usecase.output.ts +++ b/src/core/use-cases/tag/search-tag/search-tag-usecase.output.ts @@ -1,5 +1,11 @@ export interface SearchTagOutput { name: string; + + /** Posts carrying this tag */ postCount: number; + + /** Published articles carrying this tag */ + articleCount: number; + category: string | null; } diff --git a/src/core/use-cases/tag/search-tag/search-tag.usecase.ts b/src/core/use-cases/tag/search-tag/search-tag.usecase.ts index 5176b02..5d93efd 100644 --- a/src/core/use-cases/tag/search-tag/search-tag.usecase.ts +++ b/src/core/use-cases/tag/search-tag/search-tag.usecase.ts @@ -19,6 +19,7 @@ export class SearchTagsUseCase { return tags.map((tag) => ({ name: tag.name, postCount: tag.postCount, + articleCount: tag.articleCount, category: tag.category, })); } diff --git a/tests/unit/core/use-cases/post/get-trends.usecase.test.ts b/tests/unit/core/use-cases/post/get-trends.usecase.test.ts index 6fe111e..b14f3e6 100644 --- a/tests/unit/core/use-cases/post/get-trends.usecase.test.ts +++ b/tests/unit/core/use-cases/post/get-trends.usecase.test.ts @@ -55,7 +55,7 @@ describe("GetTrendsUseCase", () => { await useCase.execute({ limit: 5 }); expect(cacheService.get).toHaveBeenCalledWith( - "trends:top:limit:5:window:7", + "trends:v2:top:limit:5:window:7", ); }); @@ -63,7 +63,7 @@ describe("GetTrendsUseCase", () => { await useCase.execute({}); expect(cacheService.get).toHaveBeenCalledWith( - "trends:top:limit:10:window:7", + "trends:v2:top:limit:10:window:7", ); expect(tagRepository.findTrending).toHaveBeenCalledWith( expect.objectContaining({ limit: 10, windowDays: 7 }), @@ -74,7 +74,7 @@ describe("GetTrendsUseCase", () => { await useCase.execute({ limit: 10 }); expect(cacheService.set).toHaveBeenCalledWith( - "trends:top:limit:10:window:7", + "trends:v2:top:limit:10:window:7", JSON.stringify(mockTrends), 300, ); diff --git a/tests/unit/core/use-cases/tag/search-tag.usecase.test.ts b/tests/unit/core/use-cases/tag/search-tag.usecase.test.ts index de1e91d..48c8ee6 100644 --- a/tests/unit/core/use-cases/tag/search-tag.usecase.test.ts +++ b/tests/unit/core/use-cases/tag/search-tag.usecase.test.ts @@ -3,8 +3,8 @@ import { SearchTagsUseCase } from "@core/use-cases/tag/search-tag"; import type { ITagRepository } from "@core/ports/repositories/tag.repository"; const mockTags = [ - { name: "typescript", postCount: 42, category: "programming" }, - { name: "typeorm", postCount: 10, category: null }, + { name: "typescript", postCount: 42, articleCount: 3, category: "programming" }, + { name: "typeorm", postCount: 10, articleCount: 0, category: null }, ]; describe("SearchTagsUseCase", () => { @@ -42,11 +42,32 @@ describe("SearchTagsUseCase", () => { const result = await useCase.execute({ query: "type", limit: 10 }); expect(result).toEqual([ - { name: "typescript", postCount: 42, category: "programming" }, - { name: "typeorm", postCount: 10, category: null }, + { name: "typescript", postCount: 42, articleCount: 3, category: "programming" }, + { name: "typeorm", postCount: 10, articleCount: 0, category: null }, ]); }); + it("should carry articleCount through to the output", async () => { + // The response schema declares articleCount as required, and + // fast-json-stringify fails serialization when a required property is + // missing. This use case remaps repository rows into its own DTO, so a + // field dropped here surfaces as a 500 rather than a type error. + vi.mocked(tagRepository.search).mockResolvedValue(mockTags); + + const result = await useCase.execute({ query: "type" }); + + expect(result[0]).toHaveProperty("articleCount", 3); + expect(result[1]).toHaveProperty("articleCount", 0); + for (const item of result) { + expect(Object.keys(item).sort()).toEqual([ + "articleCount", + "category", + "name", + "postCount", + ]); + } + }); + it("should pass limit to repository", async () => { await useCase.execute({ query: "ts", limit: 20 });