From 9945163a2f632a24ecde3f9c6f830ac0185df99a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Mon, 3 Aug 2026 20:22:50 +0300 Subject: [PATCH] test email signup flow --- .../FakeEmailConfirmationTokenRepository.php | 69 ++++++++ backend/tests/Fakes/FakeEmailFactory.php | 22 +++ backend/tests/Fakes/FakeEmailer.php | 48 ++++++ backend/tests/Fakes/FakeUserRepository.php | 7 + .../Feature/Auth/ConfirmEmailEndpointTest.php | 70 ++++++++ .../tests/Feature/Auth/SignupEndpointTest.php | 50 ++++++ .../User/EloquentUserRepositoryTest.php | 19 +++ .../Auth/UseCases/AuthenticateUserTest.php | 16 ++ .../Http/Controllers/AuthControllerTest.php | 31 ++++ .../User/UseCases/ConfirmUserEmailTest.php | 149 ++++++++++++++++++ .../Unit/User/UseCases/SignupUserTest.php | 135 ++++++++++++++++ backend/tests/Unit/User/UserTest.php | 15 ++ 12 files changed, 631 insertions(+) create mode 100644 backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php create mode 100644 backend/tests/Fakes/FakeEmailFactory.php create mode 100644 backend/tests/Fakes/FakeEmailer.php create mode 100644 backend/tests/Feature/Auth/ConfirmEmailEndpointTest.php create mode 100644 backend/tests/Feature/Auth/SignupEndpointTest.php create mode 100644 backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php create mode 100644 backend/tests/Unit/User/UseCases/SignupUserTest.php diff --git a/backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php b/backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php new file mode 100644 index 0000000..d0a8332 --- /dev/null +++ b/backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php @@ -0,0 +1,69 @@ + + */ + private array $tokens = []; + + public function create( + CreateEmailConfirmationTokenDto $dto, + ): EmailConfirmationToken { + $id = count($this->tokens) + 1; + $token = new EmailConfirmationToken( + id: $id, + user: $dto->user, + availableTo: $dto->availableTo, + token: $dto->token, + ); + $this->tokens[$id] = $token; + + return $this->copy($token); + } + + public function findByToken(string $token): ?EmailConfirmationToken + { + foreach ($this->tokens as $candidate) { + if ($candidate->getToken() === $token) { + return $this->copy($candidate); + } + } + + return null; + } + + public function findByUser(User $user): ?EmailConfirmationToken + { + foreach ($this->tokens as $candidate) { + if ($candidate->getUser()->getId() === $user->getId()) { + return $this->copy($candidate); + } + } + + return null; + } + + public function delete(int $id): void + { + unset($this->tokens[$id]); + } + + private function copy( + EmailConfirmationToken $token, + ): EmailConfirmationToken { + return new EmailConfirmationToken( + id: $token->getId(), + user: $token->getUser(), + availableTo: $token->getAvailableTo(), + token: $token->getToken(), + ); + } +} diff --git a/backend/tests/Fakes/FakeEmailFactory.php b/backend/tests/Fakes/FakeEmailFactory.php new file mode 100644 index 0000000..562ccb1 --- /dev/null +++ b/backend/tests/Fakes/FakeEmailFactory.php @@ -0,0 +1,22 @@ +lastToken = $token; + + return "confirm with {$token}"; + } + + public function getLastToken(): ?string + { + return $this->lastToken; + } +} diff --git a/backend/tests/Fakes/FakeEmailer.php b/backend/tests/Fakes/FakeEmailer.php new file mode 100644 index 0000000..4fb55ce --- /dev/null +++ b/backend/tests/Fakes/FakeEmailer.php @@ -0,0 +1,48 @@ +sendCount++; + $this->lastRecipient = $recipient; + $this->lastSubject = $subject; + $this->lastBody = $body; + } + + public function getSendCount(): int + { + return $this->sendCount; + } + + public function getLastRecipient(): ?EmailAddress + { + return $this->lastRecipient; + } + + public function getLastSubject(): ?string + { + return $this->lastSubject; + } + + public function getLastBody(): ?string + { + return $this->lastBody; + } +} diff --git a/backend/tests/Fakes/FakeUserRepository.php b/backend/tests/Fakes/FakeUserRepository.php index 3672f87..df20238 100644 --- a/backend/tests/Fakes/FakeUserRepository.php +++ b/backend/tests/Fakes/FakeUserRepository.php @@ -45,6 +45,13 @@ class FakeUserRepository implements UserRepository return null; } + public function update(User $user): User + { + $this->users[$user->getId()] = $this->copy($user); + + return $this->copy($user); + } + private function copy(User $user): User { return new User( diff --git a/backend/tests/Feature/Auth/ConfirmEmailEndpointTest.php b/backend/tests/Feature/Auth/ConfirmEmailEndpointTest.php new file mode 100644 index 0000000..62a9479 --- /dev/null +++ b/backend/tests/Feature/Auth/ConfirmEmailEndpointTest.php @@ -0,0 +1,70 @@ +create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: null, + )); + $token = app(CreateEmailConfirmationToken::class)->execute( + new CreateEmailConfirmationTokenRequest( + user: $user, + minuteOffset: 10, + ), + ); + + $response = $this->postJson('/api/confirm-email', [ + 'token' => $token->getToken(), + 'password' => 'password123', + ]); + + $response->assertOk(); + $response->assertJsonPath('user.email', 'user@example.com'); + $confirmedUser = $userRepository->find($user->getId()); + $this->assertNotNull($confirmedUser); + $passwordHash = $confirmedUser->getPasswordHash(); + $this->assertNotNull($passwordHash); + $this->assertTrue( + app(PasswordHasher::class)->verify('password123', $passwordHash), + ); + $this->assertNull( + app(EmailConfirmationTokenRepository::class) + ->findByToken($token->getToken()), + ); + $cookie = $response->getCookie(AuthMiddleware::COOKIE_NAME, false); + $this->assertNotNull($cookie); + $this->assertNotNull( + app(SessionRepository::class)->findByToken($cookie->getValue()), + ); + } + + public function test_confirmation_rejects_an_unknown_token(): void + { + $response = $this->postJson('/api/confirm-email', [ + 'token' => 'unknown-token', + 'password' => 'password123', + ]); + + $response->assertConflict(); + $response->assertJson(['error' => 'token not found']); + } +} diff --git a/backend/tests/Feature/Auth/SignupEndpointTest.php b/backend/tests/Feature/Auth/SignupEndpointTest.php new file mode 100644 index 0000000..8514aa1 --- /dev/null +++ b/backend/tests/Feature/Auth/SignupEndpointTest.php @@ -0,0 +1,50 @@ +postJson('/api/signup', [ + 'email' => 'Founder@EXAMPLE.COM', + ]); + + $response->assertCreated(); + $user = app(UserRepository::class)->findByEmail( + new EmailAddress('Founder@example.com'), + ); + $this->assertNotNull($user); + $this->assertNull($user->getPasswordHash()); + $this->assertNotNull( + app(EmailConfirmationTokenRepository::class) + ->findByUser($user), + ); + } + + public function test_signup_rejects_an_existing_confirmed_account(): void + { + app(UserRepository::class)->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', + )); + + $response = $this->postJson('/api/signup', [ + 'email' => 'user@example.com', + ]); + + $response->assertConflict(); + $response->assertJson([ + 'error' => 'user@example.com already has an account', + ]); + } +} diff --git a/backend/tests/Feature/User/EloquentUserRepositoryTest.php b/backend/tests/Feature/User/EloquentUserRepositoryTest.php index 5741cb5..1b94bea 100644 --- a/backend/tests/Feature/User/EloquentUserRepositoryTest.php +++ b/backend/tests/Feature/User/EloquentUserRepositoryTest.php @@ -78,4 +78,23 @@ class EloquentUserRepositoryTest extends TestCase new EmailAddress('unknown@example.com'), )); } + + public function test_it_persists_confirmation_of_a_pending_user(): void + { + $repository = app(UserRepository::class); + $user = $repository->create(new CreateUserDto( + email: new EmailAddress('pending@example.com'), + passwordHash: null, + )); + + $this->assertNull($user->getPasswordHash()); + $user->setPasswordHash('hashed-password'); + $updatedUser = $repository->update($user); + + $this->assertSame('hashed-password', $updatedUser->getPasswordHash()); + $this->assertDatabaseHas('users', [ + 'id' => $user->getId(), + 'passwordHash' => 'hashed-password', + ]); + } } diff --git a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php index 585af22..0b3fa6a 100644 --- a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php +++ b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php @@ -64,6 +64,22 @@ class AuthenticateUserTest extends TestCase )); } + public function test_pending_user_throws_unauthorized(): void + { + $this->userRepository->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: null, + )); + + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('invalid credentials'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: 'correct-password', + )); + } + public function test_wrong_password_throws_unauthorized(): void { $this->createUser('correct-password'); diff --git a/backend/tests/Unit/Http/Controllers/AuthControllerTest.php b/backend/tests/Unit/Http/Controllers/AuthControllerTest.php index 5372272..9e136c5 100644 --- a/backend/tests/Unit/Http/Controllers/AuthControllerTest.php +++ b/backend/tests/Unit/Http/Controllers/AuthControllerTest.php @@ -5,15 +5,21 @@ namespace Tests\Unit\Http\Controllers; use App\Auth\UseCases\AuthenticateUser\AuthenticateUser; use App\Auth\UseCases\CreateSession\CreateSession; use App\Auth\UseCases\Logout\Logout; +use App\Email\EmailConfirmationToken\UseCases\CreateEmailConfirmationToken; use App\Http\Controllers\AuthController; use App\Http\Middleware\AuthMiddleware; use App\Shared\ValueObject\EmailAddress; use App\User\CreateUserDto; +use App\User\UseCases\ConfirmUserEmail\ConfirmUserEmail; +use App\User\UseCases\SignupUser\SignupUser; use DateTimeImmutable; use DateTimeZone; use Illuminate\Http\Request; use PHPUnit\Framework\TestCase; use Tests\Fakes\FakeClock; +use Tests\Fakes\FakeEmailConfirmationTokenRepository; +use Tests\Fakes\FakeEmailer; +use Tests\Fakes\FakeEmailFactory; use Tests\Fakes\FakePasswordHasher; use Tests\Fakes\FakeSessionRepository; use Tests\Fakes\FakeTokenGenerator; @@ -47,7 +53,32 @@ class AuthControllerTest extends TestCase )), ); $logout = new Logout($this->sessionRepository); + $tokenRepository = new FakeEmailConfirmationTokenRepository; + $signupUser = new SignupUser( + $this->userRepository, + new CreateEmailConfirmationToken( + $tokenRepository, + new FakeClock(new DateTimeImmutable( + '2026-07-31T12:00:00', + new DateTimeZone('UTC'), + )), + new FakeTokenGenerator(['email-token']), + ), + new FakeEmailer, + new FakeEmailFactory, + ); + $confirmUserEmail = new ConfirmUserEmail( + $tokenRepository, + $this->userRepository, + $this->passwordHasher, + new FakeClock(new DateTimeImmutable( + '2026-07-31T12:00:00', + new DateTimeZone('UTC'), + )), + ); $this->controller = new AuthController( + $signupUser, + $confirmUserEmail, $authenticateUser, $createSession, $logout, diff --git a/backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php b/backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php new file mode 100644 index 0000000..ba59277 --- /dev/null +++ b/backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php @@ -0,0 +1,149 @@ +now = new DateTimeImmutable( + '2026-08-03T12:00:00', + new DateTimeZone('UTC'), + ); + $this->userRepository = new FakeUserRepository; + $this->tokenRepository = new FakeEmailConfirmationTokenRepository; + $this->confirmUserEmail = new ConfirmUserEmail( + $this->tokenRepository, + $this->userRepository, + new FakePasswordHasher, + new FakeClock($this->now), + ); + } + + public function test_it_sets_the_password_and_consumes_the_token(): void + { + $this->createPendingUserToken( + 'confirmation-token', + $this->now->modify('+10 minutes'), + ); + + $confirmedUser = $this->confirmUserEmail->execute( + new ConfirmUserEmailRequest( + token: 'confirmation-token', + password: 'password123', + ), + ); + + $this->assertSame('hashed:password123', $confirmedUser->getPasswordHash()); + $this->assertSame( + 'hashed:password123', + $this->userRepository->find($confirmedUser->getId()) + ?->getPasswordHash(), + ); + $this->assertNull( + $this->tokenRepository->findByToken('confirmation-token'), + ); + } + + public function test_it_rejects_an_expired_token(): void + { + $this->createPendingUserToken( + 'expired-token', + $this->now->modify('-1 minute'), + ); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage('token expired'); + + $this->confirmUserEmail->execute(new ConfirmUserEmailRequest( + token: 'expired-token', + password: 'password123', + )); + } + + public function test_it_rejects_an_unknown_token(): void + { + $this->expectException(DomainException::class); + $this->expectExceptionMessage('token not found'); + + $this->confirmUserEmail->execute(new ConfirmUserEmailRequest( + token: 'unknown-token', + password: 'password123', + )); + } + + public function test_it_requires_a_token(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('token is required'); + + $this->confirmUserEmail->execute(new ConfirmUserEmailRequest( + token: null, + password: 'password123', + )); + } + + public function test_it_requires_a_password(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('password is required'); + + $this->confirmUserEmail->execute(new ConfirmUserEmailRequest( + token: 'confirmation-token', + password: null, + )); + } + + public function test_it_rejects_a_short_password(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'password must be at least 8 characters', + ); + + $this->confirmUserEmail->execute(new ConfirmUserEmailRequest( + token: 'confirmation-token', + password: 'short', + )); + } + + private function createPendingUserToken( + string $token, + DateTimeImmutable $availableTo, + ): void { + $user = $this->userRepository->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: null, + )); + $this->tokenRepository->create( + new CreateEmailConfirmationTokenDto( + user: $user, + availableTo: $availableTo, + token: $token, + ), + ); + } +} diff --git a/backend/tests/Unit/User/UseCases/SignupUserTest.php b/backend/tests/Unit/User/UseCases/SignupUserTest.php new file mode 100644 index 0000000..64e21a5 --- /dev/null +++ b/backend/tests/Unit/User/UseCases/SignupUserTest.php @@ -0,0 +1,135 @@ +userRepository = new FakeUserRepository; + $this->tokenRepository = new FakeEmailConfirmationTokenRepository; + $this->emailer = new FakeEmailer; + $this->emailFactory = new FakeEmailFactory; + $createToken = new CreateEmailConfirmationToken( + $this->tokenRepository, + new FakeClock(new DateTimeImmutable( + '2026-08-03T12:00:00', + new DateTimeZone('UTC'), + )), + new FakeTokenGenerator(['first-token', 'second-token']), + ); + $this->signupUser = new SignupUser( + $this->userRepository, + $createToken, + $this->emailer, + $this->emailFactory, + ); + } + + public function test_it_creates_a_pending_user_token_and_email(): void + { + $this->signupUser->execute(new SignupUserRequest( + email: ' Founder@EXAMPLE.COM ', + )); + + $user = $this->userRepository->findByEmail( + new EmailAddress('Founder@example.com'), + ); + $this->assertNotNull($user); + $this->assertNull($user->getPasswordHash()); + $token = $this->tokenRepository->findByUser($user); + $this->assertNotNull($token); + $this->assertSame('first-token', $token->getToken()); + $this->assertSame( + '2026-08-03T12:10:00+00:00', + $token->getAvailableTo()->format('c'), + ); + $this->assertSame(1, $this->emailer->getSendCount()); + $this->assertSame( + 'Founder@example.com', + $this->emailer->getLastRecipient()?->value(), + ); + $this->assertSame( + 'Confirm your Attainly email', + $this->emailer->getLastSubject(), + ); + $this->assertSame('first-token', $this->emailFactory->getLastToken()); + } + + public function test_it_replaces_the_token_for_a_pending_user(): void + { + $request = new SignupUserRequest(email: 'user@example.com'); + $this->signupUser->execute($request); + $this->signupUser->execute($request); + + $user = $this->userRepository->findByEmail( + new EmailAddress('user@example.com'), + ); + $this->assertNotNull($user); + $this->assertSame( + 'second-token', + $this->tokenRepository->findByUser($user)?->getToken(), + ); + $this->assertSame(2, $this->emailer->getSendCount()); + } + + public function test_it_rejects_an_existing_confirmed_account(): void + { + $this->userRepository->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed-password', + )); + + $this->expectException(DomainException::class); + $this->expectExceptionMessage('user@example.com already has an account'); + + $this->signupUser->execute(new SignupUserRequest( + email: 'user@example.com', + )); + } + + public function test_it_rejects_a_missing_email(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('email is required'); + + $this->signupUser->execute(new SignupUserRequest(email: null)); + } + + public function test_it_rejects_an_invalid_email(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('email must be valid'); + + $this->signupUser->execute(new SignupUserRequest( + email: 'not-an-email', + )); + } +} diff --git a/backend/tests/Unit/User/UserTest.php b/backend/tests/Unit/User/UserTest.php index b3c6873..105d994 100644 --- a/backend/tests/Unit/User/UserTest.php +++ b/backend/tests/Unit/User/UserTest.php @@ -21,4 +21,19 @@ class UserTest extends TestCase $this->assertSame($email, $user->getEmail()); $this->assertSame('hashed-password', $user->getPasswordHash()); } + + public function test_it_can_confirm_a_pending_user_with_a_password(): void + { + $user = new User( + id: 42, + email: new EmailAddress('user@example.com'), + passwordHash: null, + ); + + $this->assertNull($user->getPasswordHash()); + + $user->setPasswordHash('hashed-password'); + + $this->assertSame('hashed-password', $user->getPasswordHash()); + } }