From 861485855822ee6b315fe48d31238f5b4a1ae580 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:26:10 +0300 Subject: [PATCH 1/2] test post and comment controllers Adds AuthenticatesUsers feature trait that runs the full signup -> confirm -> login flow and exposes the resulting auth cookie. Bumps phpunit defaultTimeLimit to 30 seconds so the multi-bcrypt-per-test feature flow finishes inside the limit. --- backend/phpunit.xml | 1 + backend/tests/Feature/AuthenticatesUsers.php | 72 ++++++++ .../tests/Feature/Comment/CommentFlowTest.php | 172 ++++++++++++++++++ backend/tests/Feature/Post/PostFlowTest.php | 156 ++++++++++++++++ 4 files changed, 401 insertions(+) create mode 100644 backend/tests/Feature/AuthenticatesUsers.php create mode 100644 backend/tests/Feature/Comment/CommentFlowTest.php create mode 100644 backend/tests/Feature/Post/PostFlowTest.php 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/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); + } +} From 59d4ed88c4696b4316088ce46d5d27a5498330ab Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:26:35 +0300 Subject: [PATCH 2/2] implement post and comment controllers Wires PostController (recent, show, create, delete, listByUser) and CommentController (listForPost, create, delete) to the existing use cases. Posts and comments expose author display names alongside user IDs. CommentRepository binding added to RepositoryServiceProvider. --- backend/app/Controllers/CommentController.php | 124 ++++++++++++ backend/app/Controllers/PostController.php | 181 ++++++++++++++++++ .../Providers/RepositoryServiceProvider.php | 6 + backend/routes/api.php | 31 +++ 4 files changed, 342 insertions(+) create mode 100644 backend/app/Controllers/CommentController.php create mode 100644 backend/app/Controllers/PostController.php 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/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);