From 9747d07c31a04aa0e7cd440c9aa387335a749512 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:04:57 +0300 Subject: [PATCH 1/5] test EmailConfirmationToken entity --- .../EmailConfirmationTokenTest.php | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 backend/tests/Unit/Email/EmailConfirmationToken/EmailConfirmationTokenTest.php diff --git a/backend/tests/Unit/Email/EmailConfirmationToken/EmailConfirmationTokenTest.php b/backend/tests/Unit/Email/EmailConfirmationToken/EmailConfirmationTokenTest.php new file mode 100644 index 0000000..01aa9d0 --- /dev/null +++ b/backend/tests/Unit/Email/EmailConfirmationToken/EmailConfirmationTokenTest.php @@ -0,0 +1,40 @@ +assertSame(4, $token->getId()); + $this->assertSame($user, $token->getUser()); + $this->assertSame($availableTo, $token->getAvailableTo()); + $this->assertSame('abc123', $token->getToken()); + } +} From e16cb453876821678e79277d0df514a2cee8cf42 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:05:52 +0300 Subject: [PATCH 2/5] add EmailConfirmationToken persistence: model, migration, eloquent + fake repo --- .../CreateEmailConfirmationTokenDto.php | 14 ++++ ...oquentEmailConfirmationTokenRepository.php | 73 +++++++++++++++++ .../EmailConfirmationToken.php | 36 ++++++++ .../EmailConfirmationTokenModel.php | 40 +++++++++ .../EmailConfirmationTokenRepository.php | 18 ++++ ...create_email_confirmation_tokens_table.php | 25 ++++++ .../FakeEmailConfirmationTokenRepository.php | 82 +++++++++++++++++++ 7 files changed, 288 insertions(+) create mode 100644 backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php create mode 100644 backend/app/Email/EmailConfirmationToken/EloquentEmailConfirmationTokenRepository.php create mode 100644 backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php create mode 100644 backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php create mode 100644 backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php create mode 100644 backend/database/migrations/2026_05_06_000003_create_email_confirmation_tokens_table.php create mode 100644 backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php diff --git a/backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php b/backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php new file mode 100644 index 0000000..58a17ed --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/CreateEmailConfirmationTokenDto.php @@ -0,0 +1,14 @@ + $dto->user->getId(), + 'token' => bin2hex(random_bytes(32)), + 'available_to' => $dto->availableTo, + ]); + + return $this->toDomain($model); + } + + public function findByToken(string $token): ?EmailConfirmationToken + { + $model = EmailConfirmationTokenModel::where( + 'token', $token, + )->first(); + + return $model === null ? null : $this->toDomain($model); + } + + public function findByUser(User $user): ?EmailConfirmationToken + { + $model = EmailConfirmationTokenModel::where( + 'user_id', $user->getId(), + )->first(); + + return $model === null ? null : $this->toDomain($model); + } + + public function delete(int $id): void + { + EmailConfirmationTokenModel::query()->where('id', $id)->delete(); + } + + private function toDomain( + EmailConfirmationTokenModel $model, + ): EmailConfirmationToken { + $user = $this->userRepo->find($model->user_id); + if ($user === null) { + throw new DomainException( + "User with id {$model->user_id} not found" + ); + } + $availableTo = new DateTimeImmutable( + $model->available_to->toDateTimeString(), + new DateTimeZone('UTC'), + ); + + return new EmailConfirmationToken( + id: $model->id, + user: $user, + availableTo: $availableTo, + token: $model->token, + ); + } +} diff --git a/backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php b/backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php new file mode 100644 index 0000000..d2fb638 --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/EmailConfirmationToken.php @@ -0,0 +1,36 @@ +id; + } + + public function getUser(): User + { + return $this->user; + } + + public function getAvailableTo(): DateTimeImmutable + { + return $this->availableTo; + } + + public function getToken(): string + { + return $this->token; + } +} diff --git a/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php new file mode 100644 index 0000000..b8214cd --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenModel.php @@ -0,0 +1,40 @@ +|EmailConfirmationTokenModel newModelQuery() + * @method static Builder|EmailConfirmationTokenModel newQuery() + * @method static Builder|EmailConfirmationTokenModel query() + * @method static Builder|EmailConfirmationTokenModel whereId($value) + * @method static Builder|EmailConfirmationTokenModel whereUserId($value) + * @method static Builder|EmailConfirmationTokenModel whereToken($value) + * @method static Builder|EmailConfirmationTokenModel whereAvailableTo($value) + * + * @mixin \Eloquent + */ +class EmailConfirmationTokenModel extends Model +{ + protected $table = 'email_confirmation_tokens'; + + public $timestamps = false; + + protected $fillable = [ + 'user_id', + 'token', + 'available_to', + ]; + + protected $casts = [ + 'available_to' => 'immutable_datetime', + ]; +} diff --git a/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php new file mode 100644 index 0000000..ed71f62 --- /dev/null +++ b/backend/app/Email/EmailConfirmationToken/EmailConfirmationTokenRepository.php @@ -0,0 +1,18 @@ +id(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->string('token')->unique(); + $table->dateTime('available_to'); + }); + } + + public function down(): void + { + Schema::dropIfExists('email_confirmation_tokens'); + } +}; diff --git a/backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php b/backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php new file mode 100644 index 0000000..8ef77ad --- /dev/null +++ b/backend/tests/Fakes/FakeEmailConfirmationTokenRepository.php @@ -0,0 +1,82 @@ +nextId(); + $token = new EmailConfirmationToken( + id: $id, + user: $dto->user, + availableTo: $dto->availableTo, + token: bin2hex(random_bytes(32)), + ); + $this->existingTokens[$id] = $token; + + return $this->copy($token); + } + + public function findByToken(string $token): ?EmailConfirmationToken + { + foreach ($this->existingTokens as $existing) { + if ($existing->getToken() === $token) { + return $this->copy($existing); + } + } + + return null; + } + + public function findByUser(User $user): ?EmailConfirmationToken + { + foreach ($this->existingTokens as $existing) { + if ($existing->getUser()->getId() === $user->getId()) { + return $this->copy($existing); + } + } + + return null; + } + + public function delete(int $id): void + { + unset($this->existingTokens[$id]); + } + + private function copy( + EmailConfirmationToken $token, + ): EmailConfirmationToken { + $user = $this->userRepo->find($token->getUser()->getId()) + ?? $token->getUser(); + + return new EmailConfirmationToken( + id: $token->getId(), + user: $user, + availableTo: $token->getAvailableTo(), + token: $token->getToken(), + ); + } + + private function nextId(): int + { + return count($this->existingTokens) + 1; + } +} From 2890781a56235162f58b6fefa7e5e36e9ce99b0a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:06:30 +0300 Subject: [PATCH 3/5] add Emailer and EmailFactory interfaces with laravel + fake impls --- backend/app/Email/EmailFactory.php | 8 ++++++ backend/app/Email/Emailer.php | 8 ++++++ backend/app/Email/LaravelEmailFactory.php | 15 ++++++++++ backend/app/Email/LaravelMailer.php | 25 ++++++++++++++++ backend/tests/Fakes/FakeEmailFactory.php | 13 +++++++++ backend/tests/Fakes/FakeEmailer.php | 35 +++++++++++++++++++++++ 6 files changed, 104 insertions(+) create mode 100644 backend/app/Email/EmailFactory.php create mode 100644 backend/app/Email/Emailer.php create mode 100644 backend/app/Email/LaravelEmailFactory.php create mode 100644 backend/app/Email/LaravelMailer.php create mode 100644 backend/tests/Fakes/FakeEmailFactory.php create mode 100644 backend/tests/Fakes/FakeEmailer.php diff --git a/backend/app/Email/EmailFactory.php b/backend/app/Email/EmailFactory.php new file mode 100644 index 0000000..43307ad --- /dev/null +++ b/backend/app/Email/EmailFactory.php @@ -0,0 +1,8 @@ +confirmationUrlPrefix}{$token}"; + } +} diff --git a/backend/app/Email/LaravelMailer.php b/backend/app/Email/LaravelMailer.php new file mode 100644 index 0000000..cf41752 --- /dev/null +++ b/backend/app/Email/LaravelMailer.php @@ -0,0 +1,25 @@ +mailer->raw( + $body, + function (Message $message) use ($from, $to) { + $message->from($from) + ->to($to) + ->subject('TIDE'); + } + ); + } +} diff --git a/backend/tests/Fakes/FakeEmailFactory.php b/backend/tests/Fakes/FakeEmailFactory.php new file mode 100644 index 0000000..b6fb069 --- /dev/null +++ b/backend/tests/Fakes/FakeEmailFactory.php @@ -0,0 +1,13 @@ + + */ + private array $sentEmails = []; + + public function send(string $from, string $to, string $body): void + { + $this->sentEmails[] = [ + 'from' => $from, + 'to' => $to, + 'body' => $body, + ]; + } + + public function getNumberOfEmailsSent(): int + { + return count($this->sentEmails); + } + + /** + * @return array + */ + public function getSentEmails(): array + { + return $this->sentEmails; + } +} From 60308988f75918967a1cad6b28b3b1958787637a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:07:08 +0300 Subject: [PATCH 4/5] test ConfirmUserEmail use case --- .../User/UseCases/ConfirmUserEmailTest.php | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php diff --git a/backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php b/backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php new file mode 100644 index 0000000..2263902 --- /dev/null +++ b/backend/tests/Unit/User/UseCases/ConfirmUserEmailTest.php @@ -0,0 +1,182 @@ +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)); + } +} From 6823bdeb503c42c0390dfb1225a6dfa4b03bbc46 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:07:30 +0300 Subject: [PATCH 5/5] implement ConfirmUserEmail use case --- .../ConfirmUserEmail/ConfirmUserEmail.php | 65 +++++++++++++++++++ .../ConfirmUserEmailRequest.php | 11 ++++ 2 files changed, 76 insertions(+) create mode 100644 backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php create mode 100644 backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php diff --git a/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php new file mode 100644 index 0000000..1fdb563 --- /dev/null +++ b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmail.php @@ -0,0 +1,65 @@ +token === null || $request->token === '') { + throw new BadRequestException('token is required'); + } + 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' + ); + } + + $token = $this->tokenRepo->findByToken($request->token); + if ($token === null) { + throw new DomainException('token not found'); + } + + $now = $this->clock->now(); + if ($token->getAvailableTo() < $now) { + throw new DomainException('token expired'); + } + + $user = $token->getUser(); + $confirmedUser = new User( + id: $user->getId(), + email: $user->getEmail(), + displayName: $user->getDisplayName(), + passwordHash: $this->hasher->hash($request->password), + isAdmin: $user->isAdmin(), + emailConfirmedAt: $now, + ); + $this->userRepo->update($confirmedUser); + $this->tokenRepo->delete($token->getId()); + } +} diff --git a/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php new file mode 100644 index 0000000..6945ebe --- /dev/null +++ b/backend/app/User/UseCases/ConfirmUserEmail/ConfirmUserEmailRequest.php @@ -0,0 +1,11 @@ +