diff --git a/backend/app/Auth/BcryptPasswordHasher.php b/backend/app/Auth/BcryptPasswordHasher.php new file mode 100644 index 0000000..0bc4a46 --- /dev/null +++ b/backend/app/Auth/BcryptPasswordHasher.php @@ -0,0 +1,16 @@ + $dto->token, + 'user_id' => $dto->user->getId(), + 'created_at' => $dto->createdAt, + 'expires_at' => $dto->expiresAt, + ]); + + return new Session( + token: $dto->token, + user: $dto->user, + createdAt: $dto->createdAt, + expiresAt: $dto->expiresAt, + ); + } + + public function findByToken(string $token): ?Session + { + $model = SessionModel::find($token); + if ($model === null) { + return null; + } + $user = $this->userRepo->find($model->user_id); + if ($user === null) { + return null; + } + $utc = new DateTimeZone('UTC'); + + return new Session( + token: $model->token, + user: $user, + createdAt: new DateTimeImmutable( + $model->created_at->toDateTimeString(), + $utc + ), + expiresAt: new DateTimeImmutable( + $model->expires_at->toDateTimeString(), + $utc + ), + ); + } + + public function deleteByToken(string $token): void + { + SessionModel::where('token', $token)->delete(); + } +} diff --git a/backend/app/Auth/PasswordHasher.php b/backend/app/Auth/PasswordHasher.php new file mode 100644 index 0000000..ab57a41 --- /dev/null +++ b/backend/app/Auth/PasswordHasher.php @@ -0,0 +1,10 @@ +token; + } + + public function getUser(): User + { + return $this->user; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getExpiresAt(): DateTimeImmutable + { + return $this->expiresAt; + } + + public function isExpired(DateTimeImmutable $now): bool + { + return $now >= $this->expiresAt; + } +} diff --git a/backend/app/Auth/SessionModel.php b/backend/app/Auth/SessionModel.php new file mode 100644 index 0000000..574a142 --- /dev/null +++ b/backend/app/Auth/SessionModel.php @@ -0,0 +1,44 @@ +|SessionModel newModelQuery() + * @method static Builder|SessionModel newQuery() + * @method static Builder|SessionModel query() + * + * @mixin \Eloquent + */ +class SessionModel extends Model +{ + protected $table = 'sessions'; + + protected $primaryKey = 'token'; + + public $incrementing = false; + + protected $keyType = 'string'; + + public $timestamps = false; + + protected $fillable = [ + 'token', + 'user_id', + 'created_at', + 'expires_at', + ]; + + protected $casts = [ + 'created_at' => 'datetime', + 'expires_at' => 'datetime', + ]; +} diff --git a/backend/app/Auth/SessionRepository.php b/backend/app/Auth/SessionRepository.php new file mode 100644 index 0000000..cabae60 --- /dev/null +++ b/backend/app/Auth/SessionRepository.php @@ -0,0 +1,12 @@ +email === null || $request->email === '') { + throw new BadRequestException('email is required'); + } + if ($request->password === null || $request->password === '') { + throw new BadRequestException('password is required'); + } + + try { + $email = new EmailAddress($request->email); + } catch (InvalidArgumentException $exception) { + throw new BadRequestException($exception->getMessage()); + } + + $user = $this->userRepo->findByEmail($email); + if ($user === null) { + throw new UnauthorizedException('invalid credentials'); + } + + $passwordMatches = $this->hasher->verify( + $request->password, + $user->getPasswordHash(), + ); + if (! $passwordMatches) { + throw new UnauthorizedException('invalid credentials'); + } + + return $user; + } +} diff --git a/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php new file mode 100644 index 0000000..aa8b1df --- /dev/null +++ b/backend/app/Auth/UseCases/AuthenticateUser/AuthenticateUserRequest.php @@ -0,0 +1,11 @@ +clock->now(); + $expiresAt = $now->modify(self::SESSION_LIFETIME); + + return $this->sessionRepo->create(new CreateSessionDto( + token: $this->tokenGenerator->generate(), + user: $user, + createdAt: $now, + expiresAt: $expiresAt, + )); + } +} diff --git a/backend/app/Auth/UseCases/Logout/Logout.php b/backend/app/Auth/UseCases/Logout/Logout.php new file mode 100644 index 0000000..31b16de --- /dev/null +++ b/backend/app/Auth/UseCases/Logout/Logout.php @@ -0,0 +1,17 @@ +sessionRepo->deleteByToken($token); + } +} diff --git a/backend/app/Http/Middleware/AuthMiddleware.php b/backend/app/Http/Middleware/AuthMiddleware.php new file mode 100644 index 0000000..8e21ece --- /dev/null +++ b/backend/app/Http/Middleware/AuthMiddleware.php @@ -0,0 +1,51 @@ +cookie(self::COOKIE_NAME); + if (! is_string($token) || $token === '') { + return $this->unauthorized(); + } + + $session = $this->sessionRepo->findByToken($token); + if ($session === null) { + return $this->unauthorized(); + } + + if ($session->isExpired($this->clock->now())) { + $this->sessionRepo->deleteByToken($token); + + return $this->unauthorized(); + } + + $request->attributes->set('user', $session->getUser()); + + return $next($request); + } + + private function unauthorized(): JsonResponse + { + return new JsonResponse(['error' => 'unauthenticated'], 401); + } +} diff --git a/backend/app/Providers/AppServiceProvider.php b/backend/app/Providers/AppServiceProvider.php index 452e6b6..5b73916 100644 --- a/backend/app/Providers/AppServiceProvider.php +++ b/backend/app/Providers/AppServiceProvider.php @@ -2,21 +2,23 @@ namespace App\Providers; +use App\Auth\BcryptPasswordHasher; +use App\Auth\Clock; +use App\Auth\PasswordHasher; +use App\Auth\RandomTokenGenerator; +use App\Auth\SystemClock; +use App\Auth\TokenGenerator; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { - /** - * Register any application services. - */ public function register(): void { - // + $this->app->bind(Clock::class, SystemClock::class); + $this->app->bind(TokenGenerator::class, RandomTokenGenerator::class); + $this->app->bind(PasswordHasher::class, BcryptPasswordHasher::class); } - /** - * Bootstrap any application services. - */ public function boot(): void { // diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php new file mode 100644 index 0000000..0671b59 --- /dev/null +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -0,0 +1,24 @@ +app->bind( + UserRepository::class, + EloquentUserRepository::class, + ); + $this->app->bind( + SessionRepository::class, + EloquentSessionRepository::class, + ); + } +} diff --git a/backend/app/User/CreateUserDto.php b/backend/app/User/CreateUserDto.php new file mode 100644 index 0000000..7c353e5 --- /dev/null +++ b/backend/app/User/CreateUserDto.php @@ -0,0 +1,14 @@ + $dto->email->value(), + 'password_hash' => $dto->passwordHash, + 'is_admin' => $dto->isAdmin, + ]); + + return $this->toDomain($model); + } + + public function find(int $id): ?User + { + $model = UserModel::find($id); + + return $model === null ? null : $this->toDomain($model); + } + + public function findByEmail(EmailAddress $email): ?User + { + $model = UserModel::where('email', $email->value())->first(); + + return $model === null ? null : $this->toDomain($model); + } + + private function toDomain(UserModel $model): User + { + return new User( + id: $model->id, + email: new EmailAddress($model->email), + passwordHash: $model->password_hash, + isAdmin: $model->is_admin, + ); + } +} diff --git a/backend/app/User/UseCases/SignupUser/SignupUser.php b/backend/app/User/UseCases/SignupUser/SignupUser.php new file mode 100644 index 0000000..7f2c78e --- /dev/null +++ b/backend/app/User/UseCases/SignupUser/SignupUser.php @@ -0,0 +1,57 @@ +email === null || $request->email === '') { + throw new BadRequestException('email 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' + ); + } + + try { + $email = new EmailAddress($request->email); + } catch (InvalidArgumentException $exception) { + throw new BadRequestException($exception->getMessage()); + } + + if ($this->userRepo->findByEmail($email) !== null) { + throw new DomainException('email already registered'); + } + + return $this->userRepo->create(new CreateUserDto( + email: $email, + passwordHash: $this->hasher->hash($request->password), + isAdmin: false, + )); + } +} diff --git a/backend/app/User/UseCases/SignupUser/SignupUserRequest.php b/backend/app/User/UseCases/SignupUser/SignupUserRequest.php new file mode 100644 index 0000000..b6f809f --- /dev/null +++ b/backend/app/User/UseCases/SignupUser/SignupUserRequest.php @@ -0,0 +1,11 @@ +id; + } + + public function getEmail(): EmailAddress + { + return $this->email; + } + + public function getPasswordHash(): string + { + return $this->passwordHash; + } + + public function isAdmin(): bool + { + return $this->isAdmin; + } +} diff --git a/backend/app/User/UserModel.php b/backend/app/User/UserModel.php new file mode 100644 index 0000000..767ac29 --- /dev/null +++ b/backend/app/User/UserModel.php @@ -0,0 +1,38 @@ +|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 whereIsAdmin($value) + * + * @mixin \Eloquent + */ +class UserModel extends Model +{ + protected $table = 'users'; + + public $timestamps = false; + + protected $fillable = [ + 'email', + 'password_hash', + 'is_admin', + ]; + + protected $casts = [ + 'is_admin' => 'boolean', + ]; +} diff --git a/backend/app/User/UserRepository.php b/backend/app/User/UserRepository.php new file mode 100644 index 0000000..4805f3f --- /dev/null +++ b/backend/app/User/UserRepository.php @@ -0,0 +1,14 @@ +id(); + $table->string('email')->unique(); + $table->string('password_hash'); + $table->boolean('is_admin')->default(false); + }); + } + + public function down(): void + { + Schema::dropIfExists('users'); + } +}; diff --git a/backend/database/migrations/2026_05_06_000001_create_sessions_table.php b/backend/database/migrations/2026_05_06_000001_create_sessions_table.php new file mode 100644 index 0000000..77c5c40 --- /dev/null +++ b/backend/database/migrations/2026_05_06_000001_create_sessions_table.php @@ -0,0 +1,25 @@ +string('token')->primary(); + $table->foreignId('user_id') + ->constrained('users') + ->cascadeOnDelete(); + $table->dateTime('created_at'); + $table->dateTime('expires_at')->index(); + }); + } + + public function down(): void + { + Schema::dropIfExists('sessions'); + } +}; diff --git a/backend/tests/Fakes/FakeClock.php b/backend/tests/Fakes/FakeClock.php new file mode 100644 index 0000000..f112836 --- /dev/null +++ b/backend/tests/Fakes/FakeClock.php @@ -0,0 +1,21 @@ +currentTime; + } + + public function setTime(DateTimeImmutable $newTime): void + { + $this->currentTime = $newTime; + } +} diff --git a/backend/tests/Fakes/FakePasswordHasher.php b/backend/tests/Fakes/FakePasswordHasher.php new file mode 100644 index 0000000..9e93325 --- /dev/null +++ b/backend/tests/Fakes/FakePasswordHasher.php @@ -0,0 +1,18 @@ +hash($password) === $hash; + } +} diff --git a/backend/tests/Fakes/FakeSessionRepository.php b/backend/tests/Fakes/FakeSessionRepository.php new file mode 100644 index 0000000..b110a1d --- /dev/null +++ b/backend/tests/Fakes/FakeSessionRepository.php @@ -0,0 +1,48 @@ +token, + user: $dto->user, + createdAt: $dto->createdAt, + expiresAt: $dto->expiresAt, + ); + $this->sessions[$dto->token] = $session; + + return $session; + } + + public function findByToken(string $token): ?Session + { + $session = $this->sessions[$token] ?? null; + if ($session === null) { + return null; + } + + return new Session( + token: $session->getToken(), + user: $session->getUser(), + createdAt: $session->getCreatedAt(), + expiresAt: $session->getExpiresAt(), + ); + } + + public function deleteByToken(string $token): void + { + unset($this->sessions[$token]); + } +} diff --git a/backend/tests/Fakes/FakeTokenGenerator.php b/backend/tests/Fakes/FakeTokenGenerator.php new file mode 100644 index 0000000..601eb2f --- /dev/null +++ b/backend/tests/Fakes/FakeTokenGenerator.php @@ -0,0 +1,27 @@ +callCount >= count($this->tokens)) { + throw new RuntimeException('FakeTokenGenerator exhausted'); + } + $token = $this->tokens[$this->callCount]; + $this->callCount++; + + return $token; + } +} diff --git a/backend/tests/Fakes/FakeUserRepository.php b/backend/tests/Fakes/FakeUserRepository.php new file mode 100644 index 0000000..3ad23bd --- /dev/null +++ b/backend/tests/Fakes/FakeUserRepository.php @@ -0,0 +1,66 @@ +getNextId(); + $user = new User( + id: $id, + email: $dto->email, + passwordHash: $dto->passwordHash, + isAdmin: $dto->isAdmin, + ); + $this->existingUsers[$id] = $user; + + return $user; + } + + public function find(int $id): ?User + { + $user = $this->existingUsers[$id] ?? null; + if ($user === null) { + return null; + } + + return new User( + id: $user->getId(), + email: $user->getEmail(), + passwordHash: $user->getPasswordHash(), + isAdmin: $user->isAdmin(), + ); + } + + 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 null; + } + + 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 new file mode 100644 index 0000000..7f844c9 --- /dev/null +++ b/backend/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -0,0 +1,142 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC') + ); + $this->sessionRepo = new FakeSessionRepository; + $this->clock = new FakeClock($this->now); + $this->middleware = new AuthMiddleware( + $this->sessionRepo, + $this->clock, + ); + } + + private function makeRequest(?string $token): Request + { + $request = Request::create('/api/anything', 'POST'); + if ($token !== null) { + $request->cookies->set('auth_token', $token); + } + + return $request; + } + + private function nextThatRecords(?Request &$captured): Closure + { + return function (Request $request) use (&$captured) { + $captured = $request; + + return new JsonResponse(['ok' => true], 200); + }; + } + + private function makeUser(int $id = 7): User + { + return new User( + id: $id, + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed:irrelevant', + isAdmin: false, + ); + } + + public function test_missing_cookie_returns_unauthorized_json(): void + { + $captured = null; + $response = $this->middleware->handle( + $this->makeRequest(null), + $this->nextThatRecords($captured), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertSame( + ['error' => 'unauthenticated'], + json_decode($response->getContent(), true), + ); + $this->assertNull($captured); + } + + public function test_unknown_token_returns_unauthorized(): void + { + $captured = null; + $response = $this->middleware->handle( + $this->makeRequest('does-not-exist'), + $this->nextThatRecords($captured), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($captured); + } + + public function test_expired_session_returns_unauthorized_and_is_deleted(): void + { + $this->sessionRepo->create(new CreateSessionDto( + token: 'expired-token', + user: $this->makeUser(), + createdAt: $this->now->modify('-8 days'), + expiresAt: $this->now->modify('-1 day'), + )); + + $captured = null; + $response = $this->middleware->handle( + $this->makeRequest('expired-token'), + $this->nextThatRecords($captured), + ); + + $this->assertSame(401, $response->getStatusCode()); + $this->assertNull($captured); + $this->assertNull( + $this->sessionRepo->findByToken('expired-token') + ); + } + + public function test_valid_session_attaches_user_and_calls_next(): void + { + $this->sessionRepo->create(new CreateSessionDto( + token: 'valid-token', + user: $this->makeUser(id: 7), + createdAt: $this->now, + expiresAt: $this->now->modify('+7 days'), + )); + + $captured = null; + $response = $this->middleware->handle( + $this->makeRequest('valid-token'), + $this->nextThatRecords($captured), + ); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertNotNull($captured); + $attachedUser = $captured->attributes->get('user'); + $this->assertInstanceOf(User::class, $attachedUser); + $this->assertSame(7, $attachedUser->getId()); + } +} diff --git a/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php new file mode 100644 index 0000000..6d00862 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/AuthenticateUserTest.php @@ -0,0 +1,156 @@ +userRepo = new FakeUserRepository; + $this->hasher = new FakePasswordHasher; + $this->useCase = new AuthenticateUser( + $this->userRepo, + $this->hasher, + ); + } + + private function seedUser( + string $email, + string $password, + bool $isAdmin, + ): User { + return $this->userRepo->create(new CreateUserDto( + email: new EmailAddress($email), + passwordHash: $this->hasher->hash($password), + isAdmin: $isAdmin, + )); + } + + public function test_null_email_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new AuthenticateUserRequest( + email: null, + password: 'correctpassword', + )); + } + + public function test_empty_email_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new AuthenticateUserRequest( + email: '', + password: 'correctpassword', + )); + } + + public function test_null_password_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: null, + )); + } + + public function test_empty_password_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: '', + )); + } + + public function test_malformed_email_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new AuthenticateUserRequest( + email: 'not-an-email', + password: 'correctpassword', + )); + } + + public function test_unknown_email_throws_unauthorized(): void + { + $this->expectException(UnauthorizedException::class); + $this->useCase->execute(new AuthenticateUserRequest( + email: 'nobody@example.com', + password: 'correctpassword', + )); + } + + public function test_wrong_password_throws_unauthorized(): void + { + $this->seedUser( + email: 'user@example.com', + password: 'correctpassword', + isAdmin: false, + ); + + $this->expectException(UnauthorizedException::class); + $this->useCase->execute(new AuthenticateUserRequest( + email: 'user@example.com', + password: 'wrongpassword', + )); + } + + public function test_valid_credentials_return_user(): void + { + $seeded = $this->seedUser( + email: 'user@example.com', + password: 'correctpassword', + isAdmin: false, + ); + + $authenticated = $this->useCase->execute( + new AuthenticateUserRequest( + email: 'user@example.com', + password: 'correctpassword', + ) + ); + + $this->assertInstanceOf(User::class, $authenticated); + $this->assertSame($seeded->getId(), $authenticated->getId()); + $this->assertSame( + 'user@example.com', + $authenticated->getEmail()->value(), + ); + $this->assertFalse($authenticated->isAdmin()); + } + + public function test_admin_flag_is_preserved_on_authentication(): void + { + $this->seedUser( + email: 'admin@example.com', + password: 'adminpassword', + isAdmin: true, + ); + + $authenticated = $this->useCase->execute( + new AuthenticateUserRequest( + email: 'admin@example.com', + password: 'adminpassword', + ) + ); + + $this->assertTrue($authenticated->isAdmin()); + } +} diff --git a/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php new file mode 100644 index 0000000..d5460b5 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/CreateSessionTest.php @@ -0,0 +1,86 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC') + ); + $this->sessionRepo = new FakeSessionRepository; + $this->tokenGenerator = new FakeTokenGenerator(['token-abc']); + $this->clock = new FakeClock($this->now); + $this->useCase = new CreateSession( + $this->sessionRepo, + $this->tokenGenerator, + $this->clock, + ); + } + + private function makeUser(): User + { + return new User( + id: 7, + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed:irrelevant', + isAdmin: false, + ); + } + + public function test_creates_session_with_generated_token(): void + { + $session = $this->useCase->execute($this->makeUser()); + + $this->assertInstanceOf(Session::class, $session); + $this->assertSame('token-abc', $session->getToken()); + $this->assertSame(7, $session->getUser()->getId()); + } + + public function test_session_created_at_is_clock_now(): void + { + $session = $this->useCase->execute($this->makeUser()); + + $this->assertEquals($this->now, $session->getCreatedAt()); + } + + public function test_session_expires_seven_days_from_now(): void + { + $session = $this->useCase->execute($this->makeUser()); + + $expected = $this->now->modify('+7 days'); + $this->assertEquals($expected, $session->getExpiresAt()); + } + + public function test_session_is_findable_by_token(): void + { + $this->useCase->execute($this->makeUser()); + + $found = $this->sessionRepo->findByToken('token-abc'); + $this->assertNotNull($found); + $this->assertSame(7, $found->getUser()->getId()); + } +} diff --git a/backend/tests/Unit/Auth/UseCases/LogoutTest.php b/backend/tests/Unit/Auth/UseCases/LogoutTest.php new file mode 100644 index 0000000..c98c582 --- /dev/null +++ b/backend/tests/Unit/Auth/UseCases/LogoutTest.php @@ -0,0 +1,58 @@ +now = new DateTimeImmutable( + '2026-05-06T12:00:00', + new DateTimeZone('UTC') + ); + $this->sessionRepo = new FakeSessionRepository; + $this->useCase = new Logout($this->sessionRepo); + } + + public function test_existing_token_session_is_removed(): void + { + $user = new User( + id: 7, + email: new EmailAddress('user@example.com'), + passwordHash: 'hashed:irrelevant', + isAdmin: false, + ); + $this->sessionRepo->create(new CreateSessionDto( + token: 'token-abc', + user: $user, + createdAt: $this->now, + expiresAt: $this->now->modify('+7 days'), + )); + + $this->useCase->execute('token-abc'); + + $this->assertNull($this->sessionRepo->findByToken('token-abc')); + } + + public function test_unknown_token_does_not_throw(): void + { + $this->useCase->execute('unknown-token'); + + $this->assertNull($this->sessionRepo->findByToken('unknown-token')); + } +} diff --git a/backend/tests/Unit/User/UseCases/SignupUserTest.php b/backend/tests/Unit/User/UseCases/SignupUserTest.php new file mode 100644 index 0000000..1ae841e --- /dev/null +++ b/backend/tests/Unit/User/UseCases/SignupUserTest.php @@ -0,0 +1,133 @@ +userRepo = new FakeUserRepository; + $this->hasher = new FakePasswordHasher; + $this->useCase = new SignupUser( + $this->userRepo, + $this->hasher, + ); + } + + public function test_null_email_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new SignupUserRequest( + email: null, + password: 'longenoughpassword', + )); + } + + public function test_empty_email_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new SignupUserRequest( + email: '', + password: 'longenoughpassword', + )); + } + + public function test_invalid_email_format_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new SignupUserRequest( + email: 'not-an-email', + password: 'longenoughpassword', + )); + } + + public function test_null_password_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new SignupUserRequest( + email: 'user@example.com', + password: null, + )); + } + + public function test_short_password_throws_bad_request(): void + { + $this->expectException(BadRequestException::class); + $this->useCase->execute(new SignupUserRequest( + email: 'user@example.com', + password: 'short', + )); + } + + public function test_duplicate_email_throws_domain_exception(): void + { + $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('user@example.com'), + passwordHash: $this->hasher->hash('original-password'), + isAdmin: false, + )); + + $this->expectException(DomainException::class); + $this->useCase->execute(new SignupUserRequest( + email: 'user@example.com', + password: 'second-attempt-password', + )); + } + + public function test_valid_signup_returns_user_with_hashed_password(): void + { + $created = $this->useCase->execute(new SignupUserRequest( + email: 'new@example.com', + password: 'longenoughpassword', + )); + + $this->assertInstanceOf(User::class, $created); + $this->assertSame('new@example.com', $created->getEmail()->value()); + $this->assertSame( + $this->hasher->hash('longenoughpassword'), + $created->getPasswordHash(), + ); + $this->assertFalse($created->isAdmin()); + } + + public function test_created_user_is_findable_by_email(): void + { + $created = $this->useCase->execute(new SignupUserRequest( + email: 'lookup@example.com', + password: 'longenoughpassword', + )); + + $found = $this->userRepo->findByEmail( + new EmailAddress('lookup@example.com') + ); + $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', + password: 'longenoughpassword', + )); + + $this->assertSame('Mixed@case.com', $created->getEmail()->value()); + } +}