From 0d589340d9f9f841f3256682b859d2117daacb4d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:13:37 +0300 Subject: [PATCH 1/8] add Comment entity, dto, repository interface --- backend/app/Comment/Comment.php | 41 +++++++++++++++++++++++ backend/app/Comment/CommentRepository.php | 17 ++++++++++ backend/app/Comment/CreateCommentDto.php | 15 +++++++++ 3 files changed, 73 insertions(+) create mode 100644 backend/app/Comment/Comment.php create mode 100644 backend/app/Comment/CommentRepository.php create mode 100644 backend/app/Comment/CreateCommentDto.php 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/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 @@ + Date: Wed, 6 May 2026 22:14:11 +0300 Subject: [PATCH 2/8] add Comment persistence: model, migration, eloquent + fake repo --- backend/app/Comment/CommentModel.php | 43 ++++++++++ .../app/Comment/EloquentCommentRepository.php | 66 +++++++++++++++ ...026_05_06_000004_create_comments_table.php | 28 +++++++ backend/tests/Fakes/FakeCommentRepository.php | 82 +++++++++++++++++++ 4 files changed, 219 insertions(+) create mode 100644 backend/app/Comment/CommentModel.php create mode 100644 backend/app/Comment/EloquentCommentRepository.php create mode 100644 backend/database/migrations/2026_05_06_000004_create_comments_table.php create mode 100644 backend/tests/Fakes/FakeCommentRepository.php 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/EloquentCommentRepository.php b/backend/app/Comment/EloquentCommentRepository.php new file mode 100644 index 0000000..c2a3f5b --- /dev/null +++ b/backend/app/Comment/EloquentCommentRepository.php @@ -0,0 +1,66 @@ + $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/database/migrations/2026_05_06_000004_create_comments_table.php b/backend/database/migrations/2026_05_06_000004_create_comments_table.php new file mode 100644 index 0000000..eea8665 --- /dev/null +++ b/backend/database/migrations/2026_05_06_000004_create_comments_table.php @@ -0,0 +1,28 @@ +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; + } +} From 2557c9b6a901674480a4c6c74ae83777e8175cbc Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:14:31 +0300 Subject: [PATCH 3/8] test CreateComment use case --- .../Comment/UseCases/CreateCommentTest.php | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 backend/tests/Unit/Comment/UseCases/CreateCommentTest.php 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()); + } +} From e8d2ff3fdfb02014c5e0576a06beab3fd380dcf4 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:14:55 +0300 Subject: [PATCH 4/8] implement CreateComment use case --- .../UseCases/CreateComment/CreateComment.php | 49 +++++++++++++++++++ .../CreateComment/CreateCommentRequest.php | 12 +++++ 2 files changed, 61 insertions(+) create mode 100644 backend/app/Comment/UseCases/CreateComment/CreateComment.php create mode 100644 backend/app/Comment/UseCases/CreateComment/CreateCommentRequest.php 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 @@ + Date: Wed, 6 May 2026 22:15:10 +0300 Subject: [PATCH 5/8] test ListCommentsForPost use case --- .../UseCases/ListCommentsForPostTest.php | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 backend/tests/Unit/Comment/UseCases/ListCommentsForPostTest.php diff --git a/backend/tests/Unit/Comment/UseCases/ListCommentsForPostTest.php b/backend/tests/Unit/Comment/UseCases/ListCommentsForPostTest.php new file mode 100644 index 0000000..b69a25e --- /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 seed(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->seed(1, 'first', '+0 seconds'); + $this->seed(2, 'other-post', '+0 seconds'); + $this->seed(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()); + } +} From a59fc4890ffc467a5ae840e605eed5c1841c324a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:15:49 +0300 Subject: [PATCH 6/8] implement ListCommentsForPost use case Renames seed() helper to seedComment() to avoid clashing with Illuminate\Foundation\Testing\TestCase::seed(). --- .../ListCommentsForPost.php | 28 +++++++++++++++++++ .../ListCommentsForPostRequest.php | 10 +++++++ .../UseCases/ListCommentsForPostTest.php | 8 +++--- 3 files changed, 42 insertions(+), 4 deletions(-) create mode 100644 backend/app/Comment/UseCases/ListCommentsForPost/ListCommentsForPost.php create mode 100644 backend/app/Comment/UseCases/ListCommentsForPost/ListCommentsForPostRequest.php diff --git a/backend/app/Comment/UseCases/ListCommentsForPost/ListCommentsForPost.php b/backend/app/Comment/UseCases/ListCommentsForPost/ListCommentsForPost.php new file mode 100644 index 0000000..13cf6c4 --- /dev/null +++ b/backend/app/Comment/UseCases/ListCommentsForPost/ListCommentsForPost.php @@ -0,0 +1,28 @@ +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 @@ +useCase = new ListCommentsForPost($this->commentRepo); } - private function seed(int $postId, string $body, string $offset): void + private function seedComment(int $postId, string $body, string $offset): void { $this->commentRepo->create(new CreateCommentDto( postId: $postId, @@ -58,9 +58,9 @@ class ListCommentsForPostTest extends TestCase public function test_returns_only_comments_for_given_post(): void { - $this->seed(1, 'first', '+0 seconds'); - $this->seed(2, 'other-post', '+0 seconds'); - $this->seed(1, 'second', '+1 minute'); + $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, From 5bbef871dbfb391f6cc493dd91d6ad051c573a8c Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:16:10 +0300 Subject: [PATCH 7/8] test DeleteComment use case --- .../Comment/UseCases/DeleteCommentTest.php | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 backend/tests/Unit/Comment/UseCases/DeleteCommentTest.php 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())); + } +} From d24bde3761a9d45a58b70e65f1ecc3752b69f111 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:16:34 +0300 Subject: [PATCH 8/8] implement DeleteComment use case --- .../UseCases/DeleteComment/DeleteComment.php | 42 +++++++++++++++++++ .../DeleteComment/DeleteCommentRequest.php | 12 ++++++ 2 files changed, 54 insertions(+) create mode 100644 backend/app/Comment/UseCases/DeleteComment/DeleteComment.php create mode 100644 backend/app/Comment/UseCases/DeleteComment/DeleteCommentRequest.php diff --git a/backend/app/Comment/UseCases/DeleteComment/DeleteComment.php b/backend/app/Comment/UseCases/DeleteComment/DeleteComment.php new file mode 100644 index 0000000..daa8798 --- /dev/null +++ b/backend/app/Comment/UseCases/DeleteComment/DeleteComment.php @@ -0,0 +1,42 @@ +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 @@ +