diff --git a/backend/app/Comment/Comment.php b/backend/app/Comment/Comment.php new file mode 100644 index 0000000..311b6c4 --- /dev/null +++ b/backend/app/Comment/Comment.php @@ -0,0 +1,41 @@ +id; + } + + public function getPostId(): int + { + return $this->postId; + } + + public function getUserId(): int + { + return $this->userId; + } + + public function getBody(): string + { + return $this->body; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/backend/app/Comment/CommentModel.php b/backend/app/Comment/CommentModel.php new file mode 100644 index 0000000..8182d94 --- /dev/null +++ b/backend/app/Comment/CommentModel.php @@ -0,0 +1,43 @@ +|CommentModel newModelQuery() + * @method static Builder|CommentModel newQuery() + * @method static Builder|CommentModel query() + * @method static Builder|CommentModel whereId($value) + * @method static Builder|CommentModel wherePostId($value) + * @method static Builder|CommentModel whereUserId($value) + * @method static Builder|CommentModel whereBody($value) + * @method static Builder|CommentModel whereCreatedAt($value) + * + * @mixin \Eloquent + */ +class CommentModel extends Model +{ + protected $table = 'comments'; + + public $timestamps = false; + + protected $fillable = [ + 'post_id', + 'user_id', + 'body', + 'created_at', + ]; + + protected $casts = [ + 'created_at' => 'immutable_datetime', + ]; +} diff --git a/backend/app/Comment/CommentRepository.php b/backend/app/Comment/CommentRepository.php new file mode 100644 index 0000000..7248d20 --- /dev/null +++ b/backend/app/Comment/CommentRepository.php @@ -0,0 +1,17 @@ + $dto->postId, + 'user_id' => $dto->userId, + 'body' => $dto->body, + 'created_at' => $dto->createdAt, + ]); + + return $this->toDomain($model); + } + + public function find(int $id): ?Comment + { + $model = CommentModel::find($id); + + return $model === null ? null : $this->toDomain($model); + } + + /** + * @return Comment[] + */ + public function findByPostId(int $postId): array + { + $models = CommentModel::query() + ->where('post_id', $postId) + ->orderBy('created_at', 'asc') + ->get(); + + return $models->map( + function (CommentModel $model) { + return $this->toDomain($model); + }, + )->all(); + } + + public function delete(int $id): void + { + CommentModel::query()->where('id', $id)->delete(); + } + + private function toDomain(CommentModel $model): Comment + { + $utc = new DateTimeZone('UTC'); + + return new Comment( + id: $model->id, + postId: $model->post_id, + userId: $model->user_id, + body: $model->body, + createdAt: new DateTimeImmutable( + $model->created_at->toDateTimeString(), + $utc, + ), + ); + } +} diff --git a/backend/app/Comment/UseCases/CreateComment/CreateComment.php b/backend/app/Comment/UseCases/CreateComment/CreateComment.php new file mode 100644 index 0000000..1987b5d --- /dev/null +++ b/backend/app/Comment/UseCases/CreateComment/CreateComment.php @@ -0,0 +1,49 @@ +postId <= 0) { + throw new BadRequestException('postId must be positive'); + } + if ($request->userId <= 0) { + throw new BadRequestException('userId must be positive'); + } + $body = $request->body === null ? '' : trim($request->body); + if ($body === '') { + throw new BadRequestException('body is required'); + } + + if ($this->postRepo->find($request->postId) === null) { + throw new DomainException('post not found'); + } + + return $this->commentRepo->create(new CreateCommentDto( + postId: $request->postId, + userId: $request->userId, + body: $body, + createdAt: $this->clock->now(), + )); + } +} diff --git a/backend/app/Comment/UseCases/CreateComment/CreateCommentRequest.php b/backend/app/Comment/UseCases/CreateComment/CreateCommentRequest.php new file mode 100644 index 0000000..d2984bb --- /dev/null +++ b/backend/app/Comment/UseCases/CreateComment/CreateCommentRequest.php @@ -0,0 +1,12 @@ +commentId <= 0) { + throw new BadRequestException('commentId must be positive'); + } + if ($request->requesterId <= 0) { + throw new BadRequestException('requesterId must be positive'); + } + + $comment = $this->commentRepo->find($request->commentId); + if ($comment === null) { + return; + } + + $isAuthor = $comment->getUserId() === $request->requesterId; + if (! $isAuthor && ! $request->requesterIsAdmin) { + throw new ForbiddenException( + 'requester is not allowed to delete this comment' + ); + } + + $this->commentRepo->delete($request->commentId); + } +} diff --git a/backend/app/Comment/UseCases/DeleteComment/DeleteCommentRequest.php b/backend/app/Comment/UseCases/DeleteComment/DeleteCommentRequest.php new file mode 100644 index 0000000..9dfaa2e --- /dev/null +++ b/backend/app/Comment/UseCases/DeleteComment/DeleteCommentRequest.php @@ -0,0 +1,12 @@ +postId <= 0) { + throw new BadRequestException('postId must be positive'); + } + + return $this->commentRepo->findByPostId($request->postId); + } +} diff --git a/backend/app/Comment/UseCases/ListCommentsForPost/ListCommentsForPostRequest.php b/backend/app/Comment/UseCases/ListCommentsForPost/ListCommentsForPostRequest.php new file mode 100644 index 0000000..6f53289 --- /dev/null +++ b/backend/app/Comment/UseCases/ListCommentsForPost/ListCommentsForPostRequest.php @@ -0,0 +1,10 @@ +id(); + $table->foreignId('post_id') + ->constrained('posts') + ->cascadeOnDelete(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->text('body'); + $table->dateTime('created_at')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('comments'); + } +}; diff --git a/backend/tests/Fakes/FakeCommentRepository.php b/backend/tests/Fakes/FakeCommentRepository.php new file mode 100644 index 0000000..7286f2d --- /dev/null +++ b/backend/tests/Fakes/FakeCommentRepository.php @@ -0,0 +1,82 @@ +getNextId(); + $comment = new Comment( + id: $id, + postId: $dto->postId, + userId: $dto->userId, + body: $dto->body, + createdAt: $dto->createdAt, + ); + $this->existingComments[$id] = $comment; + + return $this->copy($comment); + } + + public function find(int $id): ?Comment + { + $comment = $this->existingComments[$id] ?? null; + if ($comment === null) { + return null; + } + + return $this->copy($comment); + } + + /** + * @return Comment[] + */ + public function findByPostId(int $postId): array + { + $matching = []; + foreach ($this->existingComments as $comment) { + if ($comment->getPostId() === $postId) { + $matching[] = $this->copy($comment); + } + } + usort( + $matching, + function (Comment $left, Comment $right) { + return $left->getCreatedAt() <=> $right->getCreatedAt(); + }, + ); + + return $matching; + } + + public function delete(int $id): void + { + unset($this->existingComments[$id]); + } + + private function copy(Comment $comment): Comment + { + return new Comment( + id: $comment->getId(), + postId: $comment->getPostId(), + userId: $comment->getUserId(), + body: $comment->getBody(), + createdAt: $comment->getCreatedAt(), + ); + } + + private function getNextId(): int + { + return count($this->existingComments) + 1; + } +} diff --git a/backend/tests/Unit/Comment/UseCases/CreateCommentTest.php b/backend/tests/Unit/Comment/UseCases/CreateCommentTest.php new file mode 100644 index 0000000..f35f8e8 --- /dev/null +++ b/backend/tests/Unit/Comment/UseCases/CreateCommentTest.php @@ -0,0 +1,138 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC'), + ); + $this->commentRepo = new FakeCommentRepository; + $this->postRepo = new FakePostRepository; + $this->clock = new FakeClock($this->now); + $this->useCase = new CreateComment( + $this->commentRepo, + $this->postRepo, + $this->clock, + ); + } + + private function seedPost(): int + { + $post = $this->postRepo->create(new CreatePostDto( + userId: 1, + title: 'Some Post', + body: 'Body.', + createdAt: $this->now, + )); + + return $post->getId(); + } + + public function test_zero_post_id_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new CreateCommentRequest( + postId: 0, + userId: 1, + body: 'hi', + )); + } + + public function test_zero_user_id_throws_bad_request(): void + { + $postId = $this->seedPost(); + $this->expectException(BadRequestException::class); + $this->useCase->execute(new CreateCommentRequest( + postId: $postId, + userId: 0, + body: 'hi', + )); + } + + public function test_null_body_throws_bad_request(): void + { + $postId = $this->seedPost(); + $this->expectException(BadRequestException::class); + $this->useCase->execute(new CreateCommentRequest( + postId: $postId, + userId: 1, + body: null, + )); + } + + public function test_blank_body_throws_bad_request(): void + { + $postId = $this->seedPost(); + $this->expectException(BadRequestException::class); + $this->useCase->execute(new CreateCommentRequest( + postId: $postId, + userId: 1, + body: ' ', + )); + } + + public function test_unknown_post_throws_domain_exception(): void + { + $this->expectException(DomainException::class); + $this->useCase->execute(new CreateCommentRequest( + postId: 999, + userId: 1, + body: 'hi', + )); + } + + public function test_valid_create_returns_comment(): void + { + $postId = $this->seedPost(); + $created = $this->useCase->execute(new CreateCommentRequest( + postId: $postId, + userId: 5, + body: ' Hello world ', + )); + + $this->assertSame($postId, $created->getPostId()); + $this->assertSame(5, $created->getUserId()); + $this->assertSame('Hello world', $created->getBody()); + $this->assertEquals($this->now, $created->getCreatedAt()); + } + + public function test_created_comment_is_findable(): void + { + $postId = $this->seedPost(); + $created = $this->useCase->execute(new CreateCommentRequest( + postId: $postId, + userId: 5, + body: 'Hello', + )); + + $found = $this->commentRepo->find($created->getId()); + $this->assertNotNull($found); + $this->assertSame('Hello', $found->getBody()); + } +} diff --git a/backend/tests/Unit/Comment/UseCases/DeleteCommentTest.php b/backend/tests/Unit/Comment/UseCases/DeleteCommentTest.php new file mode 100644 index 0000000..c3752a7 --- /dev/null +++ b/backend/tests/Unit/Comment/UseCases/DeleteCommentTest.php @@ -0,0 +1,131 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC'), + ); + $this->commentRepo = new FakeCommentRepository; + $this->useCase = new DeleteComment($this->commentRepo); + } + + private function seedCommentByUser(int $userId): Comment + { + return $this->commentRepo->create(new CreateCommentDto( + postId: 1, + userId: $userId, + body: 'comment body', + createdAt: $this->now, + )); + } + + public function test_zero_comment_id_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new DeleteCommentRequest( + commentId: 0, + requesterId: 1, + requesterIsAdmin: false, + )); + } + + public function test_zero_requester_id_throws_bad_request(): void + { + $comment = $this->seedCommentByUser(1); + + $this->expectException(BadRequestException::class); + $this->useCase->execute(new DeleteCommentRequest( + commentId: $comment->getId(), + requesterId: 0, + requesterIsAdmin: false, + )); + } + + public function test_unknown_comment_is_no_op(): void + { + $this->useCase->execute(new DeleteCommentRequest( + commentId: 999, + requesterId: 1, + requesterIsAdmin: false, + )); + + $this->assertNull($this->commentRepo->find(999)); + } + + public function test_author_can_delete_own_comment(): void + { + $comment = $this->seedCommentByUser(1); + + $this->useCase->execute(new DeleteCommentRequest( + commentId: $comment->getId(), + requesterId: 1, + requesterIsAdmin: false, + )); + + $this->assertNull($this->commentRepo->find($comment->getId())); + } + + public function test_admin_can_delete_anyones_comment(): void + { + $comment = $this->seedCommentByUser(1); + + $this->useCase->execute(new DeleteCommentRequest( + commentId: $comment->getId(), + requesterId: 99, + requesterIsAdmin: true, + )); + + $this->assertNull($this->commentRepo->find($comment->getId())); + } + + public function test_other_user_cannot_delete_comment(): void + { + $comment = $this->seedCommentByUser(1); + + $this->expectException(ForbiddenException::class); + $this->useCase->execute(new DeleteCommentRequest( + commentId: $comment->getId(), + requesterId: 2, + requesterIsAdmin: false, + )); + } + + public function test_forbidden_delete_does_not_remove_comment(): void + { + $comment = $this->seedCommentByUser(1); + + try { + $this->useCase->execute(new DeleteCommentRequest( + commentId: $comment->getId(), + requesterId: 2, + requesterIsAdmin: false, + )); + } catch (ForbiddenException) { + // expected + } + + $this->assertNotNull($this->commentRepo->find($comment->getId())); + } +} diff --git a/backend/tests/Unit/Comment/UseCases/ListCommentsForPostTest.php b/backend/tests/Unit/Comment/UseCases/ListCommentsForPostTest.php new file mode 100644 index 0000000..7ed8918 --- /dev/null +++ b/backend/tests/Unit/Comment/UseCases/ListCommentsForPostTest.php @@ -0,0 +1,73 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC'), + ); + $this->commentRepo = new FakeCommentRepository; + $this->useCase = new ListCommentsForPost($this->commentRepo); + } + + private function seedComment(int $postId, string $body, string $offset): void + { + $this->commentRepo->create(new CreateCommentDto( + postId: $postId, + userId: 1, + body: $body, + createdAt: $this->now->modify($offset), + )); + } + + public function test_zero_post_id_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new ListCommentsForPostRequest( + postId: 0, + )); + } + + public function test_returns_empty_list_when_no_comments(): void + { + $result = $this->useCase->execute(new ListCommentsForPostRequest( + postId: 1, + )); + + $this->assertSame([], $result); + } + + public function test_returns_only_comments_for_given_post(): void + { + $this->seedComment(1, 'first', '+0 seconds'); + $this->seedComment(2, 'other-post', '+0 seconds'); + $this->seedComment(1, 'second', '+1 minute'); + + $result = $this->useCase->execute(new ListCommentsForPostRequest( + postId: 1, + )); + + $this->assertCount(2, $result); + $this->assertSame('first', $result[0]->getBody()); + $this->assertSame('second', $result[1]->getBody()); + } +}