diff --git a/backend/app/Controllers/CommentController.php b/backend/app/Controllers/CommentController.php new file mode 100644 index 0000000..0b4433b --- /dev/null +++ b/backend/app/Controllers/CommentController.php @@ -0,0 +1,124 @@ +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), + ]; + } +} diff --git a/backend/app/Controllers/PostController.php b/backend/app/Controllers/PostController.php new file mode 100644 index 0000000..a01429c --- /dev/null +++ b/backend/app/Controllers/PostController.php @@ -0,0 +1,181 @@ +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), + ]; + } +} diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 1532e12..743fe1e 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -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, + ); } } diff --git a/backend/phpunit.xml b/backend/phpunit.xml index 8c6eb1b..ca91dd2 100644 --- a/backend/phpunit.xml +++ b/backend/phpunit.xml @@ -4,6 +4,7 @@ bootstrap="vendor/autoload.php" colors="true" enforceTimeLimit="true" + defaultTimeLimit="30" > diff --git a/backend/routes/api.php b/backend/routes/api.php index 45699fb..f614658 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,6 +1,8 @@ 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); diff --git a/backend/tests/Feature/AuthenticatesUsers.php b/backend/tests/Feature/AuthenticatesUsers.php new file mode 100644 index 0000000..1a74ad1 --- /dev/null +++ b/backend/tests/Feature/AuthenticatesUsers.php @@ -0,0 +1,72 @@ +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(), + )); + } +} diff --git a/backend/tests/Feature/Comment/CommentFlowTest.php b/backend/tests/Feature/Comment/CommentFlowTest.php new file mode 100644 index 0000000..cc5a37e --- /dev/null +++ b/backend/tests/Feature/Comment/CommentFlowTest.php @@ -0,0 +1,172 @@ +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); + } +} diff --git a/backend/tests/Feature/Post/PostFlowTest.php b/backend/tests/Feature/Post/PostFlowTest.php new file mode 100644 index 0000000..7d422fa --- /dev/null +++ b/backend/tests/Feature/Post/PostFlowTest.php @@ -0,0 +1,156 @@ +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); + } +}