From d547ec2c61475773857839d7c07e7b0af14d6de3 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:00:22 +0300 Subject: [PATCH 1/4] test User entity displayname and email confirmation --- backend/tests/Unit/User/UserTest.php | 64 ++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 backend/tests/Unit/User/UserTest.php diff --git a/backend/tests/Unit/User/UserTest.php b/backend/tests/Unit/User/UserTest.php new file mode 100644 index 0000000..61852cd --- /dev/null +++ b/backend/tests/Unit/User/UserTest.php @@ -0,0 +1,64 @@ +assertSame(7, $user->getId()); + $this->assertSame('alice@example.com', $user->getEmail()->value()); + $this->assertSame('alice', $user->getDisplayName()); + $this->assertSame('hash', $user->getPasswordHash()); + $this->assertTrue($user->isAdmin()); + $this->assertSame($confirmedAt, $user->getEmailConfirmedAt()); + } + + public function test_user_email_confirmed_at_can_be_null(): void + { + $user = new User( + id: 1, + email: new EmailAddress('bob@example.com'), + displayName: 'bob', + passwordHash: '', + isAdmin: false, + emailConfirmedAt: null, + ); + + $this->assertNull($user->getEmailConfirmedAt()); + $this->assertFalse($user->isEmailConfirmed()); + } + + public function test_email_confirmed_when_timestamp_present(): void + { + $user = new User( + id: 1, + email: new EmailAddress('bob@example.com'), + displayName: 'bob', + passwordHash: 'hash', + isAdmin: false, + emailConfirmedAt: new DateTimeImmutable('2026-05-01T00:00:00Z'), + ); + + $this->assertTrue($user->isEmailConfirmed()); + } +} From 298b8634ec5257830ce0d7463f5451f29c6627f7 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:03:19 +0300 Subject: [PATCH 2/4] extend User entity with displayname and email confirmation Add display_name (unique) and email_confirmed_at columns plus matching getters, DTO fields, repo methods (findByDisplayName, update), and migration. Existing auth tests updated to construct User with the new params. --- backend/app/User/CreateUserDto.php | 3 + backend/app/User/EloquentUserRepository.php | 43 ++++++++++++++ backend/app/User/User.php | 18 ++++++ backend/app/User/UserModel.php | 8 +++ backend/app/User/UserRepository.php | 4 ++ .../2026_05_06_000000_create_users_table.php | 2 + backend/tests/Fakes/FakeUserRepository.php | 58 ++++++++++++++----- .../Auth/Middleware/AuthMiddlewareTest.php | 6 +- .../Unit/Auth/UseCases/CreateSessionTest.php | 2 + .../tests/Unit/Auth/UseCases/LogoutTest.php | 2 + 10 files changed, 131 insertions(+), 15 deletions(-) diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php index 7c353e5..794d701 100644 --- a/backend/app/User/CreateUserDto.php +++ b/backend/app/User/CreateUserDto.php @@ -3,12 +3,15 @@ namespace App\User; use App\Shared\ValueObject\EmailAddress; +use DateTimeImmutable; readonly class CreateUserDto { public function __construct( public EmailAddress $email, + public string $displayName, public string $passwordHash, public bool $isAdmin, + public ?DateTimeImmutable $emailConfirmedAt, ) {} } diff --git a/backend/app/User/EloquentUserRepository.php b/backend/app/User/EloquentUserRepository.php index f5887ea..a4fdc67 100644 --- a/backend/app/User/EloquentUserRepository.php +++ b/backend/app/User/EloquentUserRepository.php @@ -3,6 +3,9 @@ namespace App\User; use App\Shared\ValueObject\EmailAddress; +use DateTimeImmutable; +use DateTimeZone; +use RuntimeException; class EloquentUserRepository implements UserRepository { @@ -10,8 +13,10 @@ class EloquentUserRepository implements UserRepository { $model = UserModel::create([ 'email' => $dto->email->value(), + 'display_name' => $dto->displayName, 'password_hash' => $dto->passwordHash, 'is_admin' => $dto->isAdmin, + 'email_confirmed_at' => $dto->emailConfirmedAt, ]); return $this->toDomain($model); @@ -31,13 +36,51 @@ class EloquentUserRepository implements UserRepository return $model === null ? null : $this->toDomain($model); } + public function findByDisplayName(string $displayName): ?User + { + $model = UserModel::where('display_name', $displayName)->first(); + + return $model === null ? null : $this->toDomain($model); + } + + /** + * @throws RuntimeException + */ + public function update(User $user): User + { + $model = UserModel::find($user->getId()); + if ($model === null) { + throw new RuntimeException( + "User with id: {$user->getId()} does not exist" + ); + } + $model->email = $user->getEmail()->value(); + $model->display_name = $user->getDisplayName(); + $model->password_hash = $user->getPasswordHash(); + $model->is_admin = $user->isAdmin(); + $model->email_confirmed_at = $user->getEmailConfirmedAt(); + $model->save(); + + return $this->toDomain($model); + } + private function toDomain(UserModel $model): User { + $confirmedAt = null; + if ($model->email_confirmed_at !== null) { + $confirmedAt = new DateTimeImmutable( + $model->email_confirmed_at->toDateTimeString(), + new DateTimeZone('UTC'), + ); + } + return new User( id: $model->id, email: new EmailAddress($model->email), + displayName: $model->display_name, passwordHash: $model->password_hash, isAdmin: $model->is_admin, + emailConfirmedAt: $confirmedAt, ); } } diff --git a/backend/app/User/User.php b/backend/app/User/User.php index 48bc7c0..ba690b8 100644 --- a/backend/app/User/User.php +++ b/backend/app/User/User.php @@ -3,14 +3,17 @@ namespace App\User; use App\Shared\ValueObject\EmailAddress; +use DateTimeImmutable; class User { public function __construct( private int $id, private EmailAddress $email, + private string $displayName, private string $passwordHash, private bool $isAdmin, + private ?DateTimeImmutable $emailConfirmedAt, ) {} public function getId(): int @@ -23,6 +26,11 @@ class User return $this->email; } + public function getDisplayName(): string + { + return $this->displayName; + } + public function getPasswordHash(): string { return $this->passwordHash; @@ -32,4 +40,14 @@ class User { return $this->isAdmin; } + + public function getEmailConfirmedAt(): ?DateTimeImmutable + { + return $this->emailConfirmedAt; + } + + public function isEmailConfirmed(): bool + { + return $this->emailConfirmedAt !== null; + } } diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php index 767ac29..4de109c 100644 --- a/backend/app/User/UserModel.php +++ b/backend/app/User/UserModel.php @@ -2,21 +2,26 @@ namespace App\User; +use DateTimeImmutable; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; /** * @property int $id * @property string $email + * @property string $display_name * @property string $password_hash * @property bool $is_admin + * @property ?DateTimeImmutable $email_confirmed_at * * @method static Builder|UserModel newModelQuery() * @method static Builder|UserModel newQuery() * @method static Builder|UserModel query() * @method static Builder|UserModel whereId($value) * @method static Builder|UserModel whereEmail($value) + * @method static Builder|UserModel whereDisplayName($value) * @method static Builder|UserModel whereIsAdmin($value) + * @method static Builder|UserModel whereEmailConfirmedAt($value) * * @mixin \Eloquent */ @@ -28,11 +33,14 @@ class UserModel extends Model protected $fillable = [ 'email', + 'display_name', 'password_hash', 'is_admin', + 'email_confirmed_at', ]; protected $casts = [ 'is_admin' => 'boolean', + 'email_confirmed_at' => 'immutable_datetime', ]; } diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php index 4805f3f..868371f 100644 --- a/backend/app/User/UserRepository.php +++ b/backend/app/User/UserRepository.php @@ -11,4 +11,8 @@ interface UserRepository public function find(int $id): ?User; public function findByEmail(EmailAddress $email): ?User; + + public function findByDisplayName(string $displayName): ?User; + + public function update(User $user): User; } diff --git a/backend/database/migrations/2026_05_06_000000_create_users_table.php b/backend/database/migrations/2026_05_06_000000_create_users_table.php index 45e4086..be0da14 100644 --- a/backend/database/migrations/2026_05_06_000000_create_users_table.php +++ b/backend/database/migrations/2026_05_06_000000_create_users_table.php @@ -11,8 +11,10 @@ return new class extends Migration Schema::create('users', function (Blueprint $table) { $table->id(); $table->string('email')->unique(); + $table->string('display_name')->unique(); $table->string('password_hash'); $table->boolean('is_admin')->default(false); + $table->dateTime('email_confirmed_at')->nullable(); }); } diff --git a/backend/tests/Fakes/FakeUserRepository.php b/backend/tests/Fakes/FakeUserRepository.php index 3ad23bd..49bed23 100644 --- a/backend/tests/Fakes/FakeUserRepository.php +++ b/backend/tests/Fakes/FakeUserRepository.php @@ -6,6 +6,7 @@ use App\Shared\ValueObject\EmailAddress; use App\User\CreateUserDto; use App\User\User; use App\User\UserRepository; +use RuntimeException; class FakeUserRepository implements UserRepository { @@ -20,12 +21,14 @@ class FakeUserRepository implements UserRepository $user = new User( id: $id, email: $dto->email, + displayName: $dto->displayName, passwordHash: $dto->passwordHash, isAdmin: $dto->isAdmin, + emailConfirmedAt: $dto->emailConfirmedAt, ); $this->existingUsers[$id] = $user; - return $user; + return $this->copy($user); } public function find(int $id): ?User @@ -35,30 +38,59 @@ class FakeUserRepository implements UserRepository return null; } - return new User( - id: $user->getId(), - email: $user->getEmail(), - passwordHash: $user->getPasswordHash(), - isAdmin: $user->isAdmin(), - ); + return $this->copy($user); } public function findByEmail(EmailAddress $email): ?User { foreach ($this->existingUsers as $user) { if ($user->getEmail()->equals($email)) { - return new User( - id: $user->getId(), - email: $user->getEmail(), - passwordHash: $user->getPasswordHash(), - isAdmin: $user->isAdmin(), - ); + return $this->copy($user); } } return null; } + public function findByDisplayName(string $displayName): ?User + { + foreach ($this->existingUsers as $user) { + if ($user->getDisplayName() === $displayName) { + return $this->copy($user); + } + } + + return null; + } + + /** + * @throws RuntimeException + */ + public function update(User $user): User + { + $id = $user->getId(); + if (! isset($this->existingUsers[$id])) { + throw new RuntimeException( + "User with id: $id does not exist" + ); + } + $this->existingUsers[$id] = $user; + + return $this->copy($user); + } + + private function copy(User $user): User + { + return new User( + id: $user->getId(), + email: $user->getEmail(), + displayName: $user->getDisplayName(), + passwordHash: $user->getPasswordHash(), + isAdmin: $user->isAdmin(), + emailConfirmedAt: $user->getEmailConfirmedAt(), + ); + } + private function getNextId(): int { return count($this->existingUsers) + 1; diff --git a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php index 7f844c9..232ad61 100644 --- a/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php +++ b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -58,13 +58,15 @@ class AuthMiddlewareTest extends TestCase }; } - private function makeUser(int $id = 7): User + private function makeUser(int $id): User { return new User( id: $id, email: new EmailAddress('user@example.com'), + displayName: 'user', passwordHash: 'hashed:irrelevant', isAdmin: false, + emailConfirmedAt: null, ); } @@ -100,7 +102,7 @@ class AuthMiddlewareTest extends TestCase { $this->sessionRepo->create(new CreateSessionDto( token: 'expired-token', - user: $this->makeUser(), + user: $this->makeUser(7), createdAt: $this->now->modify('-8 days'), expiresAt: $this->now->modify('-1 day'), )); diff --git a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php index d5460b5..71bcc42 100644 --- a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php +++ b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php @@ -46,8 +46,10 @@ class CreateSessionTest extends TestCase return new User( id: 7, email: new EmailAddress('user@example.com'), + displayName: 'user', passwordHash: 'hashed:irrelevant', isAdmin: false, + emailConfirmedAt: null, ); } diff --git a/backend/tests/Unit/Auth/UseCases/LogoutTest.php b/backend/tests/Unit/Auth/UseCases/LogoutTest.php index c98c582..0d76819 100644 --- a/backend/tests/Unit/Auth/UseCases/LogoutTest.php +++ b/backend/tests/Unit/Auth/UseCases/LogoutTest.php @@ -34,8 +34,10 @@ class LogoutTest extends TestCase $user = new User( id: 7, email: new EmailAddress('user@example.com'), + displayName: 'user', passwordHash: 'hashed:irrelevant', isAdmin: false, + emailConfirmedAt: null, ); $this->sessionRepo->create(new CreateSessionDto( token: 'token-abc', From 4829a02aacf5e1faedf7f27dd7725ce79f13a82d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:03:40 +0300 Subject: [PATCH 3/4] test SignupUser displayname requirement Adds displayname to existing assertions and new tests covering: null/short/invalid-charset displayname, duplicate displayname, findability by displayname. AuthenticateUser tests pick up the seedUser displayname argument. --- .../Auth/UseCases/AuthenticateUserTest.php | 6 ++ .../Unit/User/UseCases/SignupUserTest.php | 74 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php index 6d00862..1fc1d17 100644 --- a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php +++ b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php @@ -33,13 +33,16 @@ class AuthenticateUserTest extends TestCase private function seedUser( string $email, + string $displayName, string $password, bool $isAdmin, ): User { return $this->userRepo->create(new CreateUserDto( email: new EmailAddress($email), + displayName: $displayName, passwordHash: $this->hasher->hash($password), isAdmin: $isAdmin, + emailConfirmedAt: null, )); } @@ -101,6 +104,7 @@ class AuthenticateUserTest extends TestCase { $this->seedUser( email: 'user@example.com', + displayName: 'user', password: 'correctpassword', isAdmin: false, ); @@ -116,6 +120,7 @@ class AuthenticateUserTest extends TestCase { $seeded = $this->seedUser( email: 'user@example.com', + displayName: 'user', password: 'correctpassword', isAdmin: false, ); @@ -140,6 +145,7 @@ class AuthenticateUserTest extends TestCase { $this->seedUser( email: 'admin@example.com', + displayName: 'admin', password: 'adminpassword', isAdmin: true, ); diff --git a/backend/tests/Unit/User/UseCases/SignupUserTest.php b/backend/tests/Unit/User/UseCases/SignupUserTest.php index 1ae841e..9babb7e 100644 --- a/backend/tests/Unit/User/UseCases/SignupUserTest.php +++ b/backend/tests/Unit/User/UseCases/SignupUserTest.php @@ -36,6 +36,7 @@ class SignupUserTest extends TestCase $this->expectException(BadRequestException::class); $this->useCase->execute(new SignupUserRequest( email: null, + displayName: 'alice', password: 'longenoughpassword', )); } @@ -45,6 +46,7 @@ class SignupUserTest extends TestCase $this->expectException(BadRequestException::class); $this->useCase->execute(new SignupUserRequest( email: '', + displayName: 'alice', password: 'longenoughpassword', )); } @@ -54,6 +56,37 @@ class SignupUserTest extends TestCase $this->expectException(BadRequestException::class); $this->useCase->execute(new SignupUserRequest( email: 'not-an-email', + displayName: 'alice', + password: 'longenoughpassword', + )); + } + + 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, + password: 'longenoughpassword', + )); + } + + 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', + password: 'longenoughpassword', + )); + } + + 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', password: 'longenoughpassword', )); } @@ -63,6 +96,7 @@ class SignupUserTest extends TestCase $this->expectException(BadRequestException::class); $this->useCase->execute(new SignupUserRequest( email: 'user@example.com', + displayName: 'alice', password: null, )); } @@ -72,6 +106,7 @@ class SignupUserTest extends TestCase $this->expectException(BadRequestException::class); $this->useCase->execute(new SignupUserRequest( email: 'user@example.com', + displayName: 'alice', password: 'short', )); } @@ -80,13 +115,34 @@ class SignupUserTest extends TestCase { $this->userRepo->create(new CreateUserDto( email: new EmailAddress('user@example.com'), + displayName: 'first', passwordHash: $this->hasher->hash('original-password'), isAdmin: false, + emailConfirmedAt: null, )); $this->expectException(DomainException::class); $this->useCase->execute(new SignupUserRequest( email: 'user@example.com', + displayName: 'second', + password: 'second-attempt-password', + )); + } + + public function test_duplicate_display_name_throws_domain_exception(): void + { + $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('first@example.com'), + displayName: 'taken', + passwordHash: $this->hasher->hash('original-password'), + isAdmin: false, + emailConfirmedAt: null, + )); + + $this->expectException(DomainException::class); + $this->useCase->execute(new SignupUserRequest( + email: 'second@example.com', + displayName: 'taken', password: 'second-attempt-password', )); } @@ -95,22 +151,26 @@ class SignupUserTest extends TestCase { $created = $this->useCase->execute(new SignupUserRequest( email: 'new@example.com', + displayName: 'newuser', password: 'longenoughpassword', )); $this->assertInstanceOf(User::class, $created); $this->assertSame('new@example.com', $created->getEmail()->value()); + $this->assertSame('newuser', $created->getDisplayName()); $this->assertSame( $this->hasher->hash('longenoughpassword'), $created->getPasswordHash(), ); $this->assertFalse($created->isAdmin()); + $this->assertFalse($created->isEmailConfirmed()); } public function test_created_user_is_findable_by_email(): void { $created = $this->useCase->execute(new SignupUserRequest( email: 'lookup@example.com', + displayName: 'lookup', password: 'longenoughpassword', )); @@ -121,10 +181,24 @@ class SignupUserTest extends TestCase $this->assertSame($created->getId(), $found->getId()); } + public function test_created_user_is_findable_by_display_name(): void + { + $created = $this->useCase->execute(new SignupUserRequest( + email: 'lookup@example.com', + displayName: 'lookupbyname', + password: 'longenoughpassword', + )); + + $found = $this->userRepo->findByDisplayName('lookupbyname'); + $this->assertNotNull($found); + $this->assertSame($created->getId(), $found->getId()); + } + public function test_signup_normalizes_email_domain(): void { $created = $this->useCase->execute(new SignupUserRequest( email: 'Mixed@CASE.com', + displayName: 'mixed', password: 'longenoughpassword', )); From 4b1689d17ed84d4e31b6bb0b3245fed8dbee0530 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Wed, 6 May 2026 22:03:49 +0300 Subject: [PATCH 4/4] implement SignupUser displayname requirement --- .../User/UseCases/SignupUser/SignupUser.php | 20 +++++++++++++++++++ .../UseCases/SignupUser/SignupUserRequest.php | 1 + 2 files changed, 21 insertions(+) diff --git a/backend/app/User/UseCases/SignupUser/SignupUser.php b/backend/app/User/UseCases/SignupUser/SignupUser.php index 7f2c78e..7228e87 100644 --- a/backend/app/User/UseCases/SignupUser/SignupUser.php +++ b/backend/app/User/UseCases/SignupUser/SignupUser.php @@ -15,6 +15,8 @@ class SignupUser { private const MIN_PASSWORD_LENGTH = 8; + private const DISPLAY_NAME_PATTERN = '/^[a-z0-9_-]{3,30}$/'; + public function __construct( private UserRepository $userRepo, private PasswordHasher $hasher, @@ -29,6 +31,19 @@ class SignupUser if ($request->email === null || $request->email === '') { throw new BadRequestException('email is required'); } + if ($request->displayName === null || $request->displayName === '') { + throw new BadRequestException('displayName is required'); + } + if ( + preg_match( + self::DISPLAY_NAME_PATTERN, + $request->displayName, + ) !== 1 + ) { + throw new BadRequestException( + 'displayName must be 3-30 chars of [a-z0-9_-]' + ); + } if ($request->password === null || $request->password === '') { throw new BadRequestException('password is required'); } @@ -47,11 +62,16 @@ class SignupUser if ($this->userRepo->findByEmail($email) !== null) { throw new DomainException('email already registered'); } + if ($this->userRepo->findByDisplayName($request->displayName) !== null) { + throw new DomainException('displayName already taken'); + } return $this->userRepo->create(new CreateUserDto( email: $email, + displayName: $request->displayName, passwordHash: $this->hasher->hash($request->password), isAdmin: false, + emailConfirmedAt: null, )); } } diff --git a/backend/app/User/UseCases/SignupUser/SignupUserRequest.php b/backend/app/User/UseCases/SignupUser/SignupUserRequest.php index b6f809f..e202131 100644 --- a/backend/app/User/UseCases/SignupUser/SignupUserRequest.php +++ b/backend/app/User/UseCases/SignupUser/SignupUserRequest.php @@ -6,6 +6,7 @@ class SignupUserRequest { public function __construct( public ?string $email, + public ?string $displayName, public ?string $password, ) {} }