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 @@ + $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/Post.php b/backend/app/Post/Post.php new file mode 100644 index 0000000..76d100e --- /dev/null +++ b/backend/app/Post/Post.php @@ -0,0 +1,41 @@ +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/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/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 @@ +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 @@ +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 @@ +postRepo->find($id); + } +} 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 @@ +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 @@ +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; + } +} 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()); + } +} 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())); + } +} 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()); + } +} 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()); + } +} 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()); + } +}