now = new DateTimeImmutable( '2026-05-06T12:00:00', new DateTimeZone('UTC'), ); $this->userRepo = new FakeUserRepository; $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->userRepo, $this->tokenRepo, $this->emailer, $this->emailFactory, $this->clock, 'noreply@tide.test', ); } public function test_null_email_throws_bad_request(): void { $this->expectException(BadRequestException::class); $this->useCase->execute(new SignupUserRequest( email: null, displayName: 'alice', )); } public function test_empty_email_throws_bad_request(): void { $this->expectException(BadRequestException::class); $this->useCase->execute(new SignupUserRequest( email: '', displayName: 'alice', )); } public function test_invalid_email_format_throws_bad_request(): void { $this->expectException(BadRequestException::class); $this->useCase->execute(new SignupUserRequest( email: 'not-an-email', displayName: 'alice', )); } public function test_null_display_name_throws_bad_request(): void { $this->expectException(BadRequestException::class); $this->useCase->execute(new SignupUserRequest( email: 'user@example.com', displayName: null, )); } public function test_short_display_name_throws_bad_request(): void { $this->expectException(BadRequestException::class); $this->useCase->execute(new SignupUserRequest( email: 'user@example.com', displayName: 'ab', )); } public function test_display_name_with_invalid_chars_throws(): void { $this->expectException(BadRequestException::class); $this->useCase->execute(new SignupUserRequest( email: 'user@example.com', displayName: 'Has Spaces', )); } public function test_duplicate_email_throws_domain_exception(): void { $this->userRepo->create(new CreateUserDto( email: new EmailAddress('user@example.com'), displayName: 'first', passwordHash: '', isAdmin: false, emailConfirmedAt: null, )); $this->expectException(DomainException::class); $this->useCase->execute(new SignupUserRequest( email: 'user@example.com', displayName: 'second', )); } public function test_duplicate_display_name_throws_domain_exception(): void { $this->userRepo->create(new CreateUserDto( email: new EmailAddress('first@example.com'), displayName: 'taken', passwordHash: '', isAdmin: false, emailConfirmedAt: null, )); $this->expectException(DomainException::class); $this->useCase->execute(new SignupUserRequest( email: 'second@example.com', displayName: 'taken', )); } public function test_valid_signup_creates_unconfirmed_user(): void { $created = $this->useCase->execute(new SignupUserRequest( email: 'new@example.com', displayName: 'newuser', )); $this->assertInstanceOf(User::class, $created); $this->assertSame('new@example.com', $created->getEmail()->value()); $this->assertSame('newuser', $created->getDisplayName()); $this->assertSame('', $created->getPasswordHash()); $this->assertFalse($created->isAdmin()); $this->assertFalse($created->isEmailConfirmed()); } public function test_valid_signup_creates_confirmation_token(): void { $created = $this->useCase->execute(new SignupUserRequest( email: 'new@example.com', displayName: 'newuser', )); $token = $this->tokenRepo->findByUser($created); $this->assertNotNull($token); $this->assertGreaterThan($this->now, $token->getAvailableTo()); } public function test_valid_signup_sends_confirmation_email(): void { $this->useCase->execute(new SignupUserRequest( email: 'new@example.com', displayName: 'newuser', )); $this->assertSame(1, $this->emailer->getNumberOfEmailsSent()); $sent = $this->emailer->getSentEmails()[0]; $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 { $created = $this->useCase->execute(new SignupUserRequest( email: 'Mixed@CASE.com', displayName: 'mixed', )); $this->assertSame('Mixed@case.com', $created->getEmail()->value()); } }