merge signup-flow-rewrite

This commit is contained in:
Yisroel Baum 2026-05-06 22:08:58 +03:00
commit 99433a21c2
Signed by: yisroelbaum
GPG key ID: 0FA60884F75520A9
3 changed files with 76 additions and 73 deletions

View file

@ -2,7 +2,11 @@
namespace App\User\UseCases\SignupUser; namespace App\User\UseCases\SignupUser;
use App\Auth\PasswordHasher; use App\Auth\Clock;
use App\Email\EmailConfirmationToken\CreateEmailConfirmationTokenDto;
use App\Email\EmailConfirmationToken\EmailConfirmationTokenRepository;
use App\Email\Emailer;
use App\Email\EmailFactory;
use App\Exceptions\BadRequestException; use App\Exceptions\BadRequestException;
use App\Shared\ValueObject\EmailAddress; use App\Shared\ValueObject\EmailAddress;
use App\User\CreateUserDto; use App\User\CreateUserDto;
@ -13,13 +17,17 @@ use InvalidArgumentException;
class SignupUser class SignupUser
{ {
private const MIN_PASSWORD_LENGTH = 8;
private const DISPLAY_NAME_PATTERN = '/^[a-z0-9_-]{3,30}$/'; private const DISPLAY_NAME_PATTERN = '/^[a-z0-9_-]{3,30}$/';
private const TOKEN_LIFETIME = '+1 day';
public function __construct( public function __construct(
private UserRepository $userRepo, private UserRepository $userRepo,
private PasswordHasher $hasher, private EmailConfirmationTokenRepository $tokenRepo,
private Emailer $emailer,
private EmailFactory $emailFactory,
private Clock $clock,
private string $fromAddress,
) {} ) {}
/** /**
@ -44,14 +52,6 @@ class SignupUser
'displayName must be 3-30 chars of [a-z0-9_-]' 'displayName must be 3-30 chars of [a-z0-9_-]'
); );
} }
if ($request->password === null || $request->password === '') {
throw new BadRequestException('password is required');
}
if (strlen($request->password) < self::MIN_PASSWORD_LENGTH) {
throw new BadRequestException(
'password must be at least '.self::MIN_PASSWORD_LENGTH.' characters'
);
}
try { try {
$email = new EmailAddress($request->email); $email = new EmailAddress($request->email);
@ -66,12 +66,25 @@ class SignupUser
throw new DomainException('displayName already taken'); throw new DomainException('displayName already taken');
} }
return $this->userRepo->create(new CreateUserDto( $user = $this->userRepo->create(new CreateUserDto(
email: $email, email: $email,
displayName: $request->displayName, displayName: $request->displayName,
passwordHash: $this->hasher->hash($request->password), passwordHash: '',
isAdmin: false, isAdmin: false,
emailConfirmedAt: null, emailConfirmedAt: null,
)); ));
$token = $this->tokenRepo->create(new CreateEmailConfirmationTokenDto(
user: $user,
availableTo: $this->clock->now()->modify(self::TOKEN_LIFETIME),
));
$this->emailer->send(
$this->fromAddress,
$user->getEmail()->value(),
$this->emailFactory->makeConfirmationEmail($token->getToken()),
);
return $user;
} }
} }

View file

@ -7,6 +7,5 @@ class SignupUserRequest
public function __construct( public function __construct(
public ?string $email, public ?string $email,
public ?string $displayName, public ?string $displayName,
public ?string $password,
) {} ) {}
} }

View file

@ -8,8 +8,13 @@ use App\User\CreateUserDto;
use App\User\UseCases\SignupUser\SignupUser; use App\User\UseCases\SignupUser\SignupUser;
use App\User\UseCases\SignupUser\SignupUserRequest; use App\User\UseCases\SignupUser\SignupUserRequest;
use App\User\User; use App\User\User;
use DateTimeImmutable;
use DateTimeZone;
use DomainException; use DomainException;
use Tests\Fakes\FakePasswordHasher; use Tests\Fakes\FakeClock;
use Tests\Fakes\FakeEmailConfirmationTokenRepository;
use Tests\Fakes\FakeEmailer;
use Tests\Fakes\FakeEmailFactory;
use Tests\Fakes\FakeUserRepository; use Tests\Fakes\FakeUserRepository;
use Tests\TestCase; use Tests\TestCase;
@ -17,17 +22,38 @@ class SignupUserTest extends TestCase
{ {
private FakeUserRepository $userRepo; private FakeUserRepository $userRepo;
private FakePasswordHasher $hasher; private FakeEmailConfirmationTokenRepository $tokenRepo;
private FakeEmailer $emailer;
private FakeEmailFactory $emailFactory;
private FakeClock $clock;
private DateTimeImmutable $now;
private SignupUser $useCase; private SignupUser $useCase;
protected function setUp(): void protected function setUp(): void
{ {
$this->now = new DateTimeImmutable(
'2026-05-06T12:00:00',
new DateTimeZone('UTC'),
);
$this->userRepo = new FakeUserRepository; $this->userRepo = new FakeUserRepository;
$this->hasher = new FakePasswordHasher; $this->tokenRepo = new FakeEmailConfirmationTokenRepository(
$this->userRepo,
);
$this->emailer = new FakeEmailer;
$this->emailFactory = new FakeEmailFactory;
$this->clock = new FakeClock($this->now);
$this->useCase = new SignupUser( $this->useCase = new SignupUser(
$this->userRepo, $this->userRepo,
$this->hasher, $this->tokenRepo,
$this->emailer,
$this->emailFactory,
$this->clock,
'noreply@tide.test',
); );
} }
@ -37,7 +63,6 @@ class SignupUserTest extends TestCase
$this->useCase->execute(new SignupUserRequest( $this->useCase->execute(new SignupUserRequest(
email: null, email: null,
displayName: 'alice', displayName: 'alice',
password: 'longenoughpassword',
)); ));
} }
@ -47,7 +72,6 @@ class SignupUserTest extends TestCase
$this->useCase->execute(new SignupUserRequest( $this->useCase->execute(new SignupUserRequest(
email: '', email: '',
displayName: 'alice', displayName: 'alice',
password: 'longenoughpassword',
)); ));
} }
@ -57,7 +81,6 @@ class SignupUserTest extends TestCase
$this->useCase->execute(new SignupUserRequest( $this->useCase->execute(new SignupUserRequest(
email: 'not-an-email', email: 'not-an-email',
displayName: 'alice', displayName: 'alice',
password: 'longenoughpassword',
)); ));
} }
@ -67,7 +90,6 @@ class SignupUserTest extends TestCase
$this->useCase->execute(new SignupUserRequest( $this->useCase->execute(new SignupUserRequest(
email: 'user@example.com', email: 'user@example.com',
displayName: null, displayName: null,
password: 'longenoughpassword',
)); ));
} }
@ -77,7 +99,6 @@ class SignupUserTest extends TestCase
$this->useCase->execute(new SignupUserRequest( $this->useCase->execute(new SignupUserRequest(
email: 'user@example.com', email: 'user@example.com',
displayName: 'ab', displayName: 'ab',
password: 'longenoughpassword',
)); ));
} }
@ -87,27 +108,6 @@ class SignupUserTest extends TestCase
$this->useCase->execute(new SignupUserRequest( $this->useCase->execute(new SignupUserRequest(
email: 'user@example.com', email: 'user@example.com',
displayName: 'Has Spaces', displayName: 'Has Spaces',
password: 'longenoughpassword',
));
}
public function test_null_password_throws_bad_request(): void
{
$this->expectException(BadRequestException::class);
$this->useCase->execute(new SignupUserRequest(
email: 'user@example.com',
displayName: 'alice',
password: null,
));
}
public function test_short_password_throws_bad_request(): void
{
$this->expectException(BadRequestException::class);
$this->useCase->execute(new SignupUserRequest(
email: 'user@example.com',
displayName: 'alice',
password: 'short',
)); ));
} }
@ -116,7 +116,7 @@ class SignupUserTest extends TestCase
$this->userRepo->create(new CreateUserDto( $this->userRepo->create(new CreateUserDto(
email: new EmailAddress('user@example.com'), email: new EmailAddress('user@example.com'),
displayName: 'first', displayName: 'first',
passwordHash: $this->hasher->hash('original-password'), passwordHash: '',
isAdmin: false, isAdmin: false,
emailConfirmedAt: null, emailConfirmedAt: null,
)); ));
@ -125,7 +125,6 @@ class SignupUserTest extends TestCase
$this->useCase->execute(new SignupUserRequest( $this->useCase->execute(new SignupUserRequest(
email: 'user@example.com', email: 'user@example.com',
displayName: 'second', displayName: 'second',
password: 'second-attempt-password',
)); ));
} }
@ -134,7 +133,7 @@ class SignupUserTest extends TestCase
$this->userRepo->create(new CreateUserDto( $this->userRepo->create(new CreateUserDto(
email: new EmailAddress('first@example.com'), email: new EmailAddress('first@example.com'),
displayName: 'taken', displayName: 'taken',
passwordHash: $this->hasher->hash('original-password'), passwordHash: '',
isAdmin: false, isAdmin: false,
emailConfirmedAt: null, emailConfirmedAt: null,
)); ));
@ -143,55 +142,48 @@ class SignupUserTest extends TestCase
$this->useCase->execute(new SignupUserRequest( $this->useCase->execute(new SignupUserRequest(
email: 'second@example.com', email: 'second@example.com',
displayName: 'taken', displayName: 'taken',
password: 'second-attempt-password',
)); ));
} }
public function test_valid_signup_returns_user_with_hashed_password(): void public function test_valid_signup_creates_unconfirmed_user(): void
{ {
$created = $this->useCase->execute(new SignupUserRequest( $created = $this->useCase->execute(new SignupUserRequest(
email: 'new@example.com', email: 'new@example.com',
displayName: 'newuser', displayName: 'newuser',
password: 'longenoughpassword',
)); ));
$this->assertInstanceOf(User::class, $created); $this->assertInstanceOf(User::class, $created);
$this->assertSame('new@example.com', $created->getEmail()->value()); $this->assertSame('new@example.com', $created->getEmail()->value());
$this->assertSame('newuser', $created->getDisplayName()); $this->assertSame('newuser', $created->getDisplayName());
$this->assertSame( $this->assertSame('', $created->getPasswordHash());
$this->hasher->hash('longenoughpassword'),
$created->getPasswordHash(),
);
$this->assertFalse($created->isAdmin()); $this->assertFalse($created->isAdmin());
$this->assertFalse($created->isEmailConfirmed()); $this->assertFalse($created->isEmailConfirmed());
} }
public function test_created_user_is_findable_by_email(): void public function test_valid_signup_creates_confirmation_token(): void
{ {
$created = $this->useCase->execute(new SignupUserRequest( $created = $this->useCase->execute(new SignupUserRequest(
email: 'lookup@example.com', email: 'new@example.com',
displayName: 'lookup', displayName: 'newuser',
password: 'longenoughpassword',
)); ));
$found = $this->userRepo->findByEmail( $token = $this->tokenRepo->findByUser($created);
new EmailAddress('lookup@example.com') $this->assertNotNull($token);
); $this->assertGreaterThan($this->now, $token->getAvailableTo());
$this->assertNotNull($found);
$this->assertSame($created->getId(), $found->getId());
} }
public function test_created_user_is_findable_by_display_name(): void public function test_valid_signup_sends_confirmation_email(): void
{ {
$created = $this->useCase->execute(new SignupUserRequest( $this->useCase->execute(new SignupUserRequest(
email: 'lookup@example.com', email: 'new@example.com',
displayName: 'lookupbyname', displayName: 'newuser',
password: 'longenoughpassword',
)); ));
$found = $this->userRepo->findByDisplayName('lookupbyname'); $this->assertSame(1, $this->emailer->getNumberOfEmailsSent());
$this->assertNotNull($found); $sent = $this->emailer->getSentEmails()[0];
$this->assertSame($created->getId(), $found->getId()); $this->assertSame('noreply@tide.test', $sent['from']);
$this->assertSame('new@example.com', $sent['to']);
$this->assertStringContainsString('confirm:', $sent['body']);
} }
public function test_signup_normalizes_email_domain(): void public function test_signup_normalizes_email_domain(): void
@ -199,7 +191,6 @@ class SignupUserTest extends TestCase
$created = $this->useCase->execute(new SignupUserRequest( $created = $this->useCase->execute(new SignupUserRequest(
email: 'Mixed@CASE.com', email: 'Mixed@CASE.com',
displayName: 'mixed', displayName: 'mixed',
password: 'longenoughpassword',
)); ));
$this->assertSame('Mixed@case.com', $created->getEmail()->value()); $this->assertSame('Mixed@case.com', $created->getEmail()->value());