merge post-comment-controllers

This commit is contained in:
Yisroel Baum 2026-05-06 22:26:40 +03:00
commit ec4f729c63
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
8 changed files with 743 additions and 0 deletions

View file

@ -0,0 +1,124 @@
<?php
namespace App\Controllers;
use App\Comment\Comment;
use App\Comment\UseCases\CreateComment\CreateComment;
use App\Comment\UseCases\CreateComment\CreateCommentRequest;
use App\Comment\UseCases\DeleteComment\DeleteComment;
use App\Comment\UseCases\DeleteComment\DeleteCommentRequest;
use App\Comment\UseCases\ListCommentsForPost\ListCommentsForPost;
use App\Comment\UseCases\ListCommentsForPost\ListCommentsForPostRequest;
use App\Exceptions\BadRequestException;
use App\Exceptions\ForbiddenException;
use App\User\User;
use App\User\UserRepository;
use DomainException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CommentController
{
public function __construct(
private CreateComment $createComment,
private DeleteComment $deleteComment,
private ListCommentsForPost $listCommentsForPost,
private UserRepository $userRepo,
) {}
public function listForPost(Request $request, int $postId): JsonResponse
{
try {
$comments = $this->listCommentsForPost->execute(
new ListCommentsForPostRequest(postId: $postId),
);
} catch (BadRequestException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()], 400,
);
}
return new JsonResponse([
'comments' => array_map(
function (Comment $comment) {
return $this->serialize($comment);
},
$comments,
),
], 200);
}
public function create(Request $request, int $postId): JsonResponse
{
/** @var User $user */
$user = $request->attributes->get('user');
try {
$comment = $this->createComment->execute(new CreateCommentRequest(
postId: $postId,
userId: $user->getId(),
body: $request->input('body'),
));
} catch (BadRequestException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()], 400,
);
} catch (DomainException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()], 404,
);
}
return new JsonResponse([
'comment' => $this->serialize($comment),
], 201);
}
public function delete(Request $request, int $id): JsonResponse
{
/** @var User $user */
$user = $request->attributes->get('user');
try {
$this->deleteComment->execute(new DeleteCommentRequest(
commentId: $id,
requesterId: $user->getId(),
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);
}
/**
* @return array{
* id: int,
* postId: int,
* userId: int,
* authorDisplayName: string,
* body: string,
* createdAt: string
* }
*/
private function serialize(Comment $comment): array
{
$author = $this->userRepo->find($comment->getUserId());
return [
'id' => $comment->getId(),
'postId' => $comment->getPostId(),
'userId' => $comment->getUserId(),
'authorDisplayName' => $author === null
? ''
: $author->getDisplayName(),
'body' => $comment->getBody(),
'createdAt' => $comment->getCreatedAt()->format(DATE_ATOM),
];
}
}

View file

@ -0,0 +1,181 @@
<?php
namespace App\Controllers;
use App\Exceptions\BadRequestException;
use App\Exceptions\ForbiddenException;
use App\Post\Post;
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\ListRecentPosts\ListRecentPosts;
use App\Post\UseCases\ListRecentPosts\ListRecentPostsRequest;
use App\Post\UseCases\ListUserPosts\ListUserPosts;
use App\Post\UseCases\ListUserPosts\ListUserPostsRequest;
use App\User\User;
use App\User\UserRepository;
use DomainException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PostController
{
private const RECENT_LIMIT = 20;
public function __construct(
private CreatePost $createPost,
private DeletePost $deletePost,
private GetPost $getPost,
private ListRecentPosts $listRecentPosts,
private ListUserPosts $listUserPosts,
private UserRepository $userRepo,
) {}
public function recent(Request $request): JsonResponse
{
try {
$posts = $this->listRecentPosts->execute(
new ListRecentPostsRequest(limit: self::RECENT_LIMIT),
);
} catch (BadRequestException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()], 400,
);
}
return new JsonResponse([
'posts' => array_map(
function (Post $post) {
return $this->serialize($post);
},
$posts,
),
], 200);
}
public function show(Request $request, int $id): JsonResponse
{
try {
$post = $this->getPost->execute($id);
} catch (BadRequestException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()], 400,
);
}
if ($post === null) {
return new JsonResponse(['error' => 'post not found'], 404);
}
return new JsonResponse([
'post' => $this->serialize($post),
], 200);
}
public function listByUser(
Request $request,
string $displayName,
): JsonResponse {
$user = $this->userRepo->findByDisplayName($displayName);
if ($user === null) {
return new JsonResponse(['error' => 'user not found'], 404);
}
try {
$posts = $this->listUserPosts->execute(
new ListUserPostsRequest(userId: $user->getId()),
);
} catch (BadRequestException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()], 400,
);
}
return new JsonResponse([
'user' => [
'id' => $user->getId(),
'displayName' => $user->getDisplayName(),
],
'posts' => array_map(
function (Post $post) {
return $this->serialize($post);
},
$posts,
),
], 200);
}
public function create(Request $request): JsonResponse
{
/** @var User $user */
$user = $request->attributes->get('user');
try {
$post = $this->createPost->execute(new CreatePostRequest(
userId: $user->getId(),
title: $request->input('title'),
body: $request->input('body'),
));
} catch (BadRequestException $exception) {
return new JsonResponse(
['error' => $exception->getMessage()], 400,
);
}
return new JsonResponse([
'post' => $this->serialize($post),
], 201);
}
public function delete(Request $request, int $id): JsonResponse
{
/** @var User $user */
$user = $request->attributes->get('user');
try {
$this->deletePost->execute(new DeletePostRequest(
postId: $id,
requesterId: $user->getId(),
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()], 409,
);
}
return new JsonResponse(null, 204);
}
/**
* @return array{
* id: int,
* userId: int,
* authorDisplayName: string,
* title: string,
* body: string,
* createdAt: string
* }
*/
private function serialize(Post $post): array
{
$author = $this->userRepo->find($post->getUserId());
return [
'id' => $post->getId(),
'userId' => $post->getUserId(),
'authorDisplayName' => $author === null
? ''
: $author->getDisplayName(),
'title' => $post->getTitle(),
'body' => $post->getBody(),
'createdAt' => $post->getCreatedAt()->format(DATE_ATOM),
];
}
}

