From 64a334c63e9eadefa6029a337a1fc79705bc0b12 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:27:18 +0300 Subject: [PATCH 01/10] test Post entity feature slot --- backend/tests/Unit/Post/PostTest.php | 50 ++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 backend/tests/Unit/Post/PostTest.php diff --git a/backend/tests/Unit/Post/PostTest.php b/backend/tests/Unit/Post/PostTest.php new file mode 100644 index 0000000..bbd5594 --- /dev/null +++ b/backend/tests/Unit/Post/PostTest.php @@ -0,0 +1,50 @@ +assertSame(7, $post->getId()); + $this->assertSame(3, $post->getUserId()); + $this->assertSame('Hello', $post->getTitle()); + $this->assertSame('World', $post->getBody()); + $this->assertSame($createdAt, $post->getCreatedAt()); + $this->assertSame(1, $post->getFeatureSlot()); + $this->assertTrue($post->isFeatured()); + } + + public function test_post_with_null_feature_slot_is_not_featured(): void + { + $post = new Post( + id: 1, + userId: 1, + title: 't', + body: 'b', + createdAt: new DateTimeImmutable('2026-05-06T12:00:00Z'), + featureSlot: null, + ); + + $this->assertNull($post->getFeatureSlot()); + $this->assertFalse($post->isFeatured()); + } +} From f73e5a1af5473a84035e014dae31547dcb11b06c Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:28:45 +0300 Subject: [PATCH 02/10] extend Post entity with feature slot Adds nullable feature_slot column (unique) plus repo findByFeatureSlot/findFeatured/update methods so admins can pin a post into one of two slots. --- backend/app/Post/EloquentPostRepository.php | 49 +++++++++++++++++ backend/app/Post/Post.php | 11 ++++ backend/app/Post/PostModel.php | 13 +++-- backend/app/Post/PostRepository.php | 14 +++++ ...05_06_000005_add_feature_slot_to_posts.php | 24 +++++++++ backend/tests/Fakes/FakePostRepository.php | 53 ++++++++++++++++++- 6 files changed, 160 insertions(+), 4 deletions(-) create mode 100644 backend/database/migrations/2026_05_06_000005_add_feature_slot_to_posts.php diff --git a/backend/app/Post/EloquentPostRepository.php b/backend/app/Post/EloquentPostRepository.php index b8c90b5..074b90a 100644 --- a/backend/app/Post/EloquentPostRepository.php +++ b/backend/app/Post/EloquentPostRepository.php @@ -4,6 +4,7 @@ namespace App\Post; use DateTimeImmutable; use DateTimeZone; +use RuntimeException; class EloquentPostRepository implements PostRepository { @@ -65,6 +66,53 @@ class EloquentPostRepository implements PostRepository PostModel::query()->where('id', $id)->delete(); } + /** + * @throws RuntimeException + */ + public function update(Post $post): Post + { + $model = PostModel::find($post->getId()); + if ($model === null) { + throw new RuntimeException( + "Post with id: {$post->getId()} does not exist" + ); + } + $model->user_id = $post->getUserId(); + $model->title = $post->getTitle(); + $model->body = $post->getBody(); + $model->created_at = $post->getCreatedAt(); + $model->feature_slot = $post->getFeatureSlot(); + $model->save(); + + return $this->toDomain($model); + } + + public function findByFeatureSlot(int $slot): ?Post + { + $model = PostModel::query() + ->where('feature_slot', $slot) + ->first(); + + return $model === null ? null : $this->toDomain($model); + } + + /** + * @return Post[] + */ + public function findFeatured(): array + { + $models = PostModel::query() + ->whereNotNull('feature_slot') + ->orderBy('feature_slot', 'asc') + ->get(); + + return $models->map( + function (PostModel $model) { + return $this->toDomain($model); + }, + )->all(); + } + private function toDomain(PostModel $model): Post { $utc = new DateTimeZone('UTC'); @@ -78,6 +126,7 @@ class EloquentPostRepository implements PostRepository $model->created_at->toDateTimeString(), $utc, ), + featureSlot: $model->feature_slot, ); } } diff --git a/backend/app/Post/Post.php b/backend/app/Post/Post.php index 76d100e..2445318 100644 --- a/backend/app/Post/Post.php +++ b/backend/app/Post/Post.php @@ -12,6 +12,7 @@ class Post private string $title, private string $body, private DateTimeImmutable $createdAt, + private ?int $featureSlot, ) {} public function getId(): int @@ -38,4 +39,14 @@ class Post { return $this->createdAt; } + + public function getFeatureSlot(): ?int + { + return $this->featureSlot; + } + + public function isFeatured(): bool + { + return $this->featureSlot !== null; + } } diff --git a/backend/app/Post/PostModel.php b/backend/app/Post/PostModel.php index 357347b..6b566b6 100644 --- a/backend/app/Post/PostModel.php +++ b/backend/app/Post/PostModel.php @@ -2,22 +2,27 @@ namespace App\Post; +use DateTimeImmutable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Carbon; /** * @property int $id * @property int $user_id * @property string $title * @property string $body - * @property Carbon $created_at + * @property DateTimeImmutable $created_at + * @property ?int $feature_slot * * @method static Builder|PostModel newModelQuery() * @method static Builder|PostModel newQuery() * @method static Builder|PostModel query() * @method static Builder|PostModel whereId($value) * @method static Builder|PostModel whereUserId($value) + * @method static Builder|PostModel whereTitle($value) + * @method static Builder|PostModel whereBody($value) + * @method static Builder|PostModel whereCreatedAt($value) + * @method static Builder|PostModel whereFeatureSlot($value) * * @mixin \Eloquent */ @@ -32,9 +37,11 @@ class PostModel extends Model 'title', 'body', 'created_at', + 'feature_slot', ]; protected $casts = [ - 'created_at' => 'datetime', + 'created_at' => 'immutable_datetime', + 'feature_slot' => 'integer', ]; } diff --git a/backend/app/Post/PostRepository.php b/backend/app/Post/PostRepository.php index a00827b..1113949 100644 --- a/backend/app/Post/PostRepository.php +++ b/backend/app/Post/PostRepository.php @@ -2,6 +2,8 @@ namespace App\Post; +use RuntimeException; + interface PostRepository { public function create(CreatePostDto $dto): Post; @@ -19,4 +21,16 @@ interface PostRepository public function findRecent(int $limit): array; public function delete(int $id): void; + + /** + * @throws RuntimeException + */ + public function update(Post $post): Post; + + public function findByFeatureSlot(int $slot): ?Post; + + /** + * @return Post[] + */ + public function findFeatured(): array; } diff --git a/backend/database/migrations/2026_05_06_000005_add_feature_slot_to_posts.php b/backend/database/migrations/2026_05_06_000005_add_feature_slot_to_posts.php new file mode 100644 index 0000000..3531798 --- /dev/null +++ b/backend/database/migrations/2026_05_06_000005_add_feature_slot_to_posts.php @@ -0,0 +1,24 @@ +unsignedTinyInteger('feature_slot')->nullable(); + $table->unique('feature_slot'); + }); + } + + public function down(): void + { + Schema::table('posts', function (Blueprint $table) { + $table->dropUnique(['feature_slot']); + $table->dropColumn('feature_slot'); + }); + } +}; diff --git a/backend/tests/Fakes/FakePostRepository.php b/backend/tests/Fakes/FakePostRepository.php index ed64137..8f08fe1 100644 --- a/backend/tests/Fakes/FakePostRepository.php +++ b/backend/tests/Fakes/FakePostRepository.php @@ -5,6 +5,7 @@ namespace Tests\Fakes; use App\Post\CreatePostDto; use App\Post\Post; use App\Post\PostRepository; +use RuntimeException; class FakePostRepository implements PostRepository { @@ -22,10 +23,11 @@ class FakePostRepository implements PostRepository title: $dto->title, body: $dto->body, createdAt: $dto->createdAt, + featureSlot: null, ); $this->existingPosts[$id] = $post; - return $post; + return $this->copy($post); } public function find(int $id): ?Post @@ -85,6 +87,54 @@ class FakePostRepository implements PostRepository unset($this->existingPosts[$id]); } + /** + * @throws RuntimeException + */ + public function update(Post $post): Post + { + $id = $post->getId(); + if (! isset($this->existingPosts[$id])) { + throw new RuntimeException( + "Post with id: $id does not exist" + ); + } + $this->existingPosts[$id] = $post; + + return $this->copy($post); + } + + public function findByFeatureSlot(int $slot): ?Post + { + foreach ($this->existingPosts as $post) { + if ($post->getFeatureSlot() === $slot) { + return $this->copy($post); + } + } + + return null; + } + + /** + * @return Post[] + */ + public function findFeatured(): array + { + $featured = []; + foreach ($this->existingPosts as $post) { + if ($post->isFeatured()) { + $featured[] = $this->copy($post); + } + } + usort( + $featured, + function (Post $left, Post $right) { + return $left->getFeatureSlot() <=> $right->getFeatureSlot(); + }, + ); + + return $featured; + } + private function copy(Post $post): Post { return new Post( @@ -93,6 +143,7 @@ class FakePostRepository implements PostRepository title: $post->getTitle(), body: $post->getBody(), createdAt: $post->getCreatedAt(), + featureSlot: $post->getFeatureSlot(), ); } From 13ca655f9bbdef5f183092e355e767de3b9694a6 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:29:09 +0300 Subject: [PATCH 03/10] test SetFeaturedPost use case --- .../Post/UseCases/SetFeaturedPostTest.php | 171 ++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 backend/tests/Unit/Post/UseCases/SetFeaturedPostTest.php diff --git a/backend/tests/Unit/Post/UseCases/SetFeaturedPostTest.php b/backend/tests/Unit/Post/UseCases/SetFeaturedPostTest.php new file mode 100644 index 0000000..0ec231f --- /dev/null +++ b/backend/tests/Unit/Post/UseCases/SetFeaturedPostTest.php @@ -0,0 +1,171 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC'), + ); + $this->postRepo = new FakePostRepository; + $this->useCase = new SetFeaturedPost($this->postRepo); + } + + private function seedPost(): Post + { + return $this->postRepo->create(new CreatePostDto( + userId: 1, + title: 'A', + body: 'B', + createdAt: $this->now, + )); + } + + public function test_non_admin_throws_forbidden(): void + { + $post = $this->seedPost(); + $this->expectException(ForbiddenException::class); + $this->useCase->execute(new SetFeaturedPostRequest( + postId: $post->getId(), + slot: 1, + requesterIsAdmin: false, + )); + } + + public function test_invalid_slot_throws_bad_request(): void + { + $post = $this->seedPost(); + $this->expectException(BadRequestException::class); + $this->useCase->execute(new SetFeaturedPostRequest( + postId: $post->getId(), + slot: 3, + requesterIsAdmin: true, + )); + } + + public function test_zero_post_id_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new SetFeaturedPostRequest( + postId: 0, + slot: 1, + requesterIsAdmin: true, + )); + } + + public function test_unknown_post_throws_domain_exception(): void + { + $this->expectException(DomainException::class); + $this->useCase->execute(new SetFeaturedPostRequest( + postId: 999, + slot: 1, + requesterIsAdmin: true, + )); + } + + public function test_admin_assigns_slot(): void + { + $post = $this->seedPost(); + + $this->useCase->execute(new SetFeaturedPostRequest( + postId: $post->getId(), + slot: 1, + requesterIsAdmin: true, + )); + + $reloaded = $this->postRepo->find($post->getId()); + $this->assertSame(1, $reloaded->getFeatureSlot()); + } + + public function test_assigning_same_slot_evicts_previous_post(): void + { + $first = $this->seedPost(); + $second = $this->seedPost(); + + $this->useCase->execute(new SetFeaturedPostRequest( + postId: $first->getId(), + slot: 1, + requesterIsAdmin: true, + )); + $this->useCase->execute(new SetFeaturedPostRequest( + postId: $second->getId(), + slot: 1, + requesterIsAdmin: true, + )); + + $this->assertNull( + $this->postRepo->find($first->getId())->getFeatureSlot(), + ); + $this->assertSame( + 1, + $this->postRepo->find($second->getId())->getFeatureSlot(), + ); + } + + public function test_two_posts_can_occupy_separate_slots(): void + { + $first = $this->seedPost(); + $second = $this->seedPost(); + + $this->useCase->execute(new SetFeaturedPostRequest( + postId: $first->getId(), + slot: 1, + requesterIsAdmin: true, + )); + $this->useCase->execute(new SetFeaturedPostRequest( + postId: $second->getId(), + slot: 2, + requesterIsAdmin: true, + )); + + $featured = $this->postRepo->findFeatured(); + $this->assertCount(2, $featured); + $this->assertSame(1, $featured[0]->getFeatureSlot()); + $this->assertSame(2, $featured[1]->getFeatureSlot()); + } + + public function test_moving_post_to_other_slot_clears_old_slot(): void + { + $post = $this->seedPost(); + + $this->useCase->execute(new SetFeaturedPostRequest( + postId: $post->getId(), + slot: 1, + requesterIsAdmin: true, + )); + $this->useCase->execute(new SetFeaturedPostRequest( + postId: $post->getId(), + slot: 2, + requesterIsAdmin: true, + )); + + $this->assertNull( + $this->postRepo->findByFeatureSlot(1), + ); + $this->assertSame( + $post->getId(), + $this->postRepo->findByFeatureSlot(2)->getId(), + ); + } +} From ee95bcafc9af3ef2b5b12338101e284d94e16a15 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:29:34 +0300 Subject: [PATCH 04/10] implement SetFeaturedPost use case --- .../SetFeaturedPost/SetFeaturedPost.php | 69 +++++++++++++++++++ .../SetFeaturedPostRequest.php | 12 ++++ 2 files changed, 81 insertions(+) create mode 100644 backend/app/Post/UseCases/SetFeaturedPost/SetFeaturedPost.php create mode 100644 backend/app/Post/UseCases/SetFeaturedPost/SetFeaturedPostRequest.php diff --git a/backend/app/Post/UseCases/SetFeaturedPost/SetFeaturedPost.php b/backend/app/Post/UseCases/SetFeaturedPost/SetFeaturedPost.php new file mode 100644 index 0000000..7c508ad --- /dev/null +++ b/backend/app/Post/UseCases/SetFeaturedPost/SetFeaturedPost.php @@ -0,0 +1,69 @@ +requesterIsAdmin) { + throw new ForbiddenException( + 'only admins can feature a post' + ); + } + if ($request->postId <= 0) { + throw new BadRequestException('postId must be positive'); + } + if (! in_array($request->slot, self::VALID_SLOTS, true)) { + throw new BadRequestException( + 'slot must be 1 or 2' + ); + } + + $post = $this->postRepo->find($request->postId); + if ($post === null) { + throw new DomainException('post not found'); + } + + $existingInSlot = $this->postRepo->findByFeatureSlot($request->slot); + if ( + $existingInSlot !== null + && $existingInSlot->getId() !== $post->getId() + ) { + $this->postRepo->update(new Post( + id: $existingInSlot->getId(), + userId: $existingInSlot->getUserId(), + title: $existingInSlot->getTitle(), + body: $existingInSlot->getBody(), + createdAt: $existingInSlot->getCreatedAt(), + featureSlot: null, + )); + } + + return $this->postRepo->update(new Post( + id: $post->getId(), + userId: $post->getUserId(), + title: $post->getTitle(), + body: $post->getBody(), + createdAt: $post->getCreatedAt(), + featureSlot: $request->slot, + )); + } +} diff --git a/backend/app/Post/UseCases/SetFeaturedPost/SetFeaturedPostRequest.php b/backend/app/Post/UseCases/SetFeaturedPost/SetFeaturedPostRequest.php new file mode 100644 index 0000000..10de537 --- /dev/null +++ b/backend/app/Post/UseCases/SetFeaturedPost/SetFeaturedPostRequest.php @@ -0,0 +1,12 @@ + Date: Wed, 6 May 2026 22:29:54 +0300 Subject: [PATCH 05/10] test ClearFeaturedPost use case --- .../Post/UseCases/ClearFeaturedPostTest.php | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 backend/tests/Unit/Post/UseCases/ClearFeaturedPostTest.php diff --git a/backend/tests/Unit/Post/UseCases/ClearFeaturedPostTest.php b/backend/tests/Unit/Post/UseCases/ClearFeaturedPostTest.php new file mode 100644 index 0000000..ef3ee4e --- /dev/null +++ b/backend/tests/Unit/Post/UseCases/ClearFeaturedPostTest.php @@ -0,0 +1,95 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC'), + ); + $this->postRepo = new FakePostRepository; + $this->useCase = new ClearFeaturedPost($this->postRepo); + } + + private function seedFeaturedPost(int $slot): Post + { + $post = $this->postRepo->create(new CreatePostDto( + userId: 1, + title: 'A', + body: 'B', + createdAt: $this->now, + )); + + return $this->postRepo->update(new Post( + id: $post->getId(), + userId: $post->getUserId(), + title: $post->getTitle(), + body: $post->getBody(), + createdAt: $post->getCreatedAt(), + featureSlot: $slot, + )); + } + + public function test_non_admin_throws_forbidden(): void + { + $post = $this->seedFeaturedPost(1); + $this->expectException(ForbiddenException::class); + $this->useCase->execute(new ClearFeaturedPostRequest( + postId: $post->getId(), + requesterIsAdmin: false, + )); + } + + public function test_zero_post_id_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new ClearFeaturedPostRequest( + postId: 0, + requesterIsAdmin: true, + )); + } + + public function test_unknown_post_is_no_op(): void + { + $this->useCase->execute(new ClearFeaturedPostRequest( + postId: 999, + requesterIsAdmin: true, + )); + + $this->assertNull($this->postRepo->find(999)); + } + + public function test_admin_clears_feature_slot(): void + { + $post = $this->seedFeaturedPost(2); + + $this->useCase->execute(new ClearFeaturedPostRequest( + postId: $post->getId(), + requesterIsAdmin: true, + )); + + $this->assertNull( + $this->postRepo->find($post->getId())->getFeatureSlot(), + ); + } +} From a8f59afc306a9c2b7abc23afeeb83453d5c8cd2d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:30:17 +0300 Subject: [PATCH 06/10] implement ClearFeaturedPost use case --- .../ClearFeaturedPost/ClearFeaturedPost.php | 48 +++++++++++++++++++ .../ClearFeaturedPostRequest.php | 11 +++++ 2 files changed, 59 insertions(+) create mode 100644 backend/app/Post/UseCases/ClearFeaturedPost/ClearFeaturedPost.php create mode 100644 backend/app/Post/UseCases/ClearFeaturedPost/ClearFeaturedPostRequest.php diff --git a/backend/app/Post/UseCases/ClearFeaturedPost/ClearFeaturedPost.php b/backend/app/Post/UseCases/ClearFeaturedPost/ClearFeaturedPost.php new file mode 100644 index 0000000..4a47ca4 --- /dev/null +++ b/backend/app/Post/UseCases/ClearFeaturedPost/ClearFeaturedPost.php @@ -0,0 +1,48 @@ +requesterIsAdmin) { + throw new ForbiddenException( + 'only admins can unfeature a post' + ); + } + if ($request->postId <= 0) { + throw new BadRequestException('postId must be positive'); + } + + $post = $this->postRepo->find($request->postId); + if ($post === null) { + return; + } + if (! $post->isFeatured()) { + return; + } + + $this->postRepo->update(new Post( + id: $post->getId(), + userId: $post->getUserId(), + title: $post->getTitle(), + body: $post->getBody(), + createdAt: $post->getCreatedAt(), + featureSlot: null, + )); + } +} diff --git a/backend/app/Post/UseCases/ClearFeaturedPost/ClearFeaturedPostRequest.php b/backend/app/Post/UseCases/ClearFeaturedPost/ClearFeaturedPostRequest.php new file mode 100644 index 0000000..1b1f1f4 --- /dev/null +++ b/backend/app/Post/UseCases/ClearFeaturedPost/ClearFeaturedPostRequest.php @@ -0,0 +1,11 @@ + Date: Wed, 6 May 2026 22:30:32 +0300 Subject: [PATCH 07/10] test ListFeaturedPosts use case --- .../Post/UseCases/ListFeaturedPostsTest.php | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 backend/tests/Unit/Post/UseCases/ListFeaturedPostsTest.php diff --git a/backend/tests/Unit/Post/UseCases/ListFeaturedPostsTest.php b/backend/tests/Unit/Post/UseCases/ListFeaturedPostsTest.php new file mode 100644 index 0000000..b01c4a7 --- /dev/null +++ b/backend/tests/Unit/Post/UseCases/ListFeaturedPostsTest.php @@ -0,0 +1,71 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC'), + ); + $this->postRepo = new FakePostRepository; + $this->useCase = new ListFeaturedPosts($this->postRepo); + } + + private function seedFeaturedPost(string $title, ?int $slot): Post + { + $post = $this->postRepo->create(new CreatePostDto( + userId: 1, + title: $title, + body: 'B', + createdAt: $this->now, + )); + if ($slot === null) { + return $post; + } + + return $this->postRepo->update(new Post( + id: $post->getId(), + userId: $post->getUserId(), + title: $post->getTitle(), + body: $post->getBody(), + createdAt: $post->getCreatedAt(), + featureSlot: $slot, + )); + } + + public function test_returns_empty_when_none_featured(): void + { + $this->seedFeaturedPost('not featured', null); + $this->assertSame([], $this->useCase->execute()); + } + + public function test_returns_featured_posts_in_slot_order(): void + { + $this->seedFeaturedPost('slot-2', 2); + $this->seedFeaturedPost('slot-1', 1); + $this->seedFeaturedPost('not-featured', null); + + $featured = $this->useCase->execute(); + + $this->assertCount(2, $featured); + $this->assertSame('slot-1', $featured[0]->getTitle()); + $this->assertSame('slot-2', $featured[1]->getTitle()); + } +} From e4791de81a99c2d4c54c84b091cc0ecbd752ccce Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:30:49 +0300 Subject: [PATCH 08/10] implement ListFeaturedPosts use case --- .../ListFeaturedPosts/ListFeaturedPosts.php | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 backend/app/Post/UseCases/ListFeaturedPosts/ListFeaturedPosts.php diff --git a/backend/app/Post/UseCases/ListFeaturedPosts/ListFeaturedPosts.php b/backend/app/Post/UseCases/ListFeaturedPosts/ListFeaturedPosts.php new file mode 100644 index 0000000..259da23 --- /dev/null +++ b/backend/app/Post/UseCases/ListFeaturedPosts/ListFeaturedPosts.php @@ -0,0 +1,21 @@ +postRepo->findFeatured(); + } +} From 8983b69fa154cb5941f559d72ab31d8758e07da5 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:32:36 +0300 Subject: [PATCH 09/10] test featured posts admin endpoints --- .../tests/Feature/Post/FeaturedPostsTest.php | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 backend/tests/Feature/Post/FeaturedPostsTest.php diff --git a/backend/tests/Feature/Post/FeaturedPostsTest.php b/backend/tests/Feature/Post/FeaturedPostsTest.php new file mode 100644 index 0000000..3ae9aea --- /dev/null +++ b/backend/tests/Feature/Post/FeaturedPostsTest.php @@ -0,0 +1,144 @@ +withCredentials() + ->withUnencryptedCookie('auth_token', $cookie) + ->postJson('/api/posts', [ + 'title' => $title, + 'body' => 'b', + ]); + $response->assertStatus(201); + + return $response->json('post.id'); + } + + private function reLoginAsAdmin(string $email, string $password): string + { + $response = $this->postJson('/api/login', [ + 'email' => $email, + 'password' => $password, + ]); + + return $response->getCookie('auth_token', false)->getValue(); + } + + public function test_non_admin_cannot_feature_post(): void + { + $alice = $this->signupAndLogin( + email: 'alice@example.com', + displayName: 'alice', + password: 'longenoughpassword', + ); + $postId = $this->createPost($alice['cookie'], 'P1'); + + $this->withCredentials() + ->withUnencryptedCookie('auth_token', $alice['cookie']) + ->postJson('/api/admin/posts/feature', [ + 'postId' => $postId, + 'slot' => 1, + ]) + ->assertStatus(403); + } + + public function test_admin_features_post(): void + { + $alice = $this->signupAndLogin( + email: 'alice@example.com', + displayName: 'alice', + password: 'longenoughpassword', + ); + $postId = $this->createPost($alice['cookie'], 'P1'); + + $this->promoteToAdmin($alice['user']->getId()); + $cookie = $this->reLoginAsAdmin( + 'alice@example.com', + 'longenoughpassword', + ); + + $this->withCredentials() + ->withUnencryptedCookie('auth_token', $cookie) + ->postJson('/api/admin/posts/feature', [ + 'postId' => $postId, + 'slot' => 1, + ]) + ->assertStatus(200) + ->assertJsonPath('post.featureSlot', 1); + } + + public function test_listing_featured_posts_is_public(): void + { + $alice = $this->signupAndLogin( + email: 'alice@example.com', + displayName: 'alice', + password: 'longenoughpassword', + ); + $postId = $this->createPost($alice['cookie'], 'P1'); + + $this->promoteToAdmin($alice['user']->getId()); + $cookie = $this->reLoginAsAdmin( + 'alice@example.com', + 'longenoughpassword', + ); + $this->withCredentials() + ->withUnencryptedCookie('auth_token', $cookie) + ->postJson('/api/admin/posts/feature', [ + 'postId' => $postId, + 'slot' => 2, + ]) + ->assertStatus(200); + + $this->resetClientState(); + $response = $this->getJson('/api/posts/featured'); + $response->assertStatus(200); + $response->assertJsonPath('posts.0.id', $postId); + $response->assertJsonPath('posts.0.featureSlot', 2); + } + + public function test_admin_unfeatures_post(): void + { + $alice = $this->signupAndLogin( + email: 'alice@example.com', + displayName: 'alice', + password: 'longenoughpassword', + ); + $postId = $this->createPost($alice['cookie'], 'P1'); + + $this->promoteToAdmin($alice['user']->getId()); + $cookie = $this->reLoginAsAdmin( + 'alice@example.com', + 'longenoughpassword', + ); + + $this->withCredentials() + ->withUnencryptedCookie('auth_token', $cookie) + ->postJson('/api/admin/posts/feature', [ + 'postId' => $postId, + 'slot' => 1, + ]) + ->assertStatus(200); + + $this->withCredentials() + ->withUnencryptedCookie('auth_token', $cookie) + ->postJson('/api/admin/posts/unfeature', [ + 'postId' => $postId, + ]) + ->assertStatus(204); + + $this->resetClientState(); + $this->getJson('/api/posts/featured') + ->assertStatus(200) + ->assertJsonPath('posts', []); + } +} From 8ac5a5b18a13de83ec17824403adddad8d01220a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:32:46 +0300 Subject: [PATCH 10/10] implement featured post admin endpoints Adds POST /admin/posts/feature, POST /admin/posts/unfeature (both auth-required, admin-checked inside controller via the use case's ForbiddenException), and public GET /posts/featured. Post serialization now includes featureSlot. --- backend/app/Controllers/PostController.php | 81 +++++++++++++++++++++- backend/routes/api.php | 5 ++ 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/backend/app/Controllers/PostController.php b/backend/app/Controllers/PostController.php index a01429c..a70133d 100644 --- a/backend/app/Controllers/PostController.php +++ b/backend/app/Controllers/PostController.php @@ -5,15 +5,20 @@ namespace App\Controllers; use App\Exceptions\BadRequestException; use App\Exceptions\ForbiddenException; use App\Post\Post; +use App\Post\UseCases\ClearFeaturedPost\ClearFeaturedPost; +use App\Post\UseCases\ClearFeaturedPost\ClearFeaturedPostRequest; use App\Post\UseCases\CreatePost\CreatePost; use App\Post\UseCases\CreatePost\CreatePostRequest; use App\Post\UseCases\DeletePost\DeletePost; use App\Post\UseCases\DeletePost\DeletePostRequest; use App\Post\UseCases\GetPost\GetPost; +use App\Post\UseCases\ListFeaturedPosts\ListFeaturedPosts; use App\Post\UseCases\ListRecentPosts\ListRecentPosts; use App\Post\UseCases\ListRecentPosts\ListRecentPostsRequest; use App\Post\UseCases\ListUserPosts\ListUserPosts; use App\Post\UseCases\ListUserPosts\ListUserPostsRequest; +use App\Post\UseCases\SetFeaturedPost\SetFeaturedPost; +use App\Post\UseCases\SetFeaturedPost\SetFeaturedPostRequest; use App\User\User; use App\User\UserRepository; use DomainException; @@ -30,6 +35,9 @@ class PostController private GetPost $getPost, private ListRecentPosts $listRecentPosts, private ListUserPosts $listUserPosts, + private SetFeaturedPost $setFeaturedPost, + private ClearFeaturedPost $clearFeaturedPost, + private ListFeaturedPosts $listFeaturedPosts, private UserRepository $userRepo, ) {} @@ -126,6 +134,75 @@ class PostController ], 201); } + public function listFeatured(Request $request): JsonResponse + { + $posts = $this->listFeaturedPosts->execute(); + + return new JsonResponse([ + 'posts' => array_map( + function (Post $post) { + return $this->serialize($post); + }, + $posts, + ), + ], 200); + } + + public function feature(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->attributes->get('user'); + try { + $post = $this->setFeaturedPost->execute( + new SetFeaturedPostRequest( + postId: (int) $request->input('postId'), + slot: (int) $request->input('slot'), + requesterIsAdmin: $user->isAdmin(), + ), + ); + } catch (BadRequestException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 400, + ); + } catch (ForbiddenException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 403, + ); + } catch (DomainException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 404, + ); + } + + return new JsonResponse([ + 'post' => $this->serialize($post), + ], 200); + } + + public function unfeature(Request $request): JsonResponse + { + /** @var User $user */ + $user = $request->attributes->get('user'); + try { + $this->clearFeaturedPost->execute( + new ClearFeaturedPostRequest( + postId: (int) $request->input('postId'), + requesterIsAdmin: $user->isAdmin(), + ), + ); + } catch (BadRequestException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 400, + ); + } catch (ForbiddenException $exception) { + return new JsonResponse( + ['error' => $exception->getMessage()], 403, + ); + } + + return new JsonResponse(null, 204); + } + public function delete(Request $request, int $id): JsonResponse { /** @var User $user */ @@ -160,7 +237,8 @@ class PostController * authorDisplayName: string, * title: string, * body: string, - * createdAt: string + * createdAt: string, + * featureSlot: ?int * } */ private function serialize(Post $post): array @@ -176,6 +254,7 @@ class PostController 'title' => $post->getTitle(), 'body' => $post->getBody(), 'createdAt' => $post->getCreatedAt()->format(DATE_ATOM), + 'featureSlot' => $post->getFeatureSlot(), ]; } } diff --git a/backend/routes/api.php b/backend/routes/api.php index f614658..03408cc 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -19,6 +19,7 @@ Route::get('/me', [AuthController::class, 'me']) ->middleware(AuthMiddleware::class); Route::get('/posts', [PostController::class, 'recent']); +Route::get('/posts/featured', [PostController::class, 'listFeatured']); Route::get('/posts/{id}', [PostController::class, 'show']) ->whereNumber('id'); Route::post('/posts', [PostController::class, 'create']) @@ -26,6 +27,10 @@ Route::post('/posts', [PostController::class, 'create']) Route::delete('/posts/{id}', [PostController::class, 'delete']) ->whereNumber('id') ->middleware(AuthMiddleware::class); +Route::post('/admin/posts/feature', [PostController::class, 'feature']) + ->middleware(AuthMiddleware::class); +Route::post('/admin/posts/unfeature', [PostController::class, 'unfeature']) + ->middleware(AuthMiddleware::class); Route::get( '/users/{displayName}/posts',