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/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/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 @@ +postRepo->findFeatured(); + } +} 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 @@ +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/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', 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(), ); } 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', []); + } +} 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()); + } +} 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(), + ); + } +} 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()); + } +} 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(), + ); + } +}