View file

@ -4,6 +4,8 @@ namespace App\Providers;
use App\Auth\EloquentSessionRepository;
use App\Auth\SessionRepository;
use App\Comment\CommentRepository;
use App\Comment\EloquentCommentRepository;
use App\Email\EmailConfirmationToken\EloquentEmailConfirmationTokenRepository;
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
use App\Post\EloquentPostRepository;
@ -32,5 +34,9 @@ class RepositoryServiceProvider extends ServiceProvider
PostRepository::class,
EloquentPostRepository::class,
);
$this->app->bind(
CommentRepository::class,
EloquentCommentRepository::class,
);
}
}

View file

@ -4,6 +4,7 @@
bootstrap="vendor/autoload.php"
colors="true"
enforceTimeLimit="true"
defaultTimeLimit="30"
>
<testsuites>
<testsuite name="Unit">

View file

@ -1,6 +1,8 @@
<?php
use App\Controllers\AuthController;
use App\Controllers\CommentController;
use App\Controllers\PostController;
use App\Http\Middleware\AuthMiddleware;
use Illuminate\Support\Facades\Route;
@ -15,3 +17,32 @@ Route::post('/logout', [AuthController::class, 'logout'])
->middleware(AuthMiddleware::class);
Route::get('/me', [AuthController::class, 'me'])
->middleware(AuthMiddleware::class);
Route::get('/posts', [PostController::class, 'recent']);
Route::get('/posts/{id}', [PostController::class, 'show'])
->whereNumber('id');
Route::post('/posts', [PostController::class, 'create'])
->middleware(AuthMiddleware::class);
Route::delete('/posts/{id}', [PostController::class, 'delete'])
->whereNumber('id')
->middleware(AuthMiddleware::class);
Route::get(
'/users/{displayName}/posts',
[PostController::class, 'listByUser'],
);
Route::get(
'/posts/{postId}/comments',
[CommentController::class, 'listForPost'],
)->whereNumber('postId');
Route::post(
'/posts/{postId}/comments',
[CommentController::class, 'create'],
)->whereNumber('postId')
->middleware(AuthMiddleware::class);
Route::delete(
'/comments/{id}',
[CommentController::class, 'delete'],
)->whereNumber('id')
->middleware(AuthMiddleware::class);

