From e6624959c8e526c8647caa9fd3d124598665fc18 Mon Sep 17 00:00:00 2001 From: unknown Date: Tue, 25 Aug 2026 02:50:15 +0300 Subject: [PATCH] feat(article): add comments on articles Stage 5 of the article feature, and the riskiest: comments become polymorphic so an article thread reuses the post comment machinery instead of cloning it. POST /api/v1/articles/:articleId/comments GET /api/v1/articles/:articleId/comments Those are the only two new endpoints. Replies, comment detail, likes, bookmarks and deletion keep working through the existing /comments/:commentId routes, because article comments live in the same table. A separate ArticleComment model would have needed its own like and bookmark tables, two repositories, a mapper and seven duplicated use cases, and every future comment fix applied twice. Comment.postId becomes nullable and articleId appears beside it. Two nullable columns would on their own permit a comment attached to nothing, or to both, so a CHECK constraint holds the invariant. Prisma cannot express one, so it is written by hand and covered by integration tests that fail if a future migrate dev regenerates the table without it. DROP NOT NULL and adding a nullable column are metadata-only in Postgres, so the table is not rewritten. Callers branch on comment.target rather than on which id is null, keeping the two-column representation inside the entity. Two things that had to move in lockstep with the column: - CommentResponse.postId and CommentItemSchema.postId are now nullable. fast-json-stringify coerces a value that does not match its schema instead of rejecting it, so leaving the schema promising a string would have emitted a wrong postId for every article comment rather than failing loudly. - delete-comment decremented Post.commentCount unconditionally. It now branches on the target; posts keep today's behaviour, articles have no counter to maintain. Articles derive commentCount from a relation count. A counter column would drift the way posts.comment_count does, since the reply subtree is removed by a database cascade the application never sees - an e2e test pins that by deleting a parent with two replies and asserting the count falls by three. Writing the e2e suite surfaced a leak in the first version of this change: a stranger commenting on someone else's draft got 409, which confirms the draft exists. Visibility is now checked first, so a stranger gets 404 and only the author is told their own article is not published yet. Notifications: article replies use COMMENT_REPLY, which existed in the Prisma enum but not in the TS one. Post replies keep using COMMENT so their behaviour is unchanged. referenceId is populated for the first time. The existing tests/e2e/comment suite is unchanged and passing, which is the regression gate for the post path. Co-Authored-By: Claude Opus 5 --- .../migration.sql | 27 ++ prisma/models/article.prisma | 4 +- prisma/models/post.prisma | 14 +- src/app.ts | 5 + src/core/domain/entities/comment.entity.ts | 87 +++- .../domain/enums/notification-type.enum.ts | 5 + .../interfaces/comment-props.interface.ts | 14 +- .../article/article-not-published.error.ts | 20 + src/core/errors/index.ts | 1 + .../ports/repositories/comment.repository.ts | 43 ++ src/core/ports/services/realtime.port.ts | 6 + .../create-comment-usecase.input.ts | 22 +- .../create-comment/create-comment.usecase.ts | 125 ++++-- .../delete-comment/delete-comment.usecase.ts | 9 +- .../get-post-comments.input.ts | 25 +- .../get-post-comments.usecase.ts | 60 ++- .../like-comment/like-comment.usecase.ts | 4 +- src/http/controllers/comment.controller.ts | 84 +++- .../routes/article/article-comment.routes.ts | 71 ++++ .../schemas/article/article-comment.schema.ts | 52 +++ .../schemas/comment/get-comment.schema.ts | 7 +- .../mappers/article-prisma.mapper.ts | 5 + .../mappers/comment-prisma.mapper.ts | 16 +- .../repositories/prisma-article.repository.ts | 2 + .../repositories/prisma-comment.repository.ts | 76 +++- tests/e2e/article/comments.test.ts | 383 ++++++++++++++++++ .../comment-target-constraint.test.ts | 152 +++++++ tests/unit/core/domain/enums/enums.test.ts | 8 +- .../create-article-comment.usecase.test.ts | 300 ++++++++++++++ .../comment/create-comment.usecase.test.ts | 18 +- .../comment/get-post-comments.usecase.test.ts | 186 +++++++-- tests/unit/helpers/mock-factories.ts | 1 + 32 files changed, 1699 insertions(+), 133 deletions(-) create mode 100644 prisma/migrations/20260824232642_comments_polymorphic_target/migration.sql create mode 100644 src/core/errors/article/article-not-published.error.ts create mode 100644 src/http/routes/article/article-comment.routes.ts create mode 100644 src/http/types/schemas/article/article-comment.schema.ts create mode 100644 tests/e2e/article/comments.test.ts create mode 100644 tests/integration/persistence/comment-target-constraint.test.ts create mode 100644 tests/unit/core/use-cases/comment/create-article-comment.usecase.test.ts diff --git a/prisma/migrations/20260824232642_comments_polymorphic_target/migration.sql b/prisma/migrations/20260824232642_comments_polymorphic_target/migration.sql new file mode 100644 index 00000000..ae5f74de --- /dev/null +++ b/prisma/migrations/20260824232642_comments_polymorphic_target/migration.sql @@ -0,0 +1,27 @@ +-- Comments can now hang off either a post or an article. +-- +-- Both columns are nullable so Prisma can model the two optional relations, +-- which on its own would allow a comment attached to nothing, or to both. +-- The CHECK below is what actually holds the invariant; Prisma cannot express +-- one, so it is written by hand and covered by an integration test that will +-- fail if a future migrate dev regenerates the table without it. +-- +-- DROP NOT NULL and adding a nullable column are metadata-only in Postgres, +-- so no table rewrite happens here. The two indexes are not concurrent because +-- Prisma runs migrations inside a transaction; if comments ever grows large +-- enough for that to matter, they should move to a separate manual step. + +-- AlterTable +ALTER TABLE "comments" ADD COLUMN "article_id" TEXT, +ALTER COLUMN "post_id" DROP NOT NULL; +-- CreateIndex +CREATE INDEX "comments_article_id_idx" ON "comments"("article_id"); +-- CreateIndex +CREATE INDEX "comments_parentId_idx" ON "comments"("parentId"); +-- AddForeignKey +ALTER TABLE "comments" ADD CONSTRAINT "comments_article_id_fkey" FOREIGN KEY ("article_id") REFERENCES "articles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Exactly one target, enforced in the database rather than only in code. +ALTER TABLE "comments" + ADD CONSTRAINT "comments_target_xor" + CHECK (num_nonnulls("post_id", "article_id") = 1); diff --git a/prisma/models/article.prisma b/prisma/models/article.prisma index f35640dc..bf46dd8b 100644 --- a/prisma/models/article.prisma +++ b/prisma/models/article.prisma @@ -28,9 +28,7 @@ model Article { tags Tag[] likes ArticleLike[] bookmarks ArticleBookmark[] - - // NOTE: `comments Comment[]` lands with the polymorphic comment migration. - // Comment has no articleId yet, so the back-relation cannot exist today. + comments Comment[] likeCount Int @default(0) @map("like_count") diff --git a/prisma/models/post.prisma b/prisma/models/post.prisma index 93fcfe19..a0bfb518 100644 --- a/prisma/models/post.prisma +++ b/prisma/models/post.prisma @@ -98,11 +98,15 @@ model Comment { content String @db.Text mediaUrls String[] @default([]) @map("media_urls") - postId String @map("post_id") - authorId String @map("author_id") + // Exactly one of postId / articleId is set, enforced by a CHECK constraint + // added in the migration: Prisma cannot express one. + postId String? @map("post_id") + articleId String? @map("article_id") + authorId String @map("author_id") - post Post @relation(fields: [postId], references: [id], onDelete: Cascade) - author User @relation(fields: [authorId], references: [id], onDelete: Cascade) + post Post? @relation(fields: [postId], references: [id], onDelete: Cascade) + article Article? @relation(fields: [articleId], references: [id], onDelete: Cascade) + author User @relation(fields: [authorId], references: [id], onDelete: Cascade) parentId String? parent Comment? @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade) @@ -117,6 +121,8 @@ model Comment { updatedAt DateTime @updatedAt @map("updated_at") @@index([postId]) + @@index([articleId]) + @@index([parentId]) @@index([authorId]) @@map("comments") } diff --git a/src/app.ts b/src/app.ts index 0492bd8f..1d944aa3 100644 --- a/src/app.ts +++ b/src/app.ts @@ -31,6 +31,7 @@ import { bookmarkRoutes } from "@routes/post/bookmark.routes"; 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"; /** * Main Application class responsible for orchestrating the Fastify server lifecycle. @@ -162,6 +163,10 @@ export class App { this.server.register(articleRoutes, { prefix: "/api/v1", }); + + this.server.register(articleCommentRoutes, { + prefix: "/api/v1", + }); } /** diff --git a/src/core/domain/entities/comment.entity.ts b/src/core/domain/entities/comment.entity.ts index 6af1e5e9..0bfc482d 100644 --- a/src/core/domain/entities/comment.entity.ts +++ b/src/core/domain/entities/comment.entity.ts @@ -1,8 +1,9 @@ /** - * Comment entity representing a user comment on a post + * Comment entity representing a user comment on a post or an article * Supports nested comments through optional parent-child relationships */ import type { CommentProps } from "@core/domain/interfaces/comment-props.interface"; +import type { CommentTarget } from "@core/ports/repositories/comment.repository"; export class Comment { /** @@ -37,12 +38,34 @@ export class Comment { /** * Gets the ID of the post this comment belongs to - * @returns The post ID + * @returns The post ID, or null when the comment belongs to an article */ - public get postId(): string { + public get postId(): string | null { return this.props.postId; } + /** + * Gets the ID of the article this comment belongs to + * @returns The article ID, or null when the comment belongs to a post + */ + public get articleId(): string | null { + return this.props.articleId; + } + + /** + * Gets what this comment is attached to. + * + * Callers branch on this rather than on which id happens to be null, so the + * two-nullable-columns representation stays inside the entity. + * + * @returns The comment target + */ + public get target(): CommentTarget { + return this.props.postId !== null + ? { type: "POST", id: this.props.postId } + : { type: "ARTICLE", id: this.props.articleId as string }; + } + /** * Gets the ID of the user who authored this comment * @returns The author user ID @@ -76,7 +99,11 @@ export class Comment { } /** - * Factory method to create a new comment + * Factory method to create a new comment on a post. + * + * Retained as a delegate to createForPost so existing post comment code + * and its tests keep working unchanged. + * * @param content - The text content of the comment * @param postId - The ID of the post this comment belongs to * @param authorId - The ID of the user who authored this comment @@ -90,10 +117,62 @@ export class Comment { authorId: string, parentId: string | null = null, mediaUrls: string[] = [], + ): Comment { + return Comment.createForPost( + content, + postId, + authorId, + parentId, + mediaUrls, + ); + } + + /** + * Factory method to create a comment on a post + * @param content - The text content of the comment + * @param postId - The ID of the post this comment belongs to + * @param authorId - The ID of the user who authored this comment + * @param parentId - Optional parent comment ID for nested comments + * @param mediaUrls - Optional array of media URLs attached to the comment + * @returns A new Comment instance targeting a post + */ + public static createForPost( + content: string, + postId: string, + authorId: string, + parentId: string | null = null, + mediaUrls: string[] = [], ): Comment { return new Comment({ content, postId, + articleId: null, + authorId, + parentId, + mediaUrls, + }); + } + + /** + * Factory method to create a comment on an article + * @param content - The text content of the comment + * @param articleId - The ID of the article this comment belongs to + * @param authorId - The ID of the user who authored this comment + * @param parentId - Optional parent comment ID for nested comments + * @param mediaUrls - Optional array of media URLs attached to the comment + * @returns A new Comment instance targeting an article + */ + public static createForArticle( + content: string, + articleId: string, + authorId: string, + parentId: string | null = null, + mediaUrls: string[] = [], + ): Comment { + return new Comment({ + content, + postId: null, + articleId, authorId, parentId, mediaUrls, diff --git a/src/core/domain/enums/notification-type.enum.ts b/src/core/domain/enums/notification-type.enum.ts index 08d1c9cf..8a39dc00 100644 --- a/src/core/domain/enums/notification-type.enum.ts +++ b/src/core/domain/enums/notification-type.enum.ts @@ -25,4 +25,9 @@ export enum NotificationType { * */ COMMENT_LIKE = "COMMENT_LIKE", + + /** + * A reply to one of the user's comments + */ + COMMENT_REPLY = "COMMENT_REPLY", } diff --git a/src/core/domain/interfaces/comment-props.interface.ts b/src/core/domain/interfaces/comment-props.interface.ts index fb1263d2..1b586922 100644 --- a/src/core/domain/interfaces/comment-props.interface.ts +++ b/src/core/domain/interfaces/comment-props.interface.ts @@ -14,9 +14,19 @@ export interface CommentProps { content: string; /** - * ID of the post this comment belongs to + * ID of the post this comment belongs to. + * + * Null when the comment belongs to an article instead. Exactly one of + * postId and articleId is set; the database enforces it with a CHECK + * constraint. */ - postId: string; + postId: string | null; + + /** + * ID of the article this comment belongs to, or null when it belongs to a + * post. + */ + articleId: string | null; /** * ID of the user who authored this comment diff --git a/src/core/errors/article/article-not-published.error.ts b/src/core/errors/article/article-not-published.error.ts new file mode 100644 index 00000000..3d96ca24 --- /dev/null +++ b/src/core/errors/article/article-not-published.error.ts @@ -0,0 +1,20 @@ +import { CustomError } from "../common/custom.error"; + +/** + * Error thrown when an action requires an article to be published. + * + * Commenting on a draft is the case this exists for: the draft is visible to + * its author, so it is not a 404, but the action is not available yet. + * + * @extends CustomError + */ +export class ArticleNotPublishedError extends CustomError { + /** + * Creates a new ArticleNotPublishedError instance. + * + * @param message - Optional description of what was attempted + */ + constructor(message = "This article has not been published yet.") { + super(message, 409); + } +} diff --git a/src/core/errors/index.ts b/src/core/errors/index.ts index d9243bbc..ace833ff 100644 --- a/src/core/errors/index.ts +++ b/src/core/errors/index.ts @@ -26,6 +26,7 @@ export * from "./post/media-limit-exceeded.error"; export * from "./post/no-media-provided.error"; // Article errors +export * from "./article/article-not-published.error"; export * from "./article/invalid-article-state.error"; // Common HTTP errors diff --git a/src/core/ports/repositories/comment.repository.ts b/src/core/ports/repositories/comment.repository.ts index 581d8f49..3f055cc9 100644 --- a/src/core/ports/repositories/comment.repository.ts +++ b/src/core/ports/repositories/comment.repository.ts @@ -4,6 +4,23 @@ */ import type { Comment } from "@core/domain/entities/comment.entity"; +/** What a comment can be attached to. */ +export type CommentTargetType = "POST" | "ARTICLE"; + +/** + * A comment target: the kind of thing being commented on, and its id. + * + * Callers pass this instead of a bare id so a post id can never be read as an + * article id by a signature that takes both. + */ +export interface CommentTarget { + /** Whether the comment hangs off a post or an article */ + type: CommentTargetType; + + /** Identifier of the post or article */ + id: string; +} + export interface ICommentRepository { /** * Creates a new comment and increments the post's comment count @@ -22,6 +39,10 @@ export interface ICommentRepository { /** * 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 @@ -35,6 +56,28 @@ export interface ICommentRepository { currentUserId?: string, ): Promise; + /** + * Retrieves top-level comments for a post or an article + * @param target - What the comments are attached to + * @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 + */ + findTopLevelByTarget( + target: CommentTarget, + limit: number, + offset: number, + currentUserId?: string, + ): Promise; + + /** + * Counts the comments attached to a post or an article, replies included + * @param target - What the comments are attached to + * @returns Promise that resolves to the number of comments + */ + countByTarget(target: CommentTarget): Promise; + /** * Retrieves replies for a specific parent comment * @param parentId - The ID of the parent comment diff --git a/src/core/ports/services/realtime.port.ts b/src/core/ports/services/realtime.port.ts index 418efbde..e3ba8329 100644 --- a/src/core/ports/services/realtime.port.ts +++ b/src/core/ports/services/realtime.port.ts @@ -15,6 +15,12 @@ export interface RealtimeNotificationPayload { postId?: string; commentId?: string; + + /** Article the notification points at, when it concerns an article */ + articleId?: string; + + /** Identifier of the resource the client should deep-link to */ + referenceId?: string; } /** diff --git a/src/core/use-cases/comment/create-comment/create-comment-usecase.input.ts b/src/core/use-cases/comment/create-comment/create-comment-usecase.input.ts index 13ceb6b4..ffba2c0a 100644 --- a/src/core/use-cases/comment/create-comment/create-comment-usecase.input.ts +++ b/src/core/use-cases/comment/create-comment/create-comment-usecase.input.ts @@ -1,25 +1,35 @@ +import type { CommentTarget } from "@core/ports/repositories/comment.repository"; + /** - * Input type for the CreateComment use case, defining the necessary properties to create a new comment. This includes the content of the comment, the ID of the post it belongs to, the ID of the author creating the comment, an optional parent ID for nested comments, and an optional array of media URLs associated with the comment. + * Input for creating a comment on a post or an article. */ export interface CreateCommentUseCaseInput { /** * The textual content of the comment being created */ content: string; + /** - * The ID of the post to which the comment belongs, used to associate the comment with the correct post in the system + * What the comment is attached to. + * + * A tagged target rather than a bare id, so a post id cannot be silently + * accepted where an article id belongs. */ - postId: string; + target: CommentTarget; + /** - * The ID of the user who is creating the comment, used to identify the author of the comment in the system + * The ID of the user who is creating the comment */ authorId: string; + /** - * Optional ID of the parent comment if this comment is a reply to another comment, allowing for nested comment structures. If not provided, the comment will be treated as a top-level comment on the post. + * Optional ID of the parent comment, for a nested reply. The parent must + * be attached to the same post or article. */ parentId?: string; + /** - * Optional array of media URLs associated with the comment, allowing users to attach images, videos, or other media to their comments. This can enhance the expressiveness of the comment and provide additional context or information related to the comment's content. + * Optional array of media URLs associated with the comment */ mediaUrls?: string[]; } diff --git a/src/core/use-cases/comment/create-comment/create-comment.usecase.ts b/src/core/use-cases/comment/create-comment/create-comment.usecase.ts index 310a2d4f..db22f661 100644 --- a/src/core/use-cases/comment/create-comment/create-comment.usecase.ts +++ b/src/core/use-cases/comment/create-comment/create-comment.usecase.ts @@ -1,13 +1,19 @@ /** - * Use case for creating comments on posts + * Use case for creating comments on posts and articles * Handles comment creation, notification generation, and post comment count updates */ import type { TransactionPort } from "@core/ports/services/transaction.port"; import type { RealtimePort } from "@core/ports/services/realtime.port"; +import type { TransactionContext } from "@core/ports/services/transaction.port"; +import type { CommentTarget } from "@core/ports/repositories/comment.repository"; import { Comment } from "@core/domain/entities/comment.entity"; import { Notification } from "@core/domain/entities/notification.entity"; import { NotificationType } from "@core/domain/enums/notification-type.enum"; -import { NotFoundError, BadRequestError } from "@core/errors"; +import { + ArticleNotPublishedError, + BadRequestError, + NotFoundError, +} from "@core/errors"; import type { CreateCommentUseCaseInput } from "./create-comment-usecase.input"; export class CreateCommentUseCase { @@ -21,19 +27,68 @@ export class CreateCommentUseCase { private readonly realtimeService: RealtimePort, ) {} + /** + * Loads the post or article being commented on and returns its author. + * + * The two article failures are deliberately different. An unpublished + * article the commenter cannot see is a 404, identical to one that does + * not exist - answering 409 there would confirm that a draft slug is real, + * which is the leak drafts must not have. Only the author, who can already + * see their own draft, is told that the article is simply not published + * yet. + * + * @param ctx - The transactional repositories + * @param target - What is being commented on + * @param commenterId - The user attempting to comment + * @returns The id of the user who owns the target + * @throws NotFoundError - When the target does not exist or is not visible + * @throws ArticleNotPublishedError - When the author's own article is not published + */ + private async resolveTargetAuthor( + ctx: TransactionContext, + target: CommentTarget, + commenterId: string, + ): Promise { + if (target.type === "POST") { + const post = await ctx.postRepository.findById(target.id); + if (!post) throw new NotFoundError("Post not found."); + return post.author.id; + } + + const article = await ctx.articleRepository.findById(target.id); + + if (!article || !article.isVisibleTo(commenterId)) { + throw new NotFoundError("Article not found."); + } + + if (!article.isPublished()) { + throw new ArticleNotPublishedError( + "You cannot comment on an article that is not published.", + ); + } + + return article.author.id; + } + /** * Executes the comment creation use case - * @param input - Input data containing comment content, post ID, author ID, and optional parent comment ID + * @param input - Comment content, its target, author and optional parent * @returns Promise that resolves with the created Comment entity - * @throws NotFoundError if the post or parent comment is not found - * @throws BadRequestError if the parent comment belongs to a different post + * @throws NotFoundError if the target or the parent comment is not found + * @throws BadRequestError if the parent comment belongs to something else + * @throws ArticleNotPublishedError if the article is still a draft */ async execute(input: CreateCommentUseCaseInput): Promise { return await this.transactionService.runInTransaction(async (ctx) => { - const post = await ctx.postRepository.findById(input.postId); - if (!post) throw new NotFoundError("Post not found."); + const { target } = input; + const targetAuthorId = await this.resolveTargetAuthor( + ctx, + target, + input.authorId, + ); let notifyUserId: string | null = null; + let notificationType = NotificationType.COMMENT; if (input.parentId) { const parentComment = await ctx.commentRepository.findById( @@ -43,7 +98,11 @@ export class CreateCommentUseCase { throw new NotFoundError("Parent comment not found."); } - if (parentComment.postId !== input.postId) { + const parentTarget = parentComment.target; + if ( + parentTarget.type !== target.type || + parentTarget.id !== target.id + ) { throw new BadRequestError( "Parent comment belongs to a different post.", ); @@ -51,25 +110,41 @@ export class CreateCommentUseCase { if (parentComment.authorId !== input.authorId) { notifyUserId = parentComment.authorId; + // Post replies keep using COMMENT so their existing + // notification behaviour is unchanged. + if (target.type === "ARTICLE") { + notificationType = NotificationType.COMMENT_REPLY; + } } - } else { - if (post.author.id !== input.authorId) { - notifyUserId = post.author.id; - } + } else if (targetAuthorId !== input.authorId) { + notifyUserId = targetAuthorId; } - const tempComment = Comment.create( - input.content, - input.postId, - input.authorId, - input.parentId, - input.mediaUrls || [], - ); + const tempComment = + target.type === "POST" + ? Comment.createForPost( + input.content, + target.id, + input.authorId, + input.parentId, + input.mediaUrls || [], + ) + : Comment.createForArticle( + input.content, + target.id, + input.authorId, + input.parentId, + input.mediaUrls || [], + ); const savedComment = await ctx.commentRepository.create(tempComment); - await ctx.postRepository.incrementCommentsCount(input.postId); + // Articles derive their comment count from a relation count, so + // only posts carry a counter to maintain. + if (target.type === "POST") { + await ctx.postRepository.incrementCommentsCount(target.id); + } if (input.parentId) { await ctx.commentRepository.incrementRepliesCount( @@ -81,7 +156,8 @@ export class CreateCommentUseCase { const notification = Notification.create( notifyUserId, input.authorId, - NotificationType.COMMENT, + notificationType, + savedComment.id, ); await ctx.notificationRepository.create(notification); @@ -90,10 +166,13 @@ export class CreateCommentUseCase { notifyUserId, "new-notification", { - type: NotificationType.COMMENT, + type: notificationType, issuerId: input.authorId, - postId: input.postId, + postId: target.type === "POST" ? target.id : undefined, + articleId: + target.type === "ARTICLE" ? target.id : undefined, commentId: savedComment.id, + referenceId: savedComment.id, }, ); } diff --git a/src/core/use-cases/comment/delete-comment/delete-comment.usecase.ts b/src/core/use-cases/comment/delete-comment/delete-comment.usecase.ts index 3af68a50..aaad13dd 100644 --- a/src/core/use-cases/comment/delete-comment/delete-comment.usecase.ts +++ b/src/core/use-cases/comment/delete-comment/delete-comment.usecase.ts @@ -42,9 +42,16 @@ export class DeleteCommentUseCase { ); } + const target = comment.target; + await ctx.commentRepository.delete(input.commentId); - await ctx.postRepository.decrementCommentsCount(comment.postId); + // Articles derive their comment count from a relation count, so + // there is no counter to maintain. Posts keep the existing + // behaviour, drift and all: fixing that is a separate change. + if (target.type === "POST") { + await ctx.postRepository.decrementCommentsCount(target.id); + } }); try { diff --git a/src/core/use-cases/comment/get-post-comments/get-post-comments.input.ts b/src/core/use-cases/comment/get-post-comments/get-post-comments.input.ts index 4c225825..78e95a99 100644 --- a/src/core/use-cases/comment/get-post-comments/get-post-comments.input.ts +++ b/src/core/use-cases/comment/get-post-comments/get-post-comments.input.ts @@ -1,21 +1,18 @@ +import type { CommentTarget } from "@core/ports/repositories/comment.repository"; + /** - * Input type for the GetPostComments use case, defining the necessary parameters to retrieve comments for a specific post, including pagination options and an optional current user ID for context + * Input for listing the top-level comments of a post or an article. */ export interface GetPostCommentsUseCaseInput { - /** - * The ID of the post for which to retrieve comments, used to identify which post's comments to fetch from the repository - */ - postId: string; - /** - * The page number for pagination, used to determine which set of comments to return based on the specified limit. This allows clients to fetch comments in chunks rather than retrieving all comments at once, improving performance and user experience when dealing with a large number of comments. - */ + /** What the comments are attached to */ + target: CommentTarget; + + /** 1-based page number */ page?: number; - /** - * The number of comments to return per page, used in conjunction with the page parameter to control the pagination of comments. This allows clients to specify how many comments they want to receive in each response, enabling efficient data retrieval and reducing the load on the server when there are many comments on a post. - */ + + /** Page size */ limit?: number; - /** - * Optional ID of the current user making the request, which can be used to personalize the response based on the user's context. For example, this can be used to indicate whether the user has liked any of the comments or to include additional information relevant to the user's interactions with the comments. This parameter is optional because it may not always be necessary to provide user-specific context when fetching post comments, depending on the use case and client requirements. - */ + + /** The viewer, used for like and bookmark flags */ currentUserId?: string; } diff --git a/src/core/use-cases/comment/get-post-comments/get-post-comments.usecase.ts b/src/core/use-cases/comment/get-post-comments/get-post-comments.usecase.ts index 44bdd519..ae82e070 100644 --- a/src/core/use-cases/comment/get-post-comments/get-post-comments.usecase.ts +++ b/src/core/use-cases/comment/get-post-comments/get-post-comments.usecase.ts @@ -1,47 +1,73 @@ import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; +import type { IArticleRepository } from "@core/ports/repositories/article.repository"; 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"; /** - * Use case for retrieving the top-level comments of a specific post. This use case first checks if the post exists before attempting to retrieve its comments, ensuring that an appropriate error is thrown if the post is not found or has been deleted. If the post exists, it retrieves the top-level comments based on the provided pagination parameters and optional current user context. + * Use case for listing the top-level comments of a post or an article. + * + * The target is checked before its comments are read, so a comment list cannot + * 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 { /** - * @param commentRepository - Repository for accessing comment data, used to retrieve the top-level comments of the specified post based on the provided input parameters - * @param postRepository - Repository for accessing post data, used to verify the existence of the specified post before attempting to retrieve its comments + * @param commentRepository - Repository for reading comments + * @param postRepository - Repository used to verify a post target + * @param articleRepository - Repository used to verify an article target */ constructor( private readonly commentRepository: ICommentRepository, private readonly postRepository: IPostRepository, + private readonly articleRepository: IArticleRepository, ) {} /** - * Executes the use case to retrieve the top-level comments of a post based on the provided input - * @param input - The input containing the post ID, pagination parameters, and an optional current user ID for additional context - * @throws NotFoundError if the post does not exist or has been deleted - * @returns An array of comments representing the top-level comments of the specified post, including any relevant information based on the current user context + * Confirms the commented-on resource exists and may be seen. + * + * @param input - The request, carrying the target and the viewer + * @throws NotFoundError - When the target is missing or not visible + */ + private async assertTargetVisible( + input: GetPostCommentsUseCaseInput, + ): Promise { + if (input.target.type === "POST") { + const post = await this.postRepository.findById(input.target.id); + if (!post) { + throw new NotFoundError( + "The post was either not found or has been deleted.", + ); + } + return; + } + + const article = await this.articleRepository.findById(input.target.id); + if (!article || !article.isVisibleTo(input.currentUserId)) { + throw new NotFoundError("Article not found."); + } + } + + /** + * Executes the listing. + * + * @param input - The target, pagination and the viewer + * @returns The page of top-level comments + * @throws NotFoundError if the target does not exist or is not visible */ async execute(input: GetPostCommentsUseCaseInput): Promise { const page = input.page || 1; const limit = input.limit || 10; const offset = (page - 1) * limit; - const postExists = await this.postRepository.findById(input.postId); - if (!postExists) { - throw new NotFoundError( - "The post was either not found or has been deleted.", - ); - } + await this.assertTargetVisible(input); - const comments = await this.commentRepository.findTopLevelByPostId( - input.postId, + return await this.commentRepository.findTopLevelByTarget( + input.target, limit, offset, input.currentUserId, ); - - return comments; } } diff --git a/src/core/use-cases/comment/like-comment/like-comment.usecase.ts b/src/core/use-cases/comment/like-comment/like-comment.usecase.ts index fa8a7709..ef913ab9 100644 --- a/src/core/use-cases/comment/like-comment/like-comment.usecase.ts +++ b/src/core/use-cases/comment/like-comment/like-comment.usecase.ts @@ -55,7 +55,9 @@ export class LikeCommentUseCase { type: NotificationType.COMMENT_LIKE, issuerId: input.userId, commentId: input.commentId, - postId: comment.postId, + postId: comment.postId ?? undefined, + articleId: comment.articleId ?? undefined, + referenceId: input.commentId, }, ); } diff --git a/src/http/controllers/comment.controller.ts b/src/http/controllers/comment.controller.ts index 42959b59..00fbfbdf 100644 --- a/src/http/controllers/comment.controller.ts +++ b/src/http/controllers/comment.controller.ts @@ -10,6 +10,11 @@ import type { GetCommentRepliesUseCase } from "@core/use-cases/comment/get-comme import type { LikeCommentUseCase } from "@core/use-cases/comment/like-comment/like-comment.usecase"; import type { UnlikeCommentUseCase } from "@core/use-cases/comment/unlike-comment/unlike-comment.usecase"; import { CommentPrismaMapper } from "@infrastructure/persistence/mappers/comment-prisma.mapper"; +import type { + ArticleCommentParams, + CreateArticleCommentBody, + GetArticleCommentsQuery, +} from "@typings/schemas/article/article-comment.schema"; import type { CreateCommentBody, CreateCommentParams, @@ -51,7 +56,47 @@ export class CommentController { const comment = await this.createCommentUseCase.execute({ content, - postId, + target: { type: "POST", id: postId }, + authorId: userId, + parentId, + mediaUrls, + }); + + return reply.status(201).send({ + data: CommentPrismaMapper.toResponse( + comment, + request.server.config.R2_PUBLIC_URL, + userId, + ), + meta: { timestamp: new Date().toISOString() }, + }); + } + + /** + * Creates a comment on an article. + * + * Shares the comment use case with posts; only the target differs, which + * is why replies, likes, bookmarks and deletion all keep working through + * the existing /comments/:commentId routes. + * + * @param request - Request carrying the article id and the comment body + * @param reply - The Fastify reply object + * @returns A 201 response containing the created comment + */ + async createForArticle( + request: FastifyRequest<{ + Params: ArticleCommentParams; + Body: CreateArticleCommentBody; + }>, + reply: FastifyReply, + ): Promise { + const userId = request.user.id; + const { articleId } = request.params; + const { content, parentId, mediaUrls } = request.body; + + const comment = await this.createCommentUseCase.execute({ + content, + target: { type: "ARTICLE", id: articleId }, authorId: userId, parentId, mediaUrls, @@ -67,6 +112,41 @@ export class CommentController { }); } + /** + * Lists the top-level comments of an article. + * + * @param request - Request carrying the article id and pagination + * @param reply - The Fastify reply object + * @returns A 200 response containing the page of comments + */ + async getArticleComments( + request: FastifyRequest<{ + Params: ArticleCommentParams; + Querystring: GetArticleCommentsQuery; + }>, + reply: FastifyReply, + ): Promise { + const { articleId } = request.params; + const { page = 1, limit = 10 } = request.query; + const currentUserId = request.user?.id; + + const comments = await this.getPostCommentsUseCase.execute({ + target: { type: "ARTICLE", id: articleId }, + page, + limit, + currentUserId, + }); + + return reply.status(200).send({ + data: CommentPrismaMapper.toListResponse( + comments, + request.server.config.R2_PUBLIC_URL, + currentUserId, + ), + meta: { currentPage: page, limit }, + }); + } + async delete( request: FastifyRequest<{ Params: DeleteCommentParams }>, reply: FastifyReply, @@ -96,7 +176,7 @@ export class CommentController { const cdnUrl = request.server.config.R2_PUBLIC_URL; const comments = await this.getPostCommentsUseCase.execute({ - postId, + target: { type: "POST", id: postId }, page, limit, currentUserId, diff --git a/src/http/routes/article/article-comment.routes.ts b/src/http/routes/article/article-comment.routes.ts new file mode 100644 index 00000000..3197d999 --- /dev/null +++ b/src/http/routes/article/article-comment.routes.ts @@ -0,0 +1,71 @@ +/** + * @module ArticleCommentRoutes + * Comment routes scoped to an article. + * + * There are only two: creating a comment and listing the top level. Replies, + * comment detail, likes, bookmarks and deletion all continue to work through + * the existing /comments/:commentId routes, because article comments live in + * the same table as post comments. + */ + +import type { FastifyInstance } from "fastify"; +import { RateLimitPolicies } from "@plugins/rate-limit.plugin"; +import { + articleCommentParamsSchema, + createArticleCommentBodySchema, + CreateArticleCommentResponseSchema, + getArticleCommentsQuerySchema, + GetArticleCommentsResponseSchema, + type ArticleCommentParams, + type CreateArticleCommentBody, + type CreateArticleCommentResponse, + type GetArticleCommentsQuery, + type GetArticleCommentsResponse, +} from "@typings/schemas/article/article-comment.schema"; + +/** + * Registers the article comment endpoints. + * + * @param fastify - The Fastify application instance + */ +export function articleCommentRoutes(fastify: FastifyInstance): void { + const { commentController } = fastify.diContainer.cradle; + + fastify.post<{ + Params: ArticleCommentParams; + Body: CreateArticleCommentBody; + Reply: { 201: CreateArticleCommentResponse }; + }>( + "/articles/:articleId/comments", + { + onRequest: [fastify.authenticate], + schema: { + params: articleCommentParamsSchema, + body: createArticleCommentBodySchema, + response: { 201: CreateArticleCommentResponseSchema }, + tags: ["Article", "Comment"], + }, + config: { rateLimit: RateLimitPolicies.STANDARD }, + }, + commentController.createForArticle.bind(commentController), + ); + + fastify.get<{ + Params: ArticleCommentParams; + Querystring: GetArticleCommentsQuery; + Reply: { 200: GetArticleCommentsResponse }; + }>( + "/articles/:articleId/comments", + { + onRequest: [fastify.optionalAuthenticate], + schema: { + params: articleCommentParamsSchema, + querystring: getArticleCommentsQuerySchema, + response: { 200: GetArticleCommentsResponseSchema }, + tags: ["Article", "Comment"], + }, + config: { rateLimit: RateLimitPolicies.PUBLIC }, + }, + commentController.getArticleComments.bind(commentController), + ); +} diff --git a/src/http/types/schemas/article/article-comment.schema.ts b/src/http/types/schemas/article/article-comment.schema.ts new file mode 100644 index 00000000..c313b7df --- /dev/null +++ b/src/http/types/schemas/article/article-comment.schema.ts @@ -0,0 +1,52 @@ +import { Type, type Static } from "@fastify/type-provider-typebox"; +import { CommentItemSchema } from "../comment/get-comment.schema"; +import { ResponseSchema } from "../create-response-schema"; + +export const articleCommentParamsSchema = Type.Object({ + articleId: Type.String({ + format: "uuid", + description: "The unique identifier of the article", + }), +}); + +export type ArticleCommentParams = Static; + +export const createArticleCommentBodySchema = Type.Object({ + content: Type.String({ minLength: 1, maxLength: 1000 }), + parentId: Type.Optional(Type.String({ format: "uuid" })), + mediaUrls: Type.Optional( + Type.Array(Type.String({ format: "uri" }), { maxItems: 4 }), + ), +}); + +export type CreateArticleCommentBody = Static< + typeof createArticleCommentBodySchema +>; + +export const CreateArticleCommentResponseSchema = + ResponseSchema(CommentItemSchema); + +export type CreateArticleCommentResponse = Static< + typeof CreateArticleCommentResponseSchema +>; + +export const getArticleCommentsQuerySchema = Type.Object({ + page: Type.Optional(Type.Number({ minimum: 1, default: 1 })), + limit: Type.Optional(Type.Number({ minimum: 1, maximum: 50, default: 10 })), +}); + +export type GetArticleCommentsQuery = Static< + typeof getArticleCommentsQuerySchema +>; + +export const GetArticleCommentsResponseSchema = Type.Object({ + data: Type.Array(CommentItemSchema), + meta: Type.Object({ + currentPage: Type.Number(), + limit: Type.Number(), + }), +}); + +export type GetArticleCommentsResponse = Static< + typeof GetArticleCommentsResponseSchema +>; diff --git a/src/http/types/schemas/comment/get-comment.schema.ts b/src/http/types/schemas/comment/get-comment.schema.ts index 3716e692..0454c535 100644 --- a/src/http/types/schemas/comment/get-comment.schema.ts +++ b/src/http/types/schemas/comment/get-comment.schema.ts @@ -13,7 +13,12 @@ export const CommentAuthorSchema = FBType.Object({ export const CommentItemSchema = FBType.Object({ id: FBType.String({ format: "uuid" }), content: FBType.String(), - postId: FBType.String({ format: "uuid" }), + // Nullable in lockstep with CommentResponse. fast-json-stringify coerces a + // value that does not match its schema instead of rejecting it, so leaving + // this as a plain string would emit a wrong postId for an article comment + // rather than failing. + postId: FBType.Union([FBType.String({ format: "uuid" }), FBType.Null()]), + articleId: FBType.Union([FBType.String({ format: "uuid" }), FBType.Null()]), parentId: FBType.Union([FBType.String({ format: "uuid" }), FBType.Null()]), mediaUrls: FBType.Array(FBType.String()), createdAt: FBType.String(), diff --git a/src/infrastructure/persistence/mappers/article-prisma.mapper.ts b/src/infrastructure/persistence/mappers/article-prisma.mapper.ts index 2a92f0ec..b62684cc 100644 --- a/src/infrastructure/persistence/mappers/article-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/article-prisma.mapper.ts @@ -15,6 +15,7 @@ export type ArticleWithRelations = Prisma.ArticleGetPayload<{ tags: true; likes: true; bookmarks: true; + _count: { select: { comments: true } }; }; }>; @@ -88,6 +89,10 @@ export class ArticlePrismaMapper { createdAt: dbArticle.createdAt, updatedAt: dbArticle.updatedAt, likeCount: dbArticle.likeCount, + // Derived rather than denormalized: a counter column would drift + // the way posts.comment_count does, since the reply subtree is + // removed by a database cascade the application never sees. + commentCount: dbArticle._count?.comments ?? 0, isLiked: Boolean(dbArticle.likes && dbArticle.likes.length > 0), isBookmarked: Boolean( dbArticle.bookmarks && dbArticle.bookmarks.length > 0, diff --git a/src/infrastructure/persistence/mappers/comment-prisma.mapper.ts b/src/infrastructure/persistence/mappers/comment-prisma.mapper.ts index e6a4c051..908232a7 100644 --- a/src/infrastructure/persistence/mappers/comment-prisma.mapper.ts +++ b/src/infrastructure/persistence/mappers/comment-prisma.mapper.ts @@ -18,7 +18,19 @@ export type CommentWithRelations = Prisma.CommentGetPayload<{ export interface CommentResponse { id: string; content: string; - postId: string; + + /** + * The post this comment belongs to, or null when it belongs to an article. + * + * Nullable in lockstep with the response schema: fast-json-stringify + * coerces rather than rejects, so a schema that still promised a string + * would emit a wrong value instead of failing loudly. + */ + postId: string | null; + + /** The article this comment belongs to, or null when it belongs to a post */ + articleId: string | null; + mediaUrls: string[]; parentId: string | null; createdAt: Date; @@ -47,6 +59,7 @@ export class CommentPrismaMapper { id: dbComment.id, content: dbComment.content, postId: dbComment.postId, + articleId: dbComment.articleId, authorId: dbComment.authorId, parentId: dbComment.parentId, mediaUrls: dbComment.mediaUrls, @@ -75,6 +88,7 @@ export class CommentPrismaMapper { id: comment.id, content: comment.content, postId: comment.postId, + articleId: comment.articleId, parentId: comment.parentId, mediaUrls: comment.mediaUrls, createdAt: comment.createdAt, diff --git a/src/infrastructure/persistence/repositories/prisma-article.repository.ts b/src/infrastructure/persistence/repositories/prisma-article.repository.ts index f14bde67..1e261a70 100644 --- a/src/infrastructure/persistence/repositories/prisma-article.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-article.repository.ts @@ -31,6 +31,7 @@ type ArticleRelationInclude = { tags: true; likes: { where: { userId: string } } | false; bookmarks: { where: { userId: string } } | false; + _count: { select: { comments: true } }; }; /** @@ -73,6 +74,7 @@ export class PrismaArticleRepository implements IArticleRepository { bookmarks: currentUserId ? ({ where: { userId: currentUserId } } as const) : (false as const), + _count: { select: { comments: true } } as const, }; } diff --git a/src/infrastructure/persistence/repositories/prisma-comment.repository.ts b/src/infrastructure/persistence/repositories/prisma-comment.repository.ts index 7abb28ae..3a69612a 100644 --- a/src/infrastructure/persistence/repositories/prisma-comment.repository.ts +++ b/src/infrastructure/persistence/repositories/prisma-comment.repository.ts @@ -3,7 +3,10 @@ * Handles database operations for comments and nested comment relationships */ import type { PrismaTransactionalClient } from "@infrastructure/persistence/database/prisma-client.type"; -import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; +import type { + ICommentRepository, + CommentTarget, +} from "@core/ports/repositories/comment.repository"; import type { Comment } from "@core/domain/entities/comment.entity"; import { CommentPrismaMapper, @@ -29,6 +32,7 @@ export class PrismaCommentRepository implements ICommentRepository { content: comment.content, mediaUrls: comment.mediaUrls, postId: comment.postId, + articleId: comment.articleId, authorId: comment.authorId, parentId: comment.parentId, }, @@ -92,6 +96,76 @@ export class PrismaCommentRepository implements ICommentRepository { * @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 + * @returns A partial where clause selecting that target + */ + private targetWhere(target: CommentTarget): { + postId?: string; + articleId?: string; + } { + return target.type === "POST" + ? { postId: target.id } + : { articleId: target.id }; + } + + /** + * Retrieves top-level comments for a post or an article + * @param target - What the comments are attached to + * @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 + */ + async findTopLevelByTarget( + target: CommentTarget, + limit: number, + offset: number, + currentUserId?: string, + ): Promise { + const rawComments = await this.prisma.comment.findMany({ + where: { ...this.targetWhere(target), 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, + ), + ); + } + + /** + * Counts the comments attached to a post or an article, replies included + * @param target - What the comments are attached to + * @returns Promise that resolves to the number of comments + */ + async countByTarget(target: CommentTarget): Promise { + return await this.prisma.comment.count({ + where: this.targetWhere(target), + }); + } + async findTopLevelByPostId( postId: string, limit: number, diff --git a/tests/e2e/article/comments.test.ts b/tests/e2e/article/comments.test.ts new file mode 100644 index 00000000..cb3367cf --- /dev/null +++ b/tests/e2e/article/comments.test.ts @@ -0,0 +1,383 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { request, authRequest, parseBody } from "../setup"; + +interface CommentData { + id: string; + content: string; + postId: string | null; + articleId: string | null; + parentId: string | null; + replyCount: number; + likeCount: number; + author: { id: string; isMe: boolean }; +} + +interface ArticleData { + id: string; + slug: string; + commentCount: number; +} + +type CommentEnvelope = { data: CommentData; meta: { timestamp: string } }; +type CommentListEnvelope = { + data: CommentData[]; + meta: { currentPage: number; limit: number }; +}; +type ArticleEnvelope = { data: ArticleData }; +type ErrorEnvelope = { title: string; status: number }; + +const ts = Date.now(); +const author = { + email: `ac-author-${ts}@article-comments-test.com`, + password: "password123", + username: `aca${ts}`, +}; +const reader = { + email: `ac-reader-${ts}@article-comments-test.com`, + password: "password123", + username: `acr${ts}`, +}; + +let authorToken: string; +let readerToken: string; +let publishedId: string; +let publishedSlug: 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; +} + +/** + * Posts a comment on the published article as the reader. + */ +async function comment( + content: string, + parentId?: string, +): Promise { + const response = await authRequest(readerToken, { + method: "POST", + url: `/articles/${publishedId}/comments`, + payload: parentId ? { content, parentId } : { content }, + }); + 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: `Commentable article ${ts}`, + body: "Body prose for the comment tests.", + }, + }), + ).data; + publishedId = created.id; + publishedSlug = created.slug; + + await authRequest(authorToken, { + method: "POST", + url: `/articles/${publishedId}/publish`, + }); + + draftId = parseBody( + await authRequest(authorToken, { + method: "POST", + url: "/articles", + payload: { + title: `Draft article ${ts}`, + body: "Body prose for the draft.", + }, + }), + ).data.id; +}); + +describe("POST /articles/:articleId/comments", () => { + it("should create a comment attached to the article", async () => { + const response = await authRequest(readerToken, { + method: "POST", + url: `/articles/${publishedId}/comments`, + payload: { content: "First!" }, + }); + const body = parseBody(response); + + expect(response.statusCode).toBe(201); + expect(body.data.articleId).toBe(publishedId); + expect(body.data.postId).toBeNull(); + expect(body.data.parentId).toBeNull(); + expect(body.data.author.isMe).toBe(true); + }); + + it("should refuse a comment on a draft", async () => { + const response = await authRequest(readerToken, { + method: "POST", + url: `/articles/${draftId}/comments`, + payload: { content: "Sneaking in" }, + }); + + expect(response.statusCode).toBe(404); + }); + + it("should refuse a comment on the author's own draft, even for the author", async () => { + const response = await authRequest(authorToken, { + method: "POST", + url: `/articles/${draftId}/comments`, + payload: { content: "Talking to myself" }, + }); + + expect(response.statusCode).toBe(409); + expect(parseBody(response).title).toBe( + "ArticleNotPublishedError", + ); + }); + + it("should reject a parent comment from a different thread", async () => { + const other = parseBody( + await authRequest(authorToken, { + method: "POST", + url: "/articles", + payload: { + title: `Other article ${ts}`, + body: "Another body.", + }, + }), + ).data; + await authRequest(authorToken, { + method: "POST", + url: `/articles/${other.id}/publish`, + }); + const foreign = parseBody( + await authRequest(readerToken, { + method: "POST", + url: `/articles/${other.id}/comments`, + payload: { content: "Elsewhere" }, + }), + ).data; + + const response = await authRequest(readerToken, { + method: "POST", + url: `/articles/${publishedId}/comments`, + payload: { content: "Wrong parent", parentId: foreign.id }, + }); + + expect(response.statusCode).toBe(400); + }); + + it("should require authentication", async () => { + const response = await request({ + method: "POST", + url: `/articles/${publishedId}/comments`, + payload: { content: "Anonymous" }, + }); + + expect(response.statusCode).toBe(401); + }); + + it("should reject an unknown article", async () => { + const response = await authRequest(readerToken, { + method: "POST", + url: "/articles/11111111-1111-4111-8111-111111111111/comments", + payload: { content: "Nowhere" }, + }); + + expect(response.statusCode).toBe(404); + }); +}); + +describe("nested replies through the existing comment routes", () => { + it("should create a reply and expose it under the parent", async () => { + const parent = await comment("Parent comment"); + const reply = await comment("A reply", parent.id); + + expect(reply.parentId).toBe(parent.id); + expect(reply.articleId).toBe(publishedId); + + const replies = await request({ + method: "GET", + url: `/comments/${parent.id}/replies`, + }); + const body = parseBody(replies); + + expect(replies.statusCode).toBe(200); + expect(body.data.map((c) => c.id)).toContain(reply.id); + }); + + it("should serve an article comment from the shared comment detail route", async () => { + const created = await comment("Readable through /comments"); + + const response = await request({ + method: "GET", + url: `/comments/${created.id}`, + }); + const body = parseBody(response); + + expect(response.statusCode).toBe(200); + expect(body.data.articleId).toBe(publishedId); + expect(body.data.postId).toBeNull(); + }); + + it("should like an article comment through the shared route", async () => { + const created = await comment("Likeable"); + + const response = await authRequest(authorToken, { + method: "POST", + url: `/comments/${created.id}/like`, + }); + + expect(response.statusCode).toBe(200); + + const detail = parseBody( + await request({ method: "GET", url: `/comments/${created.id}` }), + ); + expect(detail.data.likeCount).toBe(1); + }); + + it("should delete an article comment through the shared route", async () => { + const created = await comment("Doomed"); + + const response = await authRequest(readerToken, { + method: "DELETE", + url: `/comments/${created.id}`, + }); + + expect(response.statusCode).toBe(204); + expect( + (await request({ method: "GET", url: `/comments/${created.id}` })) + .statusCode, + ).toBe(404); + }); + + it("should not let another user delete a comment", async () => { + const created = await comment("Not yours"); + + const response = await authRequest(authorToken, { + method: "DELETE", + url: `/comments/${created.id}`, + }); + + expect(response.statusCode).toBe(403); + }); +}); + +describe("GET /articles/:articleId/comments", () => { + it("should list only top-level comments", async () => { + const parent = await comment("Top level for listing"); + const reply = await comment("Reply not in the list", parent.id); + + const response = await request({ + method: "GET", + url: `/articles/${publishedId}/comments?limit=50`, + }); + const ids = parseBody(response).data.map( + (c) => c.id, + ); + + expect(response.statusCode).toBe(200); + expect(ids).toContain(parent.id); + expect(ids).not.toContain(reply.id); + }); + + it("should be readable by a guest", async () => { + const response = await request({ + method: "GET", + url: `/articles/${publishedId}/comments`, + }); + + expect(response.statusCode).toBe(200); + expect(parseBody(response).meta).toEqual({ + currentPage: 1, + limit: 10, + }); + }); + + it("should not expose the comments of someone else's draft", async () => { + const response = await authRequest(readerToken, { + method: "GET", + url: `/articles/${draftId}/comments`, + }); + + expect(response.statusCode).toBe(404); + }); +}); + +describe("article comment count", () => { + it("should be derived, and drop by the whole subtree when a parent is deleted", async () => { + const fresh = parseBody( + await authRequest(authorToken, { + method: "POST", + url: "/articles", + payload: { + title: `Counted article ${ts}`, + body: "Body prose.", + }, + }), + ).data; + await authRequest(authorToken, { + method: "POST", + url: `/articles/${fresh.id}/publish`, + }); + + const parent = parseBody( + await authRequest(readerToken, { + method: "POST", + url: `/articles/${fresh.id}/comments`, + payload: { content: "Parent" }, + }), + ).data; + for (const content of ["Reply one", "Reply two"]) { + await authRequest(readerToken, { + method: "POST", + url: `/articles/${fresh.id}/comments`, + payload: { content, parentId: parent.id }, + }); + } + + const withComments = parseBody( + await request({ method: "GET", url: `/articles/${fresh.slug}` }), + ).data; + expect(withComments.commentCount).toBe(3); + + await authRequest(readerToken, { + method: "DELETE", + url: `/comments/${parent.id}`, + }); + + const afterDelete = parseBody( + await request({ method: "GET", url: `/articles/${fresh.slug}` }), + ).data; + + // A denormalized counter would read 2 here, because the two replies are + // removed by a database cascade the application never sees. + expect(afterDelete.commentCount).toBe(0); + }); + + it("should count the comments on the published article", async () => { + const response = await request({ + method: "GET", + url: `/articles/${publishedSlug}`, + }); + + expect( + parseBody(response).data.commentCount, + ).toBeGreaterThan(0); + }); +}); diff --git a/tests/integration/persistence/comment-target-constraint.test.ts b/tests/integration/persistence/comment-target-constraint.test.ts new file mode 100644 index 00000000..3ac877be --- /dev/null +++ b/tests/integration/persistence/comment-target-constraint.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { PrismaClient } from "../../../src/generated/prisma/client"; +import { PrismaUserRepository } from "../../../src/infrastructure/persistence/repositories/prisma-user.repository"; +import { PrismaArticleRepository } from "../../../src/infrastructure/persistence/repositories/prisma-article.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 = "@comment-constraint-test.com"; + +/** + * The comments_target_xor CHECK constraint is written by hand in the migration + * because Prisma cannot express one, which also means Prisma does not know it + * exists. These tests are the guard: if a future migrate dev regenerates the + * table without the constraint, they fail rather than the invariant quietly + * disappearing. + */ +describe("comments target constraint (integration)", () => { + let prisma: PrismaClient; + let userId: string; + let postId: string; + let articleId: string; + + beforeAll(async () => { + prisma = createPrismaClient(); + + const user = await new PrismaUserRepository(prisma, { + gracePeriodDays: 30, + }).create({ + email: `constraint${EMAIL_DOMAIN}`, + username: "constraint_user", + passwordHash: "hashed", + }); + userId = user.id; + + const post = await new PrismaPostRepository(prisma).create( + Post.create("A post to comment on", PostType.COMMUNITY, userId), + ); + postId = post.id; + + const article = await new PrismaArticleRepository(prisma).create( + Article.create({ + title: "An article to comment on", + body: "Body prose.", + authorId: userId, + slugSuffix: "c0nstra1", + }), + ); + articleId = article.id; + }); + + afterAll(async () => { + await prisma.comment.deleteMany({ where: { authorId: userId } }); + await prisma.article.deleteMany({ where: { authorId: userId } }); + await prisma.post.deleteMany({ where: { authorId: userId } }); + await prisma.user.deleteMany({ + where: { email: { contains: EMAIL_DOMAIN } }, + }); + await prisma.$disconnect(); + }); + + it("should accept a comment attached to a post", async () => { + const comment = await prisma.comment.create({ + data: { content: "On a post", authorId: userId, postId }, + }); + + expect(comment.postId).toBe(postId); + expect(comment.articleId).toBeNull(); + }); + + it("should accept a comment attached to an article", async () => { + const comment = await prisma.comment.create({ + data: { content: "On an article", authorId: userId, articleId }, + }); + + expect(comment.articleId).toBe(articleId); + expect(comment.postId).toBeNull(); + }); + + it("should reject a comment attached to nothing", async () => { + await expect( + prisma.comment.create({ + data: { content: "Orphan", authorId: userId }, + }), + ).rejects.toThrow(); + }); + + it("should reject a comment attached to both a post and an article", async () => { + await expect( + prisma.comment.create({ + data: { + content: "Both at once", + authorId: userId, + postId, + articleId, + }, + }), + ).rejects.toThrow(); + }); + + it("should reject an update that clears both targets", async () => { + const comment = await prisma.comment.create({ + data: { content: "Will be orphaned", authorId: userId, postId }, + }); + + await expect( + prisma.comment.update({ + where: { id: comment.id }, + data: { postId: null }, + }), + ).rejects.toThrow(); + }); + + it("should reject an update that sets the second target", async () => { + const comment = await prisma.comment.create({ + data: { content: "Will point at both", authorId: userId, postId }, + }); + + await expect( + prisma.comment.update({ + where: { id: comment.id }, + data: { articleId }, + }), + ).rejects.toThrow(); + }); + + it("should remove article comments when the article is deleted", async () => { + const article = await new PrismaArticleRepository(prisma).create( + Article.create({ + title: "Short lived article", + body: "Body prose.", + authorId: userId, + slugSuffix: "d00med01", + }), + ); + await prisma.comment.create({ + data: { + content: "Goes away with the article", + authorId: userId, + articleId: article.id, + }, + }); + + await prisma.article.delete({ where: { id: article.id } }); + + expect( + await prisma.comment.count({ where: { articleId: article.id } }), + ).toBe(0); + }); +}); diff --git a/tests/unit/core/domain/enums/enums.test.ts b/tests/unit/core/domain/enums/enums.test.ts index 543269c7..3058502b 100644 --- a/tests/unit/core/domain/enums/enums.test.ts +++ b/tests/unit/core/domain/enums/enums.test.ts @@ -74,8 +74,12 @@ describe("Domain Enums", () => { expect(NotificationType.COMMENT_LIKE).toBe("COMMENT_LIKE"); }); - it("should have exactly 5 values", () => { - expect(Object.keys(NotificationType)).toHaveLength(5); + it("should have COMMENT_REPLY value", () => { + expect(NotificationType.COMMENT_REPLY).toBe("COMMENT_REPLY"); + }); + + it("should have exactly 6 values", () => { + expect(Object.keys(NotificationType)).toHaveLength(6); }); }); diff --git a/tests/unit/core/use-cases/comment/create-article-comment.usecase.test.ts b/tests/unit/core/use-cases/comment/create-article-comment.usecase.test.ts new file mode 100644 index 00000000..df9c52f9 --- /dev/null +++ b/tests/unit/core/use-cases/comment/create-article-comment.usecase.test.ts @@ -0,0 +1,300 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CreateCommentUseCase } from "@core/use-cases/comment/create-comment/create-comment.usecase"; +import { + ArticleNotPublishedError, + BadRequestError, + NotFoundError, +} from "@core/errors"; +import type { + TransactionPort, + TransactionContext, +} from "@core/ports/services/transaction.port"; +import type { RealtimePort } from "@core/ports/services/realtime.port"; +import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; +import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { IArticleRepository } from "@core/ports/repositories/article.repository"; +import type { INotificationRepository } from "@core/ports/repositories/notification.repository"; +import type { Comment } from "@core/domain/entities/comment.entity"; +import { ArticleStatus } from "@core/domain/enums/article-status.enum"; +import { NotificationType } from "@core/domain/enums/notification-type.enum"; +import { buildArticle, buildComment } from "../../../helpers/mock-factories"; + +const AUTHOR = "article-author-1"; +const COMMENTER = "commenter-1"; +const ARTICLE_TARGET = { type: "ARTICLE" as const, id: "article-1" }; + +/** + * The article-comment half of CreateCommentUseCase. The post half lives in + * create-comment.usecase.test.ts and is deliberately left untouched, so it + * doubles as the regression gate for this change. + */ +describe("CreateCommentUseCase (article target)", () => { + let useCase: CreateCommentUseCase; + let transactionSvc: Pick; + let realtimeSvc: Pick; + let txArticleRepo: Pick; + let txPostRepo: Pick< + IPostRepository, + "findById" | "incrementCommentsCount" + >; + let txCommentRepo: Pick< + ICommentRepository, + "findById" | "create" | "incrementRepliesCount" + >; + let txNotificationRepo: Pick; + + beforeEach(() => { + txArticleRepo = { + findById: vi.fn().mockResolvedValue( + buildArticle({ + status: ArticleStatus.PUBLISHED, + author: { id: AUTHOR }, + }), + ), + }; + txPostRepo = { + findById: vi.fn(), + incrementCommentsCount: vi.fn(), + }; + txCommentRepo = { + findById: vi.fn(), + create: vi + .fn() + .mockImplementation((comment: Comment) => + Promise.resolve(comment), + ), + incrementRepliesCount: vi.fn(), + }; + txNotificationRepo = { create: vi.fn() }; + realtimeSvc = { emitToUser: vi.fn() }; + transactionSvc = { + runInTransaction: vi.fn().mockImplementation(async (work) => + work({ + articleRepository: txArticleRepo as IArticleRepository, + postRepository: txPostRepo as IPostRepository, + commentRepository: txCommentRepo as ICommentRepository, + notificationRepository: + txNotificationRepo as INotificationRepository, + } as TransactionContext), + ), + }; + + useCase = new CreateCommentUseCase( + transactionSvc as TransactionPort, + realtimeSvc as RealtimePort, + ); + }); + + it("should create a comment attached to the article", async () => { + const comment = await useCase.execute({ + content: "Nice piece", + target: ARTICLE_TARGET, + authorId: COMMENTER, + }); + + expect(comment.articleId).toBe("article-1"); + expect(comment.postId).toBeNull(); + expect(comment.target).toEqual(ARTICLE_TARGET); + }); + + it("should not touch the post comment counter", async () => { + await useCase.execute({ + content: "Nice piece", + target: ARTICLE_TARGET, + authorId: COMMENTER, + }); + + expect(txPostRepo.incrementCommentsCount).not.toHaveBeenCalled(); + }); + + it("should throw NotFoundError when the article does not exist", async () => { + vi.mocked(txArticleRepo.findById).mockResolvedValue(null); + + await expect( + useCase.execute({ + content: "Hello", + target: ARTICLE_TARGET, + authorId: COMMENTER, + }), + ).rejects.toThrow(NotFoundError); + }); + + it("should hide someone else's draft behind a 404 rather than a conflict", async () => { + vi.mocked(txArticleRepo.findById).mockResolvedValue( + buildArticle({ + status: ArticleStatus.DRAFT, + author: { id: AUTHOR }, + }), + ); + + // A stranger must not be able to tell an unpublished article from one + // that does not exist: a distinct status code would confirm the draft. + await expect( + useCase.execute({ + content: "Hello", + target: ARTICLE_TARGET, + authorId: COMMENTER, + }), + ).rejects.toThrow(NotFoundError); + + expect(txCommentRepo.create).not.toHaveBeenCalled(); + }); + + it("should tell the author their own draft is not published yet", async () => { + vi.mocked(txArticleRepo.findById).mockResolvedValue( + buildArticle({ + status: ArticleStatus.DRAFT, + author: { id: AUTHOR }, + }), + ); + + await expect( + useCase.execute({ + content: "Talking to myself", + target: ARTICLE_TARGET, + authorId: AUTHOR, + }), + ).rejects.toThrow(ArticleNotPublishedError); + + expect(txCommentRepo.create).not.toHaveBeenCalled(); + }); + + it("should refuse to comment on an archived article", async () => { + vi.mocked(txArticleRepo.findById).mockResolvedValue( + buildArticle({ + status: ArticleStatus.ARCHIVED, + author: { id: AUTHOR }, + }), + ); + + // Archived is visible to its author only, so the two callers get the + // same split as a draft does. + await expect( + useCase.execute({ + content: "Hello", + target: ARTICLE_TARGET, + authorId: COMMENTER, + }), + ).rejects.toThrow(NotFoundError); + + await expect( + useCase.execute({ + content: "Hello", + target: ARTICLE_TARGET, + authorId: AUTHOR, + }), + ).rejects.toThrow(ArticleNotPublishedError); + }); + + it("should notify the article author of a top-level comment", async () => { + await useCase.execute({ + content: "Nice piece", + target: ARTICLE_TARGET, + authorId: COMMENTER, + }); + + expect(txNotificationRepo.create).toHaveBeenCalledTimes(1); + expect(realtimeSvc.emitToUser).toHaveBeenCalledWith( + AUTHOR, + "new-notification", + expect.objectContaining({ + type: NotificationType.COMMENT, + issuerId: COMMENTER, + articleId: "article-1", + postId: undefined, + }), + ); + }); + + it("should populate referenceId with the new comment", async () => { + const comment = await useCase.execute({ + content: "Nice piece", + target: ARTICLE_TARGET, + authorId: COMMENTER, + }); + + expect(realtimeSvc.emitToUser).toHaveBeenCalledWith( + AUTHOR, + "new-notification", + expect.objectContaining({ referenceId: comment.id }), + ); + }); + + it("should not notify when the author comments on their own article", async () => { + await useCase.execute({ + content: "Following up", + target: ARTICLE_TARGET, + authorId: AUTHOR, + }); + + expect(txNotificationRepo.create).not.toHaveBeenCalled(); + expect(realtimeSvc.emitToUser).not.toHaveBeenCalled(); + }); + + it("should use COMMENT_REPLY when replying to another user's comment", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue( + buildComment({ + id: "parent-1", + postId: null, + articleId: "article-1", + authorId: "parent-author", + }), + ); + + await useCase.execute({ + content: "Agreed", + target: ARTICLE_TARGET, + authorId: COMMENTER, + parentId: "parent-1", + }); + + expect(txCommentRepo.incrementRepliesCount).toHaveBeenCalledWith( + "parent-1", + ); + expect(realtimeSvc.emitToUser).toHaveBeenCalledWith( + "parent-author", + "new-notification", + expect.objectContaining({ + type: NotificationType.COMMENT_REPLY, + }), + ); + }); + + it("should reject a parent comment that belongs to a post", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue( + buildComment({ + id: "parent-1", + postId: "post-1", + articleId: null, + }), + ); + + await expect( + useCase.execute({ + content: "Wrong thread", + target: ARTICLE_TARGET, + authorId: COMMENTER, + parentId: "parent-1", + }), + ).rejects.toThrow(BadRequestError); + }); + + it("should reject a parent comment from a different article", async () => { + vi.mocked(txCommentRepo.findById).mockResolvedValue( + buildComment({ + id: "parent-1", + postId: null, + articleId: "another-article", + }), + ); + + await expect( + useCase.execute({ + content: "Wrong thread", + target: ARTICLE_TARGET, + authorId: COMMENTER, + parentId: "parent-1", + }), + ).rejects.toThrow(BadRequestError); + }); +}); diff --git a/tests/unit/core/use-cases/comment/create-comment.usecase.test.ts b/tests/unit/core/use-cases/comment/create-comment.usecase.test.ts index 9d08c468..3974f01e 100644 --- a/tests/unit/core/use-cases/comment/create-comment.usecase.test.ts +++ b/tests/unit/core/use-cases/comment/create-comment.usecase.test.ts @@ -71,7 +71,7 @@ describe("CreateCommentUseCase", () => { await expect( useCase.execute({ content: "Hello", - postId: "post-1", + target: { type: "POST" as const, id: "post-1" }, authorId: "user-1", }), ).rejects.toThrow(NotFoundError); @@ -84,7 +84,7 @@ describe("CreateCommentUseCase", () => { await expect( useCase.execute({ content: "Reply", - postId: "post-1", + target: { type: "POST" as const, id: "post-1" }, authorId: "user-1", parentId: "parent-1", }), @@ -100,7 +100,7 @@ describe("CreateCommentUseCase", () => { await expect( useCase.execute({ content: "Reply", - postId: "post-1", + target: { type: "POST" as const, id: "post-1" }, authorId: "user-1", parentId: "parent-1", }), @@ -117,7 +117,7 @@ describe("CreateCommentUseCase", () => { const result = await useCase.execute({ content: "Hello", - postId: "post-1", + target: { type: "POST" as const, id: "post-1" }, authorId: "commenter-user", }); @@ -143,7 +143,7 @@ describe("CreateCommentUseCase", () => { await useCase.execute({ content: "My own post comment", - postId: "post-1", + target: { type: "POST" as const, id: "post-1" }, authorId: "user-1", }); @@ -154,7 +154,7 @@ describe("CreateCommentUseCase", () => { it("should create reply, increment repliesCount and notify parent comment author", async () => { const parentComment = buildComment({ id: "parent-1", - postId: "post-1", + target: { type: "POST" as const, id: "post-1" }, authorId: "parent-author", }); const savedComment = buildComment({ id: "reply-1" }); @@ -171,7 +171,7 @@ describe("CreateCommentUseCase", () => { await useCase.execute({ content: "Reply", - postId: "post-1", + target: { type: "POST" as const, id: "post-1" }, authorId: "user-1", parentId: "parent-1", }); @@ -190,7 +190,7 @@ describe("CreateCommentUseCase", () => { it("should not send notification when replying to own comment", async () => { const parentComment = buildComment({ id: "parent-1", - postId: "post-1", + target: { type: "POST" as const, id: "post-1" }, authorId: "user-1", }); const savedComment = buildComment({ id: "reply-1" }); @@ -207,7 +207,7 @@ describe("CreateCommentUseCase", () => { await useCase.execute({ content: "Self reply", - postId: "post-1", + target: { type: "POST" as const, id: "post-1" }, authorId: "user-1", parentId: "parent-1", }); diff --git a/tests/unit/core/use-cases/comment/get-post-comments.usecase.test.ts b/tests/unit/core/use-cases/comment/get-post-comments.usecase.test.ts index 13e2c0a5..b18c6bed 100644 --- a/tests/unit/core/use-cases/comment/get-post-comments.usecase.test.ts +++ b/tests/unit/core/use-cases/comment/get-post-comments.usecase.test.ts @@ -3,75 +3,177 @@ import { GetPostCommentsUseCase } from "@core/use-cases/comment/get-post-comment import { NotFoundError } from "@core/errors"; import type { ICommentRepository } from "@core/ports/repositories/comment.repository"; import type { IPostRepository } from "@core/ports/repositories/post.repository"; +import type { IArticleRepository } from "@core/ports/repositories/article.repository"; import type { Post } from "@core/domain/entities/post.entity"; -import { buildComment } from "../../../helpers/mock-factories"; +import { ArticleStatus } from "@core/domain/enums/article-status.enum"; +import { buildComment, buildArticle } from "../../../helpers/mock-factories"; 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; let postRepo: Pick; - let commentRepo: Pick; + let articleRepo: Pick; + let commentRepo: Pick; beforeEach(() => { postRepo = { findById: vi.fn() }; - commentRepo = { findTopLevelByPostId: vi.fn() }; + articleRepo = { findById: vi.fn() }; + commentRepo = { findTopLevelByTarget: vi.fn().mockResolvedValue([]) }; useCase = new GetPostCommentsUseCase( commentRepo as ICommentRepository, postRepo as IPostRepository, + articleRepo as IArticleRepository, ); }); - it("should throw NotFoundError when post does not exist", async () => { - vi.mocked(postRepo.findById).mockResolvedValue(null); + describe("post target", () => { + it("should throw NotFoundError when the post does not exist", async () => { + vi.mocked(postRepo.findById).mockResolvedValue(null); - await expect(useCase.execute({ postId: "post-1" })).rejects.toThrow( - NotFoundError, - ); - }); + await expect( + useCase.execute({ target: POST_TARGET }), + ).rejects.toThrow(NotFoundError); + }); - it("should return top-level comments for the post", async () => { - const comments = [buildComment(), buildComment({ id: "comment-2" })]; - vi.mocked(postRepo.findById).mockResolvedValue(buildPost()); - vi.mocked(commentRepo.findTopLevelByPostId).mockResolvedValue(comments); + it("should return top-level comments for the post", async () => { + const comments = [ + buildComment(), + buildComment({ id: "comment-2" }), + ]; + vi.mocked(postRepo.findById).mockResolvedValue(buildPost()); + vi.mocked(commentRepo.findTopLevelByTarget).mockResolvedValue( + comments, + ); - const result = await useCase.execute({ postId: "post-1" }); + const result = await useCase.execute({ target: POST_TARGET }); - expect(result).toBe(comments); - expect(commentRepo.findTopLevelByPostId).toHaveBeenCalledWith( - "post-1", - 10, - 0, - undefined, - ); - }); + expect(result).toBe(comments); + expect(commentRepo.findTopLevelByTarget).toHaveBeenCalledWith( + POST_TARGET, + 10, + 0, + undefined, + ); + }); - it("should calculate offset correctly for pagination", async () => { - vi.mocked(postRepo.findById).mockResolvedValue(buildPost()); - vi.mocked(commentRepo.findTopLevelByPostId).mockResolvedValue([]); + it("should calculate offset correctly for pagination", async () => { + vi.mocked(postRepo.findById).mockResolvedValue(buildPost()); - await useCase.execute({ postId: "post-1", page: 2, limit: 20 }); + await useCase.execute({ + target: POST_TARGET, + page: 2, + limit: 20, + }); - expect(commentRepo.findTopLevelByPostId).toHaveBeenCalledWith( - "post-1", - 20, - 20, - undefined, - ); + expect(commentRepo.findTopLevelByTarget).toHaveBeenCalledWith( + POST_TARGET, + 20, + 20, + undefined, + ); + }); + + it("should pass currentUserId to the repository", async () => { + vi.mocked(postRepo.findById).mockResolvedValue(buildPost()); + + await useCase.execute({ + target: POST_TARGET, + currentUserId: "user-5", + }); + + expect(commentRepo.findTopLevelByTarget).toHaveBeenCalledWith( + POST_TARGET, + 10, + 0, + "user-5", + ); + }); + + it("should not consult the article repository", async () => { + vi.mocked(postRepo.findById).mockResolvedValue(buildPost()); + + await useCase.execute({ target: POST_TARGET }); + + expect(articleRepo.findById).not.toHaveBeenCalled(); + }); }); - it("should pass currentUserId to the repository", async () => { - vi.mocked(postRepo.findById).mockResolvedValue(buildPost()); - vi.mocked(commentRepo.findTopLevelByPostId).mockResolvedValue([]); + describe("article target", () => { + it("should return comments for a published article", async () => { + const comments = [buildComment()]; + vi.mocked(articleRepo.findById).mockResolvedValue( + buildArticle({ status: ArticleStatus.PUBLISHED }), + ); + vi.mocked(commentRepo.findTopLevelByTarget).mockResolvedValue( + comments, + ); - await useCase.execute({ postId: "post-1", currentUserId: "user-5" }); + const result = await useCase.execute({ target: ARTICLE_TARGET }); - expect(commentRepo.findTopLevelByPostId).toHaveBeenCalledWith( - "post-1", - 10, - 0, - "user-5", - ); + expect(result).toBe(comments); + expect(commentRepo.findTopLevelByTarget).toHaveBeenCalledWith( + ARTICLE_TARGET, + 10, + 0, + undefined, + ); + }); + + it("should throw NotFoundError when the article does not exist", async () => { + vi.mocked(articleRepo.findById).mockResolvedValue(null); + + await expect( + useCase.execute({ target: ARTICLE_TARGET }), + ).rejects.toThrow(NotFoundError); + }); + + it("should not let a comment list expose someone else's draft", async () => { + vi.mocked(articleRepo.findById).mockResolvedValue( + buildArticle({ + status: ArticleStatus.DRAFT, + author: { id: "author-1" }, + }), + ); + + await expect( + useCase.execute({ + target: ARTICLE_TARGET, + currentUserId: "stranger", + }), + ).rejects.toThrow(NotFoundError); + + expect(commentRepo.findTopLevelByTarget).not.toHaveBeenCalled(); + }); + + it("should let an author read the comments on their own draft", async () => { + vi.mocked(articleRepo.findById).mockResolvedValue( + buildArticle({ + status: ArticleStatus.DRAFT, + author: { id: "author-1" }, + }), + ); + + await useCase.execute({ + target: ARTICLE_TARGET, + currentUserId: "author-1", + }); + + expect(commentRepo.findTopLevelByTarget).toHaveBeenCalled(); + }); + + it("should not consult the post repository", async () => { + vi.mocked(articleRepo.findById).mockResolvedValue( + buildArticle({ status: ArticleStatus.PUBLISHED }), + ); + + await useCase.execute({ target: ARTICLE_TARGET }); + + expect(postRepo.findById).not.toHaveBeenCalled(); + }); }); }); diff --git a/tests/unit/helpers/mock-factories.ts b/tests/unit/helpers/mock-factories.ts index ce650398..9ef01e8a 100644 --- a/tests/unit/helpers/mock-factories.ts +++ b/tests/unit/helpers/mock-factories.ts @@ -70,6 +70,7 @@ export function buildComment(overrides: Partial = {}): Comment { id: "comment-1", content: "Test comment", postId: "post-1", + articleId: null, authorId: "user-1", parentId: null, createdAt: new Date("2024-01-01T00:00:00Z"),