From 73a3acd39fba481c928395188077a07fa8fda939 Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:19:00 +0300 Subject: [PATCH 01/12] add Post entity, dto, repository interface Post: id, userId (fk -> User), title, body, createdAt as DateTimeImmutable. CreatePostDto readonly with explicit createdAt (use case supplies it via Clock; entity remains pure). PostRepository: create, find, findByUserId, findRecent (limit), delete. --- backend/app/Post/CreatePostDto.php | 15 +++++++++++ backend/app/Post/Post.php | 41 +++++++++++++++++++++++++++++ backend/app/Post/PostRepository.php | 22 ++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 backend/app/Post/CreatePostDto.php create mode 100644 backend/app/Post/Post.php create mode 100644 backend/app/Post/PostRepository.php diff --git a/backend/app/Post/CreatePostDto.php b/backend/app/Post/CreatePostDto.php new file mode 100644 index 0000000..255f2e9 --- /dev/null +++ b/backend/app/Post/CreatePostDto.php @@ -0,0 +1,15 @@ +id; + } + + public function getUserId(): int + { + return $this->userId; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getBody(): string + { + return $this->body; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } +} diff --git a/backend/app/Post/PostRepository.php b/backend/app/Post/PostRepository.php new file mode 100644 index 0000000..a00827b --- /dev/null +++ b/backend/app/Post/PostRepository.php @@ -0,0 +1,22 @@ + Date: Wed, 6 May 2026 15:22:22 +0300 Subject: [PATCH 02/12] add Post persistence: model, migration, eloquent + fake repo PostModel maps posts table (id, user_id fk, title, body text, created_at indexed). EloquentPostRepository: create, find, findByUserId (desc by created_at), findRecent (limit, desc), delete - chain via ::query() to keep larastan happy. FakePostRepository sorts on read (defensive copy each return). cascade-on-delete on user_id so removing a user nukes their posts. phpstan.neon suppresses staticMethod.dynamicCall under app/*/Eloquent*Repository.php - phpstan-strict-rules flags Eloquent's fluent builder idiom (Model::query()->orderBy()) because the static methods become instance calls mid-chain. suppression scoped to repo files only so the rule still applies elsewhere. --- backend/app/Post/EloquentPostRepository.php | 83 ++++++++++++++ backend/app/Post/PostModel.php | 40 +++++++ .../2026_05_06_000002_create_posts_table.php | 26 +++++ backend/phpstan.neon | 9 ++ backend/tests/Fakes/FakePostRepository.php | 103 ++++++++++++++++++ 5 files changed, 261 insertions(+) create mode 100644 backend/app/Post/EloquentPostRepository.php create mode 100644 backend/app/Post/PostModel.php create mode 100644 backend/database/migrations/2026_05_06_000002_create_posts_table.php create mode 100644 backend/tests/Fakes/FakePostRepository.php diff --git a/backend/app/Post/EloquentPostRepository.php b/backend/app/Post/EloquentPostRepository.php new file mode 100644 index 0000000..b8c90b5 --- /dev/null +++ b/backend/app/Post/EloquentPostRepository.php @@ -0,0 +1,83 @@ + $dto->userId, + 'title' => $dto->title, + 'body' => $dto->body, + 'created_at' => $dto->createdAt, + ]); + + return $this->toDomain($model); + } + + public function find(int $id): ?Post + { + $model = PostModel::find($id); + + return $model === null ? null : $this->toDomain($model); + } + + /** + * @return Post[] + */ + public function findByUserId(int $userId): array + { + $models = PostModel::query() + ->where('user_id', $userId) + ->orderBy('created_at', 'desc') + ->get(); + + return $models->map( + function (PostModel $model) { + return $this->toDomain($model); + }, + )->all(); + } + + /** + * @return Post[] + */ + public function findRecent(int $limit): array + { + $models = PostModel::query() + ->orderBy('created_at', 'desc') + ->limit($limit) + ->get(); + + return $models->map( + function (PostModel $model) { + return $this->toDomain($model); + }, + )->all(); + } + + public function delete(int $id): void + { + PostModel::query()->where('id', $id)->delete(); + } + + private function toDomain(PostModel $model): Post + { + $utc = new DateTimeZone('UTC'); + + return new Post( + id: $model->id, + userId: $model->user_id, + title: $model->title, + body: $model->body, + createdAt: new DateTimeImmutable( + $model->created_at->toDateTimeString(), + $utc, + ), + ); + } +} diff --git a/backend/app/Post/PostModel.php b/backend/app/Post/PostModel.php new file mode 100644 index 0000000..357347b --- /dev/null +++ b/backend/app/Post/PostModel.php @@ -0,0 +1,40 @@ +|PostModel newModelQuery() + * @method static Builder|PostModel newQuery() + * @method static Builder|PostModel query() + * @method static Builder|PostModel whereId($value) + * @method static Builder|PostModel whereUserId($value) + * + * @mixin \Eloquent + */ +class PostModel extends Model +{ + protected $table = 'posts'; + + public $timestamps = false; + + protected $fillable = [ + 'user_id', + 'title', + 'body', + 'created_at', + ]; + + protected $casts = [ + 'created_at' => 'datetime', + ]; +} diff --git a/backend/database/migrations/2026_05_06_000002_create_posts_table.php b/backend/database/migrations/2026_05_06_000002_create_posts_table.php new file mode 100644 index 0000000..da968a1 --- /dev/null +++ b/backend/database/migrations/2026_05_06_000002_create_posts_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->string('title'); + $table->text('body'); + $table->dateTime('created_at')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('posts'); + } +}; diff --git a/backend/phpstan.neon b/backend/phpstan.neon index 0ef48ce..c2504d4 100644 --- a/backend/phpstan.neon +++ b/backend/phpstan.neon @@ -7,6 +7,15 @@ parameters: treatPhpDocTypesAsCertain: false reportUnmatchedIgnoredErrors: false + ignoreErrors: + # Eloquent's fluent builder triggers staticMethod.dynamicCall in + # phpstan-strict-rules because Builder methods declared static on + # the model become instance calls after the first chain link. This + # is the documented Laravel idiom; suppress the false positive. + - + identifier: staticMethod.dynamicCall + path: app/*/Eloquent*Repository.php + paths: - app diff --git a/backend/tests/Fakes/FakePostRepository.php b/backend/tests/Fakes/FakePostRepository.php new file mode 100644 index 0000000..ed64137 --- /dev/null +++ b/backend/tests/Fakes/FakePostRepository.php @@ -0,0 +1,103 @@ +getNextId(); + $post = new Post( + id: $id, + userId: $dto->userId, + title: $dto->title, + body: $dto->body, + createdAt: $dto->createdAt, + ); + $this->existingPosts[$id] = $post; + + return $post; + } + + public function find(int $id): ?Post + { + $post = $this->existingPosts[$id] ?? null; + if ($post === null) { + return null; + } + + return $this->copy($post); + } + + /** + * @return Post[] + */ + public function findByUserId(int $userId): array + { + $matching = []; + foreach ($this->existingPosts as $post) { + if ($post->getUserId() === $userId) { + $matching[] = $this->copy($post); + } + } + usort( + $matching, + function (Post $left, Post $right) { + return $right->getCreatedAt() <=> $left->getCreatedAt(); + }, + ); + + return $matching; + } + + /** + * @return Post[] + */ + public function findRecent(int $limit): array + { + $all = array_map( + function (Post $post) { + return $this->copy($post); + }, + array_values($this->existingPosts), + ); + usort( + $all, + function (Post $left, Post $right) { + return $right->getCreatedAt() <=> $left->getCreatedAt(); + }, + ); + + return array_slice($all, 0, $limit); + } + + public function delete(int $id): void + { + unset($this->existingPosts[$id]); + } + + private function copy(Post $post): Post + { + return new Post( + id: $post->getId(), + userId: $post->getUserId(), + title: $post->getTitle(), + body: $post->getBody(), + createdAt: $post->getCreatedAt(), + ); + } + + private function getNextId(): int + { + return count($this->existingPosts) + 1; + } +} From 504554bf7fb07b9b0ba336f8ea8fccb19b3abbb0 Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:22:57 +0300 Subject: [PATCH 03/12] test CreatePost use case 7 cases: null + whitespace title -> BadRequest; null + whitespace body -> BadRequest; valid request returns Post with correct userId/title/body and createdAt = clock.now(); the post is findable via the repo afterwards; title and body get trimmed of leading/trailing whitespace. fails red - CreatePost class absent. --- .../Unit/Post/UseCases/CreatePostTest.php | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 backend/tests/Unit/Post/UseCases/CreatePostTest.php diff --git a/backend/tests/Unit/Post/UseCases/CreatePostTest.php b/backend/tests/Unit/Post/UseCases/CreatePostTest.php new file mode 100644 index 0000000..f7ebefd --- /dev/null +++ b/backend/tests/Unit/Post/UseCases/CreatePostTest.php @@ -0,0 +1,118 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC') + ); + $this->postRepo = new FakePostRepository; + $this->clock = new FakeClock($this->now); + $this->useCase = new CreatePost( + $this->postRepo, + $this->clock, + ); + } + + public function test_null_title_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new CreatePostRequest( + userId: 7, + title: null, + body: 'some body content', + )); + } + + public function test_empty_title_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new CreatePostRequest( + userId: 7, + title: ' ', + body: 'some body content', + )); + } + + public function test_null_body_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new CreatePostRequest( + userId: 7, + title: 'My Post', + body: null, + )); + } + + public function test_empty_body_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new CreatePostRequest( + userId: 7, + title: 'My Post', + body: ' ', + )); + } + + public function test_valid_request_returns_post(): void + { + $post = $this->useCase->execute(new CreatePostRequest( + userId: 7, + title: 'My Post', + body: 'Some body content here.', + )); + + $this->assertInstanceOf(Post::class, $post); + $this->assertSame(7, $post->getUserId()); + $this->assertSame('My Post', $post->getTitle()); + $this->assertSame('Some body content here.', $post->getBody()); + $this->assertEquals($this->now, $post->getCreatedAt()); + } + + public function test_post_is_findable_after_creation(): void + { + $created = $this->useCase->execute(new CreatePostRequest( + userId: 7, + title: 'My Post', + body: 'Some body content here.', + )); + + $found = $this->postRepo->find($created->getId()); + $this->assertNotNull($found); + $this->assertSame('My Post', $found->getTitle()); + } + + public function test_title_and_body_are_trimmed(): void + { + $post = $this->useCase->execute(new CreatePostRequest( + userId: 7, + title: ' Padded Title ', + body: " Padded body \n", + )); + + $this->assertSame('Padded Title', $post->getTitle()); + $this->assertSame('Padded body', $post->getBody()); + } +} From 4a4e046de43d08a08fde042dd994dba84341302e Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:23:21 +0300 Subject: [PATCH 04/12] implement CreatePost use case trims title and body, rejects empty (post-trim) values with BadRequest. supplies createdAt from injected Clock. persists through PostRepository->create and returns the resulting Post. 44 tests pass. --- .../Post/UseCases/CreatePost/CreatePost.php | 40 +++++++++++++++++++ .../UseCases/CreatePost/CreatePostRequest.php | 12 ++++++ 2 files changed, 52 insertions(+) create mode 100644 backend/app/Post/UseCases/CreatePost/CreatePost.php create mode 100644 backend/app/Post/UseCases/CreatePost/CreatePostRequest.php diff --git a/backend/app/Post/UseCases/CreatePost/CreatePost.php b/backend/app/Post/UseCases/CreatePost/CreatePost.php new file mode 100644 index 0000000..0008a08 --- /dev/null +++ b/backend/app/Post/UseCases/CreatePost/CreatePost.php @@ -0,0 +1,40 @@ +title === null ? '' : trim($request->title); + $body = $request->body === null ? '' : trim($request->body); + + if ($title === '') { + throw new BadRequestException('title is required'); + } + if ($body === '') { + throw new BadRequestException('body is required'); + } + + return $this->postRepo->create(new CreatePostDto( + userId: $request->userId, + title: $title, + body: $body, + createdAt: $this->clock->now(), + )); + } +} diff --git a/backend/app/Post/UseCases/CreatePost/CreatePostRequest.php b/backend/app/Post/UseCases/CreatePost/CreatePostRequest.php new file mode 100644 index 0000000..c3aea18 --- /dev/null +++ b/backend/app/Post/UseCases/CreatePost/CreatePostRequest.php @@ -0,0 +1,12 @@ + Date: Wed, 6 May 2026 15:23:53 +0300 Subject: [PATCH 05/12] test ListRecentPosts use case 5 cases: zero/negative limit -> BadRequest; empty repo -> []; returns posts ordered newest-first; respects limit truncation. fails red - ListRecentPosts class missing. --- .../Post/UseCases/ListRecentPostsTest.php | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 backend/tests/Unit/Post/UseCases/ListRecentPostsTest.php diff --git a/backend/tests/Unit/Post/UseCases/ListRecentPostsTest.php b/backend/tests/Unit/Post/UseCases/ListRecentPostsTest.php new file mode 100644 index 0000000..b5e8a02 --- /dev/null +++ b/backend/tests/Unit/Post/UseCases/ListRecentPostsTest.php @@ -0,0 +1,94 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC') + ); + $this->postRepo = new FakePostRepository; + $this->useCase = new ListRecentPosts($this->postRepo); + } + + private function seedPost(int $userId, string $title, string $offset): void + { + $this->postRepo->create(new CreatePostDto( + userId: $userId, + title: $title, + body: 'body', + createdAt: $this->now->modify($offset), + )); + } + + public function test_zero_limit_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new ListRecentPostsRequest(limit: 0)); + } + + public function test_negative_limit_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new ListRecentPostsRequest(limit: -1)); + } + + public function test_empty_repo_returns_empty_array(): void + { + $posts = $this->useCase->execute( + new ListRecentPostsRequest(limit: 10) + ); + + $this->assertSame([], $posts); + } + + public function test_returns_posts_ordered_newest_first(): void + { + $this->seedPost(1, 'oldest', '-2 days'); + $this->seedPost(2, 'middle', '-1 day'); + $this->seedPost(3, 'newest', '-1 hour'); + + $posts = $this->useCase->execute( + new ListRecentPostsRequest(limit: 10) + ); + + $this->assertCount(3, $posts); + $this->assertSame('newest', $posts[0]->getTitle()); + $this->assertSame('middle', $posts[1]->getTitle()); + $this->assertSame('oldest', $posts[2]->getTitle()); + } + + public function test_respects_limit(): void + { + $this->seedPost(1, 'first', '-3 days'); + $this->seedPost(2, 'second', '-2 days'); + $this->seedPost(3, 'third', '-1 day'); + $this->seedPost(4, 'fourth', '-1 hour'); + + $posts = $this->useCase->execute( + new ListRecentPostsRequest(limit: 2) + ); + + $this->assertCount(2, $posts); + $this->assertSame('fourth', $posts[0]->getTitle()); + $this->assertSame('third', $posts[1]->getTitle()); + } +} From 7ec46aa8f9eeffd71b745ff3a378f68d44cb2df2 Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:24:15 +0300 Subject: [PATCH 06/12] implement ListRecentPosts use case validates limit > 0 (zero or negative -> BadRequest), then delegates to PostRepository->findRecent. 49 tests pass. --- .../ListRecentPosts/ListRecentPosts.php | 28 +++++++++++++++++++ .../ListRecentPostsRequest.php | 10 +++++++ 2 files changed, 38 insertions(+) create mode 100644 backend/app/Post/UseCases/ListRecentPosts/ListRecentPosts.php create mode 100644 backend/app/Post/UseCases/ListRecentPosts/ListRecentPostsRequest.php diff --git a/backend/app/Post/UseCases/ListRecentPosts/ListRecentPosts.php b/backend/app/Post/UseCases/ListRecentPosts/ListRecentPosts.php new file mode 100644 index 0000000..f559d27 --- /dev/null +++ b/backend/app/Post/UseCases/ListRecentPosts/ListRecentPosts.php @@ -0,0 +1,28 @@ +limit <= 0) { + throw new BadRequestException('limit must be positive'); + } + + return $this->postRepo->findRecent($request->limit); + } +} diff --git a/backend/app/Post/UseCases/ListRecentPosts/ListRecentPostsRequest.php b/backend/app/Post/UseCases/ListRecentPosts/ListRecentPostsRequest.php new file mode 100644 index 0000000..10089e6 --- /dev/null +++ b/backend/app/Post/UseCases/ListRecentPosts/ListRecentPostsRequest.php @@ -0,0 +1,10 @@ + Date: Wed, 6 May 2026 15:24:41 +0300 Subject: [PATCH 07/12] test ListUserPosts use case 5 cases: zero/negative userId -> BadRequest; user with no posts -> []; returns only requested user's posts (filters out other authors); ordered newest-first by createdAt. fails red. --- .../Unit/Post/UseCases/ListUserPostsTest.php | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 backend/tests/Unit/Post/UseCases/ListUserPostsTest.php diff --git a/backend/tests/Unit/Post/UseCases/ListUserPostsTest.php b/backend/tests/Unit/Post/UseCases/ListUserPostsTest.php new file mode 100644 index 0000000..6d24b30 --- /dev/null +++ b/backend/tests/Unit/Post/UseCases/ListUserPostsTest.php @@ -0,0 +1,95 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC') + ); + $this->postRepo = new FakePostRepository; + $this->useCase = new ListUserPosts($this->postRepo); + } + + private function seedPost(int $userId, string $title, string $offset): void + { + $this->postRepo->create(new CreatePostDto( + userId: $userId, + title: $title, + body: 'body', + createdAt: $this->now->modify($offset), + )); + } + + public function test_zero_user_id_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new ListUserPostsRequest(userId: 0)); + } + + public function test_negative_user_id_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new ListUserPostsRequest(userId: -1)); + } + + public function test_user_with_no_posts_returns_empty_array(): void + { + $this->seedPost(2, 'other-user-post', '-1 hour'); + + $posts = $this->useCase->execute( + new ListUserPostsRequest(userId: 1) + ); + + $this->assertSame([], $posts); + } + + public function test_returns_only_requested_users_posts(): void + { + $this->seedPost(1, 'mine-1', '-2 days'); + $this->seedPost(2, 'theirs', '-1 day'); + $this->seedPost(1, 'mine-2', '-1 hour'); + + $posts = $this->useCase->execute( + new ListUserPostsRequest(userId: 1) + ); + + $this->assertCount(2, $posts); + foreach ($posts as $post) { + $this->assertSame(1, $post->getUserId()); + } + } + + public function test_returns_posts_ordered_newest_first(): void + { + $this->seedPost(1, 'first', '-3 days'); + $this->seedPost(1, 'second', '-2 days'); + $this->seedPost(1, 'third', '-1 hour'); + + $posts = $this->useCase->execute( + new ListUserPostsRequest(userId: 1) + ); + + $this->assertSame('third', $posts[0]->getTitle()); + $this->assertSame('second', $posts[1]->getTitle()); + $this->assertSame('first', $posts[2]->getTitle()); + } +} From 32cbf4229c0196741b9966613cece6e42997c3ad Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:25:07 +0300 Subject: [PATCH 08/12] implement ListUserPosts use case validates userId > 0, delegates to PostRepository->findByUserId. 54 tests pass. --- .../UseCases/ListUserPosts/ListUserPosts.php | 28 +++++++++++++++++++ .../ListUserPosts/ListUserPostsRequest.php | 10 +++++++ 2 files changed, 38 insertions(+) create mode 100644 backend/app/Post/UseCases/ListUserPosts/ListUserPosts.php create mode 100644 backend/app/Post/UseCases/ListUserPosts/ListUserPostsRequest.php diff --git a/backend/app/Post/UseCases/ListUserPosts/ListUserPosts.php b/backend/app/Post/UseCases/ListUserPosts/ListUserPosts.php new file mode 100644 index 0000000..c0c305e --- /dev/null +++ b/backend/app/Post/UseCases/ListUserPosts/ListUserPosts.php @@ -0,0 +1,28 @@ +userId <= 0) { + throw new BadRequestException('userId must be positive'); + } + + return $this->postRepo->findByUserId($request->userId); + } +} diff --git a/backend/app/Post/UseCases/ListUserPosts/ListUserPostsRequest.php b/backend/app/Post/UseCases/ListUserPosts/ListUserPostsRequest.php new file mode 100644 index 0000000..6a5a6a5 --- /dev/null +++ b/backend/app/Post/UseCases/ListUserPosts/ListUserPostsRequest.php @@ -0,0 +1,10 @@ + Date: Wed, 6 May 2026 15:25:36 +0300 Subject: [PATCH 09/12] test GetPost use case 4 cases: zero/negative id -> BadRequest; unknown id -> null (controller maps to 404); existing id returns the Post. GetPost takes int id directly (no Request object - the value is trivial and controllers pull it from a route param). --- .../tests/Unit/Post/UseCases/GetPostTest.php | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 backend/tests/Unit/Post/UseCases/GetPostTest.php diff --git a/backend/tests/Unit/Post/UseCases/GetPostTest.php b/backend/tests/Unit/Post/UseCases/GetPostTest.php new file mode 100644 index 0000000..d0e2740 --- /dev/null +++ b/backend/tests/Unit/Post/UseCases/GetPostTest.php @@ -0,0 +1,66 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC') + ); + $this->postRepo = new FakePostRepository; + $this->useCase = new GetPost($this->postRepo); + } + + public function test_zero_id_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(0); + } + + public function test_negative_id_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(-5); + } + + public function test_unknown_id_returns_null(): void + { + $result = $this->useCase->execute(999); + + $this->assertNull($result); + } + + public function test_existing_id_returns_post(): void + { + $created = $this->postRepo->create(new CreatePostDto( + userId: 7, + title: 'My Post', + body: 'Some body content.', + createdAt: $this->now, + )); + + $result = $this->useCase->execute($created->getId()); + + $this->assertInstanceOf(Post::class, $result); + $this->assertSame('My Post', $result->getTitle()); + $this->assertSame(7, $result->getUserId()); + } +} From 7fda18dde3f609cb9719b56e2deb46cbe2ee7ece Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:25:56 +0300 Subject: [PATCH 10/12] implement GetPost use case validates id > 0, delegates to PostRepository->find. 58 tests pass. --- backend/app/Post/UseCases/GetPost/GetPost.php | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 backend/app/Post/UseCases/GetPost/GetPost.php diff --git a/backend/app/Post/UseCases/GetPost/GetPost.php b/backend/app/Post/UseCases/GetPost/GetPost.php new file mode 100644 index 0000000..fcf7d80 --- /dev/null +++ b/backend/app/Post/UseCases/GetPost/GetPost.php @@ -0,0 +1,26 @@ +postRepo->find($id); + } +} From fd91da6bab68a977c6ff85a7865ccd8fd396f7e7 Mon Sep 17 00:00:00 2001 From: yisroel Date: Wed, 6 May 2026 15:26:28 +0300 Subject: [PATCH 11/12] test DeletePost use case 7 cases: zero postId or requesterId -> BadRequest; unknown post is idempotent no-op; author can delete own post; admin can delete anyone's post; non-author non-admin -> ForbiddenException; forbidden attempts leave post intact. --- .../Unit/Post/UseCases/DeletePostTest.php | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 backend/tests/Unit/Post/UseCases/DeletePostTest.php diff --git a/backend/tests/Unit/Post/UseCases/DeletePostTest.php b/backend/tests/Unit/Post/UseCases/DeletePostTest.php new file mode 100644 index 0000000..699c9d1 --- /dev/null +++ b/backend/tests/Unit/Post/UseCases/DeletePostTest.php @@ -0,0 +1,132 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC') + ); + $this->postRepo = new FakePostRepository; + $this->useCase = new DeletePost($this->postRepo); + } + + private function seedPostByUser(int $userId): Post + { + return $this->postRepo->create(new CreatePostDto( + userId: $userId, + title: 'Some Post', + body: 'Some body.', + createdAt: $this->now, + )); + } + + public function test_zero_post_id_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new DeletePostRequest( + postId: 0, + requesterId: 1, + requesterIsAdmin: false, + )); + } + + public function test_zero_requester_id_throws_bad_request(): void + { + $post = $this->seedPostByUser(1); + + $this->expectException(BadRequestException::class); + $this->useCase->execute(new DeletePostRequest( + postId: $post->getId(), + requesterId: 0, + requesterIsAdmin: false, + )); + } + + public function test_unknown_post_is_no_op(): void + { + $this->useCase->execute(new DeletePostRequest( + postId: 999, + requesterId: 1, + requesterIsAdmin: false, + )); + + // No exception thrown -> idempotent delete on missing post. + $this->assertNull($this->postRepo->find(999)); + } + + public function test_author_can_delete_own_post(): void + { + $post = $this->seedPostByUser(1); + + $this->useCase->execute(new DeletePostRequest( + postId: $post->getId(), + requesterId: 1, + requesterIsAdmin: false, + )); + + $this->assertNull($this->postRepo->find($post->getId())); + } + + public function test_admin_can_delete_anyones_post(): void + { + $post = $this->seedPostByUser(1); + + $this->useCase->execute(new DeletePostRequest( + postId: $post->getId(), + requesterId: 99, + requesterIsAdmin: true, + )); + + $this->assertNull($this->postRepo->find($post->getId())); + } + + public function test_other_user_cannot_delete_post(): void + { + $post = $this->seedPostByUser(1); + + $this->expectException(ForbiddenException::class); + $this->useCase->execute(new DeletePostRequest( + postId: $post->getId(), + requesterId: 2, + requesterIsAdmin: false, + )); + } + + public function test_forbidden_delete_does_not_remove_post(): void + { + $post = $this->seedPostByUser(1); + + try { + $this->useCase->execute(new DeletePostRequest( + postId: $post->getId(), + requesterId: 2, + requesterIsAdmin: false, + )); + } catch (ForbiddenException) { + // expected + } + + $this->assertNotNull($this->postRepo->find($post->getId())); + } +} From e9ac16377fea48337f90384927db330c37e25839 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 21:58:25 +0300 Subject: [PATCH 12/12] implement DeletePost use case --- .../Post/UseCases/DeletePost/DeletePost.php | 42 +++++++++++++++++++ .../UseCases/DeletePost/DeletePostRequest.php | 12 ++++++ 2 files changed, 54 insertions(+) create mode 100644 backend/app/Post/UseCases/DeletePost/DeletePost.php create mode 100644 backend/app/Post/UseCases/DeletePost/DeletePostRequest.php diff --git a/backend/app/Post/UseCases/DeletePost/DeletePost.php b/backend/app/Post/UseCases/DeletePost/DeletePost.php new file mode 100644 index 0000000..b6fa928 --- /dev/null +++ b/backend/app/Post/UseCases/DeletePost/DeletePost.php @@ -0,0 +1,42 @@ +postId <= 0) { + throw new BadRequestException('postId must be positive'); + } + if ($request->requesterId <= 0) { + throw new BadRequestException('requesterId must be positive'); + } + + $post = $this->postRepo->find($request->postId); + if ($post === null) { + return; + } + + $isAuthor = $post->getUserId() === $request->requesterId; + if (! $isAuthor && ! $request->requesterIsAdmin) { + throw new ForbiddenException( + 'requester is not allowed to delete this post' + ); + } + + $this->postRepo->delete($request->postId); + } +} diff --git a/backend/app/Post/UseCases/DeletePost/DeletePostRequest.php b/backend/app/Post/UseCases/DeletePost/DeletePostRequest.php new file mode 100644 index 0000000..a62f2a5 --- /dev/null +++ b/backend/app/Post/UseCases/DeletePost/DeletePostRequest.php @@ -0,0 +1,12 @@ +