merge featured-posts
This commit is contained in:
commit
3c97a19e2d
18 changed files with 937 additions and 5 deletions
|
|
@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?php
|
||||
|
||||
namespace App\Post\UseCases\ClearFeaturedPost;
|
||||
|
||||
class ClearFeaturedPostRequest
|
||||
{
|
||||
public function __construct(
|
||||
public int $postId,
|
||||
public bool $requesterIsAdmin,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace App\Post\UseCases\SetFeaturedPost;
|
||||
|
||||
class SetFeaturedPostRequest
|
||||
{
|
||||
public function __construct(
|
||||
public int $postId,
|
||||
public int $slot,
|
||||
public bool $requesterIsAdmin,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
144
backend/tests/Feature/Post/FeaturedPostsTest.php
Normal file
144
backend/tests/Feature/Post/FeaturedPostsTest.php
Normal 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', []);
|
||||
}
|
||||
}
|
||||
50
backend/tests/Unit/Post/PostTest.php
Normal file
50
backend/tests/Unit/Post/PostTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
95
backend/tests/Unit/Post/UseCases/ClearFeaturedPostTest.php
Normal file
95
backend/tests/Unit/Post/UseCases/ClearFeaturedPostTest.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
backend/tests/Unit/Post/UseCases/ListFeaturedPostsTest.php
Normal file
71
backend/tests/Unit/Post/UseCases/ListFeaturedPostsTest.php
Normal 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());
|
||||
}
|
||||
}
|
||||
171
backend/tests/Unit/Post/UseCases/SetFeaturedPostTest.php
Normal file
171
backend/tests/Unit/Post/UseCases/SetFeaturedPostTest.php
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue