now = new DateTimeImmutable( '2026-05-06T12:00:00', new DateTimeZone('UTC'), ); $this->userRepo = new FakeUserRepository; $this->tokenRepo = new FakeEmailConfirmationTokenRepository( $this->userRepo, ); $this->hasher = new FakePasswordHasher; $this->clock = new FakeClock($this->now); $this->useCase = new ConfirmUserEmail( $this->userRepo, $this->tokenRepo, $this->hasher, $this->clock, ); } private function seedUserAndToken(DateTimeImmutable $availableTo): User { $user = $this->userRepo->create(new CreateUserDto( email: new EmailAddress('user@example.com'), displayName: 'user', passwordHash: '', isAdmin: false, emailConfirmedAt: null, )); $this->tokenRepo->create(new CreateEmailConfirmationTokenDto( user: $user, availableTo: $availableTo, )); return $user; } private function tokenStringForUser(User $user): string { $token = $this->tokenRepo->findByUser($user); $this->assertNotNull($token); return $token->getToken(); } public function test_null_token_throws_bad_request(): void { $this->expectException(BadRequestException::class); $this->useCase->execute(new ConfirmUserEmailRequest( token: null, password: 'longenoughpassword', )); } public function test_null_password_throws_bad_request(): void { $this->expectException(BadRequestException::class); $this->useCase->execute(new ConfirmUserEmailRequest( token: 'sometoken', password: null, )); } public function test_short_password_throws_bad_request(): void { $this->expectException(BadRequestException::class); $this->useCase->execute(new ConfirmUserEmailRequest( token: 'sometoken', password: 'short', )); } public function test_unknown_token_throws_domain_exception(): void { $this->expectException(DomainException::class); $this->useCase->execute(new ConfirmUserEmailRequest( token: 'no-such-token', password: 'longenoughpassword', )); } public function test_expired_token_throws_domain_exception(): void { $user = $this->seedUserAndToken( $this->now->modify('-1 minute'), ); $tokenStr = $this->tokenStringForUser($user); $this->expectException(DomainException::class); $this->useCase->execute(new ConfirmUserEmailRequest( token: $tokenStr, password: 'longenoughpassword', )); } public function test_valid_confirmation_sets_password_hash(): void { $user = $this->seedUserAndToken( $this->now->modify('+1 day'), ); $tokenStr = $this->tokenStringForUser($user); $this->useCase->execute(new ConfirmUserEmailRequest( token: $tokenStr, password: 'longenoughpassword', )); $reloaded = $this->userRepo->find($user->getId()); $this->assertNotNull($reloaded); $this->assertSame( $this->hasher->hash('longenoughpassword'), $reloaded->getPasswordHash(), ); } public function test_valid_confirmation_marks_email_confirmed(): void { $user = $this->seedUserAndToken( $this->now->modify('+1 day'), ); $tokenStr = $this->tokenStringForUser($user); $this->useCase->execute(new ConfirmUserEmailRequest( token: $tokenStr, password: 'longenoughpassword', )); $reloaded = $this->userRepo->find($user->getId()); $this->assertNotNull($reloaded); $this->assertTrue($reloaded->isEmailConfirmed()); $this->assertEquals($this->now, $reloaded->getEmailConfirmedAt()); } public function test_valid_confirmation_consumes_token(): void { $user = $this->seedUserAndToken( $this->now->modify('+1 day'), ); $tokenStr = $this->tokenStringForUser($user); $this->useCase->execute(new ConfirmUserEmailRequest( token: $tokenStr, password: 'longenoughpassword', )); $this->assertNull($this->tokenRepo->findByToken($tokenStr)); } }