merge featured-posts

This commit is contained in:
Yisroel Baum 2026-05-06 22:32:50 +03:00
commit 3c97a19e2d
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
18 changed files with 937 additions and 5 deletions

View file

@ -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(),
];
}
}

View file

@ -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,
);
}
}

View file

@ -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;
}
}

View file

@ -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<static>|PostModel newModelQuery()
* @method static Builder<static>|PostModel newQuery()
* @method static Builder<static>|PostModel query()
* @method static Builder<static>|PostModel whereId($value)
* @method static Builder<static>|PostModel whereUserId($value)
* @method static Builder<static>|PostModel whereTitle($value)
* @method static Builder<static>|PostModel whereBody($value)
* @method static Builder<static>|PostModel whereCreatedAt($value)
* @method static Builder<static>|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',
];
}

View file

@ -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;
}

View file

@ -0,0 +1,48 @@
<?php
namespace App\Post\UseCases\ClearFeaturedPost;
use App\Exceptions\BadRequestException;
use App\Exceptions\ForbiddenException;
use App\Post\Post;
use App\Post\PostRepository;
class ClearFeaturedPost
{
public function __construct(
private PostRepository $postRepo,
) {}
/**
* @throws BadRequestException
* @throws ForbiddenException
*/
public function execute(ClearFeaturedPostRequest $request): void
{
if (! $request->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,
));
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace App\Post\UseCases\ClearFeaturedPost;
class ClearFeaturedPostRequest
{
public function __construct(
public int $postId,
public bool $requesterIsAdmin,
) {}
}

View file

@ -0,0 +1,21 @@
<?php
namespace App\Post\UseCases\ListFeaturedPosts;
use App\Post\Post;
use App\Post\PostRepository;
class ListFeaturedPosts
{
public function __construct(
private PostRepository $postRepo,
) {}
/**
* @return Post[]
*/
public function execute(): array
{
return $this->postRepo->findFeatured();
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Post\UseCases\SetFeaturedPost;
use App\Exceptions\BadRequestException;
use App\Exceptions\ForbiddenException;
use App\Post\Post;
use App\Post\PostRepository;
use DomainException;
class SetFeaturedPost
{
private const VALID_SLOTS = [1, 2];
public function __construct(
private PostRepository $postRepo,
) {}
/**
* @throws BadRequestException
* @throws ForbiddenException
* @throws DomainException
*/
public function execute(SetFeaturedPostRequest $request): Post
{
if (! $request->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,
));
}
}

View file

@ -0,0 +1,12 @@
<?php
namespace App\Post\UseCases\SetFeaturedPost;
class SetFeaturedPostRequest
{
public function __construct(
public int $postId,
public int $slot,
public bool $requesterIsAdmin,
) {}
}

View file

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('posts', function (Blueprint $table) {
$table->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');
});
}
};

View file

@ -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',

View file

@ -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(),
);
}

View file

@ -0,0 +1,144 @@
<?php
namespace Tests\Feature\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Feature\AuthenticatesUsers;
use Tests\TestCase;
class FeaturedPostsTest extends TestCase
{
use AuthenticatesUsers;
use RefreshDatabase;
private function createPost(string $cookie, string $title): int
{
$response = $this->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', []);
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace Tests\Unit\Post;
use App\Post\Post;
use DateTimeImmutable;
use DateTimeZone;
use Tests\TestCase;
class PostTest extends TestCase
{
public function test_post_exposes_all_properties(): void
{
$createdAt = new DateTimeImmutable(
'2026-05-06T12:00:00',
new DateTimeZone('UTC'),
);
$post = new Post(
id: 7,
userId: 3,
title: 'Hello',
body: 'World',
createdAt: $createdAt,
featureSlot: 1,
);
$this->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());
}
}

View file

@ -0,0 +1,95 @@
<?php
namespace Tests\Unit\Post\UseCases;
use App\Exceptions\BadRequestException;
use App\Exceptions\ForbiddenException;
use App\Post\CreatePostDto;
use App\Post\Post;
use App\Post\UseCases\ClearFeaturedPost\ClearFeaturedPost;
use App\Post\UseCases\ClearFeaturedPost\ClearFeaturedPostRequest;
use DateTimeImmutable;
use DateTimeZone;
use Tests\Fakes\FakePostRepository;
use Tests\TestCase;
class ClearFeaturedPostTest extends TestCase
{
private FakePostRepository $postRepo;
private ClearFeaturedPost $useCase;
private DateTimeImmutable $now;
protected function setUp(): void
{
$this->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(),
);
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace Tests\Unit\Post\UseCases;
use App\Post\CreatePostDto;
use App\Post\Post;
use App\Post\UseCases\ListFeaturedPosts\ListFeaturedPosts;
use DateTimeImmutable;
use DateTimeZone;
use Tests\Fakes\FakePostRepository;
use Tests\TestCase;
class ListFeaturedPostsTest extends TestCase
{
private FakePostRepository $postRepo;
private ListFeaturedPosts $useCase;
private DateTimeImmutable $now;
protected function setUp(): void
{
$this->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());
}
}

View file

@ -0,0 +1,171 @@
<?php
namespace Tests\Unit\Post\UseCases;
use App\Exceptions\BadRequestException;
use App\Exceptions\ForbiddenException;
use App\Post\CreatePostDto;
use App\Post\Post;
use App\Post\UseCases\SetFeaturedPost\SetFeaturedPost;
use App\Post\UseCases\SetFeaturedPost\SetFeaturedPostRequest;
use DateTimeImmutable;
use DateTimeZone;
use DomainException;
use Tests\Fakes\FakePostRepository;
use Tests\TestCase;
class SetFeaturedPostTest extends TestCase
{
private FakePostRepository $postRepo;
private SetFeaturedPost $useCase;
private DateTimeImmutable $now;
protected function setUp(): void
{
$this->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(),
);
}
}