diff --git a/src/app.ts b/src/app.ts index 1d944aa3..9a4a6509 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 00000000..2e731be8 --- /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 00000000..ced04fa0 --- /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 3f055cc9..f432595a 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 40b7ba2c..606d46e8 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 23acac2c..ae33a342 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 00000000..bc82ee09 --- /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 00000000..294eaeb0 --- /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 00000000..4fa06e33 --- /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 00000000..9b6bf248 --- /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 00000000..1c5a855b --- /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 00000000..92e7b17e --- /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 00000000..6b39173d --- /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 00000000..fe58d73f --- /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 00000000..8fda5761 --- /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 00000000..dec0a502 --- /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 00000000..f7ae4481 --- /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 00000000..b2f72906 --- /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 78e95a99..43c9d9b7 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 ae82e070..1c93d8b0 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 00000000..6566af84 --- /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 eb5cd1eb..00000000 --- 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/core/use-cases/post/get-trends/get-trends.usecase.ts b/src/core/use-cases/post/get-trends/get-trends.usecase.ts index fdfab358..062c09bd 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 4e57c380..04b9b2d7 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 5176b022..5d93efd9 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/src/http/controllers/article.controller.ts b/src/http/controllers/article.controller.ts index 62cf230a..6cf87d66 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 00fbfbdf..15ad6935 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 ba019954..5372d7ec 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 c9c0a2e0..92d4d13f 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 00000000..fc5c4f9a --- /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 4437320c..4fd49f54 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 fbc35fe7..b98fa8f2 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 31a5d78d..f759b992 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 00000000..61297de4 --- /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 00000000..ef037a26 --- /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 3a69612a..c8aa164d 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 65369376..a267f792 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 00000000..ed96d3b7 --- /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 00000000..490acf94 --- /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 00000000..55ba582f --- /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 b18c6bed..3a027f6b 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, 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 6fe111ea..b14f3e64 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 de1e91d2..48c8ee60 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 });