View file

@ -0,0 +1,72 @@
<?php
namespace Tests\Feature;
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
use App\Shared\ValueObject\EmailAddress;
use App\User\User;
use App\User\UserRepository;
trait AuthenticatesUsers
{
/**
* @return array{user: User, cookie: string}
*/
private function signupAndLogin(
string $email,
string $displayName,
string $password,
): array {
$this->postJson('/api/signup', [
'email' => $email,
'displayName' => $displayName,
])->assertStatus(201);
$userRepo = $this->app->make(UserRepository::class);
$user = $userRepo->findByEmail(new EmailAddress($email));
$tokenRepo = $this->app->make(
EmailConfirmationTokenRepository::class,
);
$token = $tokenRepo->findByUser($user);
$this->postJson('/api/confirm-email', [
'token' => $token->getToken(),
'password' => $password,
])->assertStatus(200);
$loginResponse = $this->postJson('/api/login', [
'email' => $email,
'password' => $password,
]);
$loginResponse->assertStatus(200);
$cookie = $loginResponse->getCookie('auth_token', false);
$reloaded = $userRepo->findByEmail(new EmailAddress($email));
return [
'user' => $reloaded,
'cookie' => $cookie->getValue(),
];
}
private function resetClientState(): void
{
$this->defaultCookies = [];
$this->unencryptedCookies = [];
$this->withCredentials = false;
}
private function promoteToAdmin(int $userId): void
{
$userRepo = $this->app->make(UserRepository::class);
$user = $userRepo->find($userId);
$userRepo->update(new User(
id: $user->getId(),
email: $user->getEmail(),
displayName: $user->getDisplayName(),
passwordHash: $user->getPasswordHash(),
isAdmin: true,
emailConfirmedAt: $user->getEmailConfirmedAt(),
));
}
}

View file

@ -0,0 +1,172 @@
<?php
namespace Tests\Feature\Comment;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Feature\AuthenticatesUsers;
use Tests\TestCase;
class CommentFlowTest 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');
}
public function test_anonymous_can_list_comments(): void
{
$alice = $this->signupAndLogin(
email: 'alice@example.com',
displayName: 'alice',
password: 'longenoughpassword',
);
$postId = $this->createPost($alice['cookie'], 'P1');
$response = $this->getJson("/api/posts/{$postId}/comments");
$response->assertStatus(200);
$response->assertJsonPath('comments', []);
}
public function test_authenticated_user_creates_comment(): void
{
$alice = $this->signupAndLogin(
email: 'alice@example.com',
displayName: 'alice',
password: 'longenoughpassword',
);
$postId = $this->createPost($alice['cookie'], 'P1');
$bob = $this->signupAndLogin(
email: 'bob@example.com',
displayName: 'bob',
password: 'longenoughpassword',
);
$response = $this->withCredentials()->withUnencryptedCookie('auth_token', $bob['cookie'])
->postJson("/api/posts/{$postId}/comments", [
'body' => 'nice post',
]);
$response->assertStatus(201);
$response->assertJsonPath('comment.body', 'nice post');
$response->assertJsonPath('comment.authorDisplayName', 'bob');
}
public function test_anonymous_cannot_create_comment(): void
{
$alice = $this->signupAndLogin(
email: 'alice@example.com',
displayName: 'alice',
password: 'longenoughpassword',
);
$postId = $this->createPost($alice['cookie'], 'P1');
$this->resetClientState();
$this->postJson("/api/posts/{$postId}/comments", [
'body' => 'hi',
])->assertStatus(401);
}
public function test_create_on_missing_post_returns_404(): void
{
$alice = $this->signupAndLogin(
email: 'alice@example.com',
displayName: 'alice',
password: 'longenoughpassword',
);
$this->withCredentials()->withUnencryptedCookie('auth_token', $alice['cookie'])
->postJson('/api/posts/9999/comments', [
'body' => 'hi',
])->assertStatus(404);
}
public function test_author_deletes_own_comment(): void
{
$alice = $this->signupAndLogin(
email: 'alice@example.com',
displayName: 'alice',
password: 'longenoughpassword',
);
$postId = $this->createPost($alice['cookie'], 'P1');
$bob = $this->signupAndLogin(
email: 'bob@example.com',
displayName: 'bob',
password: 'longenoughpassword',
);
$createResponse = $this->withCredentials()->withUnencryptedCookie('auth_token', $bob['cookie'])
->postJson("/api/posts/{$postId}/comments", [
'body' => 'nice',
]);
$commentId = $createResponse->json('comment.id');
$this->withCredentials()->withUnencryptedCookie('auth_token', $bob['cookie'])
->deleteJson("/api/comments/{$commentId}")
->assertStatus(204);
}
public function test_other_user_cannot_delete_comment(): void
{
$alice = $this->signupAndLogin(
email: 'alice@example.com',
displayName: 'alice',
password: 'longenoughpassword',
);
$postId = $this->createPost($alice['cookie'], 'P1');
$bob = $this->signupAndLogin(
email: 'bob@example.com',
displayName: 'bob',
password: 'longenoughpassword',
);
$createResponse = $this->withCredentials()->withUnencryptedCookie('auth_token', $bob['cookie'])
->postJson("/api/posts/{$postId}/comments", [
'body' => 'nice',
]);
$commentId = $createResponse->json('comment.id');
$this->withCredentials()->withUnencryptedCookie('auth_token', $alice['cookie'])
->deleteJson("/api/comments/{$commentId}")
->assertStatus(403);
}
public function test_admin_deletes_any_comment(): void
{
$alice = $this->signupAndLogin(
email: 'alice@example.com',
displayName: 'alice',
password: 'longenoughpassword',
);
$postId = $this->createPost($alice['cookie'], 'P1');
$bob = $this->signupAndLogin(
email: 'bob@example.com',
displayName: 'bob',
password: 'longenoughpassword',
);
$createResponse = $this->withCredentials()->withUnencryptedCookie('auth_token', $bob['cookie'])
->postJson("/api/posts/{$postId}/comments", [
'body' => 'nice',
]);
$commentId = $createResponse->json('comment.id');
$this->promoteToAdmin($alice['user']->getId());
$loginResponse = $this->postJson('/api/login', [
'email' => 'alice@example.com',
'password' => 'longenoughpassword',
]);
$aliceCookie = $loginResponse->getCookie('auth_token', false);
$this->withCredentials()->withUnencryptedCookie('auth_token', $aliceCookie->getValue())
->deleteJson("/api/comments/{$commentId}")
->assertStatus(204);
}
}

View file

@ -0,0 +1,156 @@
<?php
namespace Tests\Feature\Post;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\Feature\AuthenticatesUsers;
use Tests\TestCase;
class PostFlowTest extends TestCase
{
use AuthenticatesUsers;
use RefreshDatabase;
public function test_authenticated_user_creates_post(): void
{
$session = $this->signupAndLogin(
email: 'alice@example.com',
displayName: 'alice',
password: 'longenoughpassword',
);
$response = $this->withCredentials()->withUnencryptedCookie('auth_token', $session['cookie'])
->postJson('/api/posts', [
'title' => 'My Post',
'body' => 'Hello world',
]);
$response->assertStatus(201);
$response->assertJsonPath('post.title', 'My Post');
$response->assertJsonPath('post.authorDisplayName', 'alice');
}
public function test_anonymous_create_post_returns_401(): void
{
$this->postJson('/api/posts', [
'title' => 'My Post',
'body' => 'Hello world',
])->assertStatus(401);
}
public function test_recent_posts_are_public(): void
{
$session = $this->signupAndLogin(
email: 'alice@example.com',
displayName: 'alice',
password: 'longenoughpassword',
);
$this->withCredentials()->withUnencryptedCookie('auth_token', $session['cookie'])
->postJson('/api/posts', [
'title' => 'P1',
'body' => 'B1',
])->assertStatus(201);
$response = $this->getJson('/api/posts');
$response->assertStatus(200);
$response->assertJsonPath('posts.0.title', 'P1');
}
public function test_show_returns_404_when_missing(): void
{
$this->getJson('/api/posts/9999')->assertStatus(404);
}
public function test_user_posts_listed_by_display_name(): void
{
$session = $this->signupAndLogin(
email: 'alice@example.com',
displayName: 'alice',
password: 'longenoughpassword',
);
$this->withCredentials()->withUnencryptedCookie('auth_token', $session['cookie'])
->postJson('/api/posts', [
'title' => 'A1',
'body' => 'b',
])->assertStatus(201);
$response = $this->getJson('/api/users/alice/posts');
$response->assertStatus(200);
$response->assertJsonPath('user.displayName', 'alice');
$response->assertJsonPath('posts.0.title', 'A1');
}
public function test_other_user_cannot_delete_post(): void
{
$alice = $this->signupAndLogin(
email: 'alice@example.com',
displayName: 'alice',
password: 'longenoughpassword',
);
$createResponse = $this->withCredentials()->withUnencryptedCookie('auth_token', $alice['cookie'])
->postJson('/api/posts', [
'title' => 'A1',
'body' => 'b',
]);
$postId = $createResponse->json('post.id');
$bob = $this->signupAndLogin(
email: 'bob@example.com',
displayName: 'bob',
password: 'longenoughpassword',
);
$this->withCredentials()->withUnencryptedCookie('auth_token', $bob['cookie'])
->deleteJson("/api/posts/{$postId}")
->assertStatus(403);
}
public function test_author_deletes_own_post(): void
{
$alice = $this->signupAndLogin(
email: 'alice@example.com',
displayName: 'alice',
password: 'longenoughpassword',
);
$createResponse = $this->withCredentials()->withUnencryptedCookie('auth_token', $alice['cookie'])
->postJson('/api/posts', [
'title' => 'A1',
'body' => 'b',
]);
$postId = $createResponse->json('post.id');
$this->withCredentials()->withUnencryptedCookie('auth_token', $alice['cookie'])
->deleteJson("/api/posts/{$postId}")
->assertStatus(204);
}
public function test_admin_deletes_anyones_post(): void
{
$alice = $this->signupAndLogin(
email: 'alice@example.com',
displayName: 'alice',
password: 'longenoughpassword',
);
$createResponse = $this->withCredentials()->withUnencryptedCookie('auth_token', $alice['cookie'])
->postJson('/api/posts', [
'title' => 'A1',
'body' => 'b',
]);
$postId = $createResponse->json('post.id');
$bob = $this->signupAndLogin(
email: 'bob@example.com',
displayName: 'bob',
password: 'longenoughpassword',
);
$this->promoteToAdmin($bob['user']->getId());
// Re-login bob to get a fresh cookie/payload
$loginResponse = $this->postJson('/api/login', [
'email' => 'bob@example.com',
'password' => 'longenoughpassword',
]);
$bobCookie = $loginResponse->getCookie('auth_token', false);
$this->withCredentials()->withUnencryptedCookie('auth_token', $bobCookie->getValue())
->deleteJson("/api/posts/{$postId}")
->assertStatus(204);
}
}