From 160181888d4e0e1eda04eb023b2b8f5aa08e26f9 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:14:20 +0300 Subject: [PATCH 01/86] test user has is admin flag --- tests/Unit/User/UseCases/CreateUserTest.php | 23 +++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Unit/User/UseCases/CreateUserTest.php b/tests/Unit/User/UseCases/CreateUserTest.php index 180046f..bf2c793 100644 --- a/tests/Unit/User/UseCases/CreateUserTest.php +++ b/tests/Unit/User/UseCases/CreateUserTest.php @@ -35,4 +35,27 @@ class CreateUserTest extends TestCase email: null, )); } + + public function test_is_admin_defaults_to_false(): void + { + $userRepo = new FakeUserRepository(); + $useCase = new CreateUser($userRepo); + $useCase->execute(new CreateUserRequest( + email: 'test@test.com', + )); + $user = $userRepo->find(0); + $this->assertFalse($user->isAdmin()); + } + + public function test_is_admin_can_be_set_true(): void + { + $userRepo = new FakeUserRepository(); + $useCase = new CreateUser($userRepo); + $useCase->execute(new CreateUserRequest( + email: 'test@test.com', + isAdmin: true, + )); + $user = $userRepo->find(0); + $this->assertTrue($user->isAdmin()); + } } From b9f7fcf148cd15425eefc12157fb5f7d6c0693b9 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:14:30 +0300 Subject: [PATCH 02/86] add is admin to create user request --- app/User/UseCases/CreateUserRequest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/User/UseCases/CreateUserRequest.php b/app/User/UseCases/CreateUserRequest.php index 7c48913..702742d 100644 --- a/app/User/UseCases/CreateUserRequest.php +++ b/app/User/UseCases/CreateUserRequest.php @@ -6,5 +6,6 @@ class CreateUserRequest { public function __construct( public ?string $email, + public bool $isAdmin = false, ) {} } From affa1e7b1b179a760720088d55ed3f9b0d19efd9 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:14:39 +0300 Subject: [PATCH 03/86] add is admin to create user dto --- app/User/UseCases/CreateUserDto.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/User/UseCases/CreateUserDto.php b/app/User/UseCases/CreateUserDto.php index e978287..b1049f5 100644 --- a/app/User/UseCases/CreateUserDto.php +++ b/app/User/UseCases/CreateUserDto.php @@ -8,5 +8,6 @@ class CreateUserDto { public function __construct( public EmailAddress $email, + public bool $isAdmin = false, ) {} } From 0e86af3e814b2abcf38e6f8f6a5f1cc5d641709c Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:14:48 +0300 Subject: [PATCH 04/86] add is admin to user entity --- app/User/User.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/User/User.php b/app/User/User.php index e685a32..3c33c75 100644 --- a/app/User/User.php +++ b/app/User/User.php @@ -9,6 +9,7 @@ class User public function __construct( private int $id, private EmailAddress $email, + private bool $isAdmin = false, ) {} public function getId(): int @@ -20,4 +21,9 @@ class User { return $this->email; } + + public function isAdmin(): bool + { + return $this->isAdmin; + } } From 4157710187aa03b6e1e355d1fead9a99cf891bdd Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:14:57 +0300 Subject: [PATCH 05/86] pass is admin through create user --- app/User/UseCases/CreateUser.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/User/UseCases/CreateUser.php b/app/User/UseCases/CreateUser.php index cc5ffc0..e2b9b55 100644 --- a/app/User/UseCases/CreateUser.php +++ b/app/User/UseCases/CreateUser.php @@ -23,6 +23,7 @@ class CreateUser $this->userRepo->create(new CreateUserDto( email: new EmailAddress($dto->email), + isAdmin: $dto->isAdmin, )); } } From dcb4df043eb595a94ec48e0d4b08343c6d3e8138 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:15:12 +0300 Subject: [PATCH 06/86] store is admin in fake user repo --- tests/Fakes/FakeUserRepository.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Fakes/FakeUserRepository.php b/tests/Fakes/FakeUserRepository.php index 062cae8..cde7533 100644 --- a/tests/Fakes/FakeUserRepository.php +++ b/tests/Fakes/FakeUserRepository.php @@ -28,6 +28,7 @@ class FakeUserRepository implements UserRepository return new User( id: $user->getId(), email: $user->getEmail(), + isAdmin: $user->isAdmin(), ); } @@ -37,6 +38,7 @@ class FakeUserRepository implements UserRepository $user = new User( id: $id, email: $dto->email, + isAdmin: $dto->isAdmin, ); $this->existingUsers[$id] = $user; From 54db92a76c42a70b4096e97ff9b6f98893f2317f Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:15:24 +0300 Subject: [PATCH 07/86] persist is admin in json user repo --- app/User/JsonUserRepository.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/User/JsonUserRepository.php b/app/User/JsonUserRepository.php index bde8c62..61e7467 100644 --- a/app/User/JsonUserRepository.php +++ b/app/User/JsonUserRepository.php @@ -22,12 +22,14 @@ class JsonUserRepository implements UserRepository $users[] = [ 'id' => $id, 'email' => (string) $dto->email, + 'isAdmin' => $dto->isAdmin, ]; $this->writeUsers($users); return new User( id: $id, email: $dto->email, + isAdmin: $dto->isAdmin, ); } @@ -40,6 +42,7 @@ class JsonUserRepository implements UserRepository return new User( id: $data['id'], email: new EmailAddress($data['email']), + isAdmin: $data['isAdmin'] ?? false, ); } } From cbeb43f18c410cad931741b29a354f034d7b775a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:15:33 +0300 Subject: [PATCH 08/86] seed is admin on default user --- data/seedDb.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/data/seedDb.php b/data/seedDb.php index 922ca90..ba7c010 100644 --- a/data/seedDb.php +++ b/data/seedDb.php @@ -31,7 +31,8 @@ $nodes = [ $users = [ [ 'id' => 0, - 'email' => 'user@example.com', + 'email' => 'admin@example.com', + 'isAdmin' => true, ], ]; From b2fc6a7deda624519758f8521ff0666ca85a5b12 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:16:02 +0300 Subject: [PATCH 09/86] test fake user repo find by email --- tests/Unit/User/FakeUserRepositoryTest.php | 50 ++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/Unit/User/FakeUserRepositoryTest.php diff --git a/tests/Unit/User/FakeUserRepositoryTest.php b/tests/Unit/User/FakeUserRepositoryTest.php new file mode 100644 index 0000000..20d8b73 --- /dev/null +++ b/tests/Unit/User/FakeUserRepositoryTest.php @@ -0,0 +1,50 @@ +create(new CreateUserDto( + email: new EmailAddress('test@test.com'), + )); + + $user = $userRepo->findByEmail(new EmailAddress('test@test.com')); + + $this->assertInstanceOf(User::class, $user); + $this->assertEquals('test@test.com', (string) $user->getEmail()); + } + + public function test_find_by_email_returns_null_when_not_found(): void + { + $userRepo = new FakeUserRepository(); + + $user = $userRepo->findByEmail( + new EmailAddress('missing@test.com') + ); + + $this->assertNull($user); + } + + public function test_find_by_email_returns_fresh_instance(): void + { + $userRepo = new FakeUserRepository(); + $created = $userRepo->create(new CreateUserDto( + email: new EmailAddress('test@test.com'), + )); + + $fetched = $userRepo->findByEmail( + new EmailAddress('test@test.com') + ); + + $this->assertNotSame($created, $fetched); + } +} From ee271e162e95740e5cdc2f0739d1028b42f802c3 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:16:11 +0300 Subject: [PATCH 10/86] add find by email to user repository --- app/User/UserRepository.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/User/UserRepository.php b/app/User/UserRepository.php index c58a649..278cd98 100644 --- a/app/User/UserRepository.php +++ b/app/User/UserRepository.php @@ -3,9 +3,11 @@ namespace App\User; use App\User\UseCases\CreateUserDto; +use App\ValueObjects\EmailAddress; interface UserRepository { public function create(CreateUserDto $dto): User; public function find(int $id): ?User; + public function findByEmail(EmailAddress $email): ?User; } From 64edec514183ba8803d671242d33dc3e000d5749 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:16:29 +0300 Subject: [PATCH 11/86] implement find by email in fake user repo --- tests/Fakes/FakeUserRepository.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/Fakes/FakeUserRepository.php b/tests/Fakes/FakeUserRepository.php index cde7533..14c5cab 100644 --- a/tests/Fakes/FakeUserRepository.php +++ b/tests/Fakes/FakeUserRepository.php @@ -5,6 +5,7 @@ namespace Tests\Fakes; use App\User\UseCases\CreateUserDto; use App\User\User; use App\User\UserRepository; +use App\ValueObjects\EmailAddress; class FakeUserRepository implements UserRepository { @@ -32,6 +33,25 @@ class FakeUserRepository implements UserRepository ); } + public function findByEmail(EmailAddress $email): ?User + { + $user = array_find( + $this->existingUsers, + function (User $user) use ($email) { + return (string) $user->getEmail() === (string) $email; + } + ); + if ($user === null) { + return null; + } + + return new User( + id: $user->getId(), + email: $user->getEmail(), + isAdmin: $user->isAdmin(), + ); + } + public function create(CreateUserDto $dto): User { $id = $this->nextId(); From ac461afcf010dfe9beb5b3a8de55a243418c83e7 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:16:41 +0300 Subject: [PATCH 12/86] implement find by email in json user repo --- app/User/JsonUserRepository.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/app/User/JsonUserRepository.php b/app/User/JsonUserRepository.php index 61e7467..61f74b0 100644 --- a/app/User/JsonUserRepository.php +++ b/app/User/JsonUserRepository.php @@ -50,6 +50,23 @@ class JsonUserRepository implements UserRepository return null; } + public function findByEmail(EmailAddress $email): ?User + { + $users = $this->readUsers(); + + foreach ($users as $data) { + if ($data['email'] === (string) $email) { + return new User( + id: $data['id'], + email: new EmailAddress($data['email']), + isAdmin: $data['isAdmin'] ?? false, + ); + } + } + + return null; + } + private function readUsers(): array { if (!file_exists($this->filePath)) { From 30b8cc2c74d890afcd7847f9207f11b28fc1a916 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:16:57 +0300 Subject: [PATCH 13/86] test create user rejects duplicate email --- tests/Unit/User/UseCases/CreateUserTest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/Unit/User/UseCases/CreateUserTest.php b/tests/Unit/User/UseCases/CreateUserTest.php index bf2c793..1a97716 100644 --- a/tests/Unit/User/UseCases/CreateUserTest.php +++ b/tests/Unit/User/UseCases/CreateUserTest.php @@ -58,4 +58,20 @@ class CreateUserTest extends TestCase $user = $userRepo->find(0); $this->assertTrue($user->isAdmin()); } + + public function test_throws_when_email_already_taken(): void + { + $userRepo = new FakeUserRepository(); + $useCase = new CreateUser($userRepo); + $useCase->execute(new CreateUserRequest( + email: 'test@test.com', + )); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('email already taken'); + + $useCase->execute(new CreateUserRequest( + email: 'test@test.com', + )); + } } From 96ad78425f140ca75cc65cc0230b49c578f4a948 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:17:11 +0300 Subject: [PATCH 14/86] reject duplicate email in create user --- app/User/UseCases/CreateUser.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/User/UseCases/CreateUser.php b/app/User/UseCases/CreateUser.php index e2b9b55..cfec430 100644 --- a/app/User/UseCases/CreateUser.php +++ b/app/User/UseCases/CreateUser.php @@ -21,8 +21,13 @@ class CreateUser throw new BadRequestException('email is required'); } + $email = new EmailAddress($dto->email); + if ($this->userRepo->findByEmail($email) !== null) { + throw new BadRequestException('email already taken'); + } + $this->userRepo->create(new CreateUserDto( - email: new EmailAddress($dto->email), + email: $email, isAdmin: $dto->isAdmin, )); } From 38cfd3464562ad9efc48b47f277b3fb41e171c59 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:18:00 +0300 Subject: [PATCH 15/86] test create user requires password --- tests/Unit/User/UseCases/CreateUserTest.php | 50 +++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/Unit/User/UseCases/CreateUserTest.php b/tests/Unit/User/UseCases/CreateUserTest.php index 1a97716..93971c4 100644 --- a/tests/Unit/User/UseCases/CreateUserTest.php +++ b/tests/Unit/User/UseCases/CreateUserTest.php @@ -17,6 +17,7 @@ class CreateUserTest extends TestCase $useCase = new CreateUser($userRepo); $useCase->execute(new CreateUserRequest( email: 'test@test.com', + password: 'password1', )); $user = $userRepo->find(0); $this->assertInstanceOf(User::class, $user); @@ -42,6 +43,7 @@ class CreateUserTest extends TestCase $useCase = new CreateUser($userRepo); $useCase->execute(new CreateUserRequest( email: 'test@test.com', + password: 'password1', )); $user = $userRepo->find(0); $this->assertFalse($user->isAdmin()); @@ -53,6 +55,7 @@ class CreateUserTest extends TestCase $useCase = new CreateUser($userRepo); $useCase->execute(new CreateUserRequest( email: 'test@test.com', + password: 'password1', isAdmin: true, )); $user = $userRepo->find(0); @@ -65,6 +68,7 @@ class CreateUserTest extends TestCase $useCase = new CreateUser($userRepo); $useCase->execute(new CreateUserRequest( email: 'test@test.com', + password: 'password1', )); $this->expectException(BadRequestException::class); @@ -72,6 +76,52 @@ class CreateUserTest extends TestCase $useCase->execute(new CreateUserRequest( email: 'test@test.com', + password: 'password1', )); } + + public function test_throws_if_password_is_null(): void + { + $userRepo = new FakeUserRepository(); + $useCase = new CreateUser($userRepo); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('password is required'); + + $useCase->execute(new CreateUserRequest( + email: 'test@test.com', + password: null, + )); + } + + public function test_throws_if_password_too_short(): void + { + $userRepo = new FakeUserRepository(); + $useCase = new CreateUser($userRepo); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage( + 'password must be at least 8 characters' + ); + + $useCase->execute(new CreateUserRequest( + email: 'test@test.com', + password: 'short', + )); + } + + public function test_stores_hashed_password(): void + { + $userRepo = new FakeUserRepository(); + $useCase = new CreateUser($userRepo); + $useCase->execute(new CreateUserRequest( + email: 'test@test.com', + password: 'password1', + )); + $user = $userRepo->find(0); + $this->assertNotEquals('password1', $user->getPasswordHash()); + $this->assertTrue( + password_verify('password1', $user->getPasswordHash()) + ); + } } From 261319078daeb48d0c0bc486b1a2badc27f315e6 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:18:15 +0300 Subject: [PATCH 16/86] add password to create user request --- app/User/UseCases/CreateUserRequest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/User/UseCases/CreateUserRequest.php b/app/User/UseCases/CreateUserRequest.php index 702742d..52ead72 100644 --- a/app/User/UseCases/CreateUserRequest.php +++ b/app/User/UseCases/CreateUserRequest.php @@ -6,6 +6,7 @@ class CreateUserRequest { public function __construct( public ?string $email, + public ?string $password = null, public bool $isAdmin = false, ) {} } From 5093259063033464b6c668d0a0572a43888d82cd Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:18:24 +0300 Subject: [PATCH 17/86] add password hash to create user dto --- app/User/UseCases/CreateUserDto.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/User/UseCases/CreateUserDto.php b/app/User/UseCases/CreateUserDto.php index b1049f5..a9c38fa 100644 --- a/app/User/UseCases/CreateUserDto.php +++ b/app/User/UseCases/CreateUserDto.php @@ -8,6 +8,7 @@ class CreateUserDto { public function __construct( public EmailAddress $email, + public string $passwordHash, public bool $isAdmin = false, ) {} } From 016e98412bfabda0a01ed0c55ba85b2c5e8e4995 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:18:33 +0300 Subject: [PATCH 18/86] add password hash to user entity --- app/User/User.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/User/User.php b/app/User/User.php index 3c33c75..9cdc469 100644 --- a/app/User/User.php +++ b/app/User/User.php @@ -9,6 +9,7 @@ class User public function __construct( private int $id, private EmailAddress $email, + private string $passwordHash = '', private bool $isAdmin = false, ) {} @@ -22,6 +23,11 @@ class User return $this->email; } + public function getPasswordHash(): string + { + return $this->passwordHash; + } + public function isAdmin(): bool { return $this->isAdmin; From 0f179e53c2fbbc0a048143e6c75a33cf6e959c23 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:18:44 +0300 Subject: [PATCH 19/86] hash password in create user --- app/User/UseCases/CreateUser.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/User/UseCases/CreateUser.php b/app/User/UseCases/CreateUser.php index cfec430..49dfb71 100644 --- a/app/User/UseCases/CreateUser.php +++ b/app/User/UseCases/CreateUser.php @@ -21,6 +21,16 @@ class CreateUser throw new BadRequestException('email is required'); } + if ($dto->password === null) { + throw new BadRequestException('password is required'); + } + + if (strlen($dto->password) < 8) { + throw new BadRequestException( + 'password must be at least 8 characters' + ); + } + $email = new EmailAddress($dto->email); if ($this->userRepo->findByEmail($email) !== null) { throw new BadRequestException('email already taken'); @@ -28,6 +38,7 @@ class CreateUser $this->userRepo->create(new CreateUserDto( email: $email, + passwordHash: password_hash($dto->password, PASSWORD_DEFAULT), isAdmin: $dto->isAdmin, )); } From a52bb18b137482094e6443169d25440284798dd4 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:19:00 +0300 Subject: [PATCH 20/86] store password hash in fake user repo --- tests/Fakes/FakeUserRepository.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Fakes/FakeUserRepository.php b/tests/Fakes/FakeUserRepository.php index 14c5cab..2538213 100644 --- a/tests/Fakes/FakeUserRepository.php +++ b/tests/Fakes/FakeUserRepository.php @@ -29,6 +29,7 @@ class FakeUserRepository implements UserRepository return new User( id: $user->getId(), email: $user->getEmail(), + passwordHash: $user->getPasswordHash(), isAdmin: $user->isAdmin(), ); } @@ -48,6 +49,7 @@ class FakeUserRepository implements UserRepository return new User( id: $user->getId(), email: $user->getEmail(), + passwordHash: $user->getPasswordHash(), isAdmin: $user->isAdmin(), ); } @@ -58,6 +60,7 @@ class FakeUserRepository implements UserRepository $user = new User( id: $id, email: $dto->email, + passwordHash: $dto->passwordHash, isAdmin: $dto->isAdmin, ); $this->existingUsers[$id] = $user; From ada29ea9570179c6d2d3f6c06234ef4e86d8823a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:19:15 +0300 Subject: [PATCH 21/86] store password hash in json user repo --- app/User/JsonUserRepository.php | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/app/User/JsonUserRepository.php b/app/User/JsonUserRepository.php index 61f74b0..954ec48 100644 --- a/app/User/JsonUserRepository.php +++ b/app/User/JsonUserRepository.php @@ -22,6 +22,7 @@ class JsonUserRepository implements UserRepository $users[] = [ 'id' => $id, 'email' => (string) $dto->email, + 'passwordHash' => $dto->passwordHash, 'isAdmin' => $dto->isAdmin, ]; $this->writeUsers($users); @@ -29,6 +30,7 @@ class JsonUserRepository implements UserRepository return new User( id: $id, email: $dto->email, + passwordHash: $dto->passwordHash, isAdmin: $dto->isAdmin, ); } @@ -39,11 +41,7 @@ class JsonUserRepository implements UserRepository foreach ($users as $data) { if ($data['id'] === $id) { - return new User( - id: $data['id'], - email: new EmailAddress($data['email']), - isAdmin: $data['isAdmin'] ?? false, - ); + return $this->hydrate($data); } } @@ -56,17 +54,23 @@ class JsonUserRepository implements UserRepository foreach ($users as $data) { if ($data['email'] === (string) $email) { - return new User( - id: $data['id'], - email: new EmailAddress($data['email']), - isAdmin: $data['isAdmin'] ?? false, - ); + return $this->hydrate($data); } } return null; } + private function hydrate(array $data): User + { + return new User( + id: $data['id'], + email: new EmailAddress($data['email']), + passwordHash: $data['passwordHash'] ?? '', + isAdmin: $data['isAdmin'] ?? false, + ); + } + private function readUsers(): array { if (!file_exists($this->filePath)) { From 73ade7f971aa600ce556d2feacdb761136d02529 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:19:51 +0300 Subject: [PATCH 22/86] update tests for user password hash --- tests/Unit/Plan/UseCases/CreatePlanTest.php | 1 + tests/Unit/User/FakeUserRepositoryTest.php | 2 ++ tests/e2e/Controllers/PlanControllerTest.php | 1 + 3 files changed, 4 insertions(+) diff --git a/tests/Unit/Plan/UseCases/CreatePlanTest.php b/tests/Unit/Plan/UseCases/CreatePlanTest.php index 667e72c..f64c0be 100644 --- a/tests/Unit/Plan/UseCases/CreatePlanTest.php +++ b/tests/Unit/Plan/UseCases/CreatePlanTest.php @@ -39,6 +39,7 @@ class CreatePlanTest extends TestCase $this->scheduledNodeRepo = new FakeScheduledNodeRepository(); $this->userRepo->create(new CreateUserDto( email: new EmailAddress('test@test.com'), + passwordHash: '', )); $this->createScheduledNode = new CreateScheduledNode( scheduledNodeRepo: $this->scheduledNodeRepo, diff --git a/tests/Unit/User/FakeUserRepositoryTest.php b/tests/Unit/User/FakeUserRepositoryTest.php index 20d8b73..6d583a5 100644 --- a/tests/Unit/User/FakeUserRepositoryTest.php +++ b/tests/Unit/User/FakeUserRepositoryTest.php @@ -15,6 +15,7 @@ class FakeUserRepositoryTest extends TestCase $userRepo = new FakeUserRepository(); $userRepo->create(new CreateUserDto( email: new EmailAddress('test@test.com'), + passwordHash: '', )); $user = $userRepo->findByEmail(new EmailAddress('test@test.com')); @@ -39,6 +40,7 @@ class FakeUserRepositoryTest extends TestCase $userRepo = new FakeUserRepository(); $created = $userRepo->create(new CreateUserDto( email: new EmailAddress('test@test.com'), + passwordHash: '', )); $fetched = $userRepo->findByEmail( diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index ee51012..a14fd7d 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -40,6 +40,7 @@ class PlanControllerTest extends TestCase $this->userRepo->create(new CreateUserDto( email: new EmailAddress('test@test.com'), + passwordHash: '', )); $text = $this->textRepo->create(new CreateTextDto('testname')); $this->nodeRepo->create(new CreateNodeDto( From f012728876d4e0bd772fa1928bf463b5ca247252 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:20:05 +0300 Subject: [PATCH 23/86] seed admin with hashed password --- data/seedDb.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/data/seedDb.php b/data/seedDb.php index ba7c010..5756162 100644 --- a/data/seedDb.php +++ b/data/seedDb.php @@ -28,10 +28,12 @@ $nodes = [ ], ]; +// Default admin credentials: admin@example.com / admin1234 $users = [ [ 'id' => 0, 'email' => 'admin@example.com', + 'passwordHash' => password_hash('admin1234', PASSWORD_DEFAULT), 'isAdmin' => true, ], ]; From 271f28936d5b6976b23e56ca4140e2b22000095b Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:20:22 +0300 Subject: [PATCH 24/86] add unauthorized exception --- app/Exceptions/UnauthorizedException.php | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 app/Exceptions/UnauthorizedException.php diff --git a/app/Exceptions/UnauthorizedException.php b/app/Exceptions/UnauthorizedException.php new file mode 100644 index 0000000..8c3b1de --- /dev/null +++ b/app/Exceptions/UnauthorizedException.php @@ -0,0 +1,5 @@ + Date: Fri, 24 Apr 2026 13:20:30 +0300 Subject: [PATCH 25/86] add authenticate user request --- app/User/UseCases/AuthenticateUserRequest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 app/User/UseCases/AuthenticateUserRequest.php diff --git a/app/User/UseCases/AuthenticateUserRequest.php b/app/User/UseCases/AuthenticateUserRequest.php new file mode 100644 index 0000000..953ff6e --- /dev/null +++ b/app/User/UseCases/AuthenticateUserRequest.php @@ -0,0 +1,11 @@ + Date: Fri, 24 Apr 2026 13:20:45 +0300 Subject: [PATCH 26/86] test authenticate user --- .../User/UseCases/AuthenticateUserTest.php | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/Unit/User/UseCases/AuthenticateUserTest.php diff --git a/tests/Unit/User/UseCases/AuthenticateUserTest.php b/tests/Unit/User/UseCases/AuthenticateUserTest.php new file mode 100644 index 0000000..e929ab0 --- /dev/null +++ b/tests/Unit/User/UseCases/AuthenticateUserTest.php @@ -0,0 +1,85 @@ +userRepo = new FakeUserRepository(); + $createUser = new CreateUser($this->userRepo); + $createUser->execute(new CreateUserRequest( + email: 'test@test.com', + password: 'password1', + )); + $this->useCase = new AuthenticateUser($this->userRepo); + } + + public function test_returns_user_on_valid_credentials(): void + { + $user = $this->useCase->execute(new AuthenticateUserRequest( + email: 'test@test.com', + password: 'password1', + )); + + $this->assertInstanceOf(User::class, $user); + $this->assertEquals('test@test.com', (string) $user->getEmail()); + } + + public function test_throws_bad_request_when_email_null(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('email is required'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: null, + password: 'password1', + )); + } + + public function test_throws_bad_request_when_password_null(): void + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('password is required'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: 'test@test.com', + password: null, + )); + } + + public function test_throws_unauthorized_on_wrong_password(): void + { + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('invalid credentials'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: 'test@test.com', + password: 'wrongpassword', + )); + } + + public function test_throws_unauthorized_when_email_not_found(): void + { + $this->expectException(UnauthorizedException::class); + $this->expectExceptionMessage('invalid credentials'); + + $this->useCase->execute(new AuthenticateUserRequest( + email: 'missing@test.com', + password: 'password1', + )); + } +} From 79d9ece2aeea894063be6d5ea08cae19d2e0576d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:21:02 +0300 Subject: [PATCH 27/86] add authenticate user use case --- app/User/UseCases/AuthenticateUser.php | 48 ++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 app/User/UseCases/AuthenticateUser.php diff --git a/app/User/UseCases/AuthenticateUser.php b/app/User/UseCases/AuthenticateUser.php new file mode 100644 index 0000000..56281b1 --- /dev/null +++ b/app/User/UseCases/AuthenticateUser.php @@ -0,0 +1,48 @@ +email === null) { + throw new BadRequestException('email is required'); + } + + if ($request->password === null) { + throw new BadRequestException('password is required'); + } + + $user = $this->userRepo->findByEmail( + new EmailAddress($request->email) + ); + if ($user === null) { + throw new UnauthorizedException('invalid credentials'); + } + + $passwordMatches = password_verify( + $request->password, + $user->getPasswordHash() + ); + if (!$passwordMatches) { + throw new UnauthorizedException('invalid credentials'); + } + + return $user; + } +} From 6fbdc82589e8df811b17fe36cd87bb9cf3fbfb69 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:21:20 +0300 Subject: [PATCH 28/86] add session entity --- app/Auth/Session.php | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 app/Auth/Session.php diff --git a/app/Auth/Session.php b/app/Auth/Session.php new file mode 100644 index 0000000..82bb95d --- /dev/null +++ b/app/Auth/Session.php @@ -0,0 +1,40 @@ +token; + } + + public function getUserId(): int + { + return $this->userId; + } + + public function getCreatedAt(): DateTimeImmutable + { + return $this->createdAt; + } + + public function getExpiresAt(): DateTimeImmutable + { + return $this->expiresAt; + } + + public function isExpired(DateTimeImmutable $now): bool + { + return $now >= $this->expiresAt; + } +} From b37e80147cef7f27f906b269a767f0d0ded79b20 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:21:28 +0300 Subject: [PATCH 29/86] add create session dto --- app/Auth/CreateSessionDto.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 app/Auth/CreateSessionDto.php diff --git a/app/Auth/CreateSessionDto.php b/app/Auth/CreateSessionDto.php new file mode 100644 index 0000000..d79ed76 --- /dev/null +++ b/app/Auth/CreateSessionDto.php @@ -0,0 +1,15 @@ + Date: Fri, 24 Apr 2026 13:21:37 +0300 Subject: [PATCH 30/86] add session repository interface --- app/Auth/SessionRepository.php | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 app/Auth/SessionRepository.php diff --git a/app/Auth/SessionRepository.php b/app/Auth/SessionRepository.php new file mode 100644 index 0000000..073a16c --- /dev/null +++ b/app/Auth/SessionRepository.php @@ -0,0 +1,10 @@ + Date: Fri, 24 Apr 2026 13:21:51 +0300 Subject: [PATCH 31/86] test fake session repository --- tests/Unit/Auth/FakeSessionRepositoryTest.php | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 tests/Unit/Auth/FakeSessionRepositoryTest.php diff --git a/tests/Unit/Auth/FakeSessionRepositoryTest.php b/tests/Unit/Auth/FakeSessionRepositoryTest.php new file mode 100644 index 0000000..6e2bce4 --- /dev/null +++ b/tests/Unit/Auth/FakeSessionRepositoryTest.php @@ -0,0 +1,68 @@ +sessionRepo = new FakeSessionRepository(); + } + + public function test_create_and_find_by_token(): void + { + $this->sessionRepo->create(new CreateSessionDto( + token: 'abc123', + userId: 0, + createdAt: new DateTimeImmutable('2025-01-01'), + expiresAt: new DateTimeImmutable('2025-01-08'), + )); + + $session = $this->sessionRepo->findByToken('abc123'); + + $this->assertInstanceOf(Session::class, $session); + $this->assertEquals('abc123', $session->getToken()); + $this->assertEquals(0, $session->getUserId()); + } + + public function test_find_by_token_returns_null_when_missing(): void + { + $this->assertNull($this->sessionRepo->findByToken('nope')); + } + + public function test_find_by_token_returns_fresh_instance(): void + { + $created = $this->sessionRepo->create(new CreateSessionDto( + token: 'abc123', + userId: 0, + createdAt: new DateTimeImmutable('2025-01-01'), + expiresAt: new DateTimeImmutable('2025-01-08'), + )); + + $fetched = $this->sessionRepo->findByToken('abc123'); + + $this->assertNotSame($created, $fetched); + } + + public function test_delete_by_token(): void + { + $this->sessionRepo->create(new CreateSessionDto( + token: 'abc123', + userId: 0, + createdAt: new DateTimeImmutable('2025-01-01'), + expiresAt: new DateTimeImmutable('2025-01-08'), + )); + + $this->sessionRepo->deleteByToken('abc123'); + + $this->assertNull($this->sessionRepo->findByToken('abc123')); + } +} From 619ebd39075d12180f475cefa2d9f4c3c934696d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:22:06 +0300 Subject: [PATCH 32/86] add fake session repository --- tests/Fakes/FakeSessionRepository.php | 48 +++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/Fakes/FakeSessionRepository.php diff --git a/tests/Fakes/FakeSessionRepository.php b/tests/Fakes/FakeSessionRepository.php new file mode 100644 index 0000000..18add97 --- /dev/null +++ b/tests/Fakes/FakeSessionRepository.php @@ -0,0 +1,48 @@ +token, + userId: $dto->userId, + createdAt: $dto->createdAt, + expiresAt: $dto->expiresAt, + ); + $this->existingSessions[$dto->token] = $session; + + return $session; + } + + public function findByToken(string $token): ?Session + { + $session = $this->existingSessions[$token] ?? null; + if ($session === null) { + return null; + } + + return new Session( + token: $session->getToken(), + userId: $session->getUserId(), + createdAt: $session->getCreatedAt(), + expiresAt: $session->getExpiresAt(), + ); + } + + public function deleteByToken(string $token): void + { + unset($this->existingSessions[$token]); + } +} From 762bbb7fdab974ed0dde82f950c219b45dcc1ebe Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:22:19 +0300 Subject: [PATCH 33/86] add json session repository --- app/Auth/JsonSessionRepository.php | 84 ++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 app/Auth/JsonSessionRepository.php diff --git a/app/Auth/JsonSessionRepository.php b/app/Auth/JsonSessionRepository.php new file mode 100644 index 0000000..655b601 --- /dev/null +++ b/app/Auth/JsonSessionRepository.php @@ -0,0 +1,84 @@ +filePath = __DIR__ . '/../../data/sessions.json'; + } + + public function create(CreateSessionDto $dto): Session + { + $sessions = $this->readSessions(); + + $sessions[] = [ + 'token' => $dto->token, + 'userId' => $dto->userId, + 'createdAt' => $dto->createdAt->format(DATE_ATOM), + 'expiresAt' => $dto->expiresAt->format(DATE_ATOM), + ]; + $this->writeSessions($sessions); + + return new Session( + token: $dto->token, + userId: $dto->userId, + createdAt: $dto->createdAt, + expiresAt: $dto->expiresAt, + ); + } + + public function findByToken(string $token): ?Session + { + $sessions = $this->readSessions(); + + foreach ($sessions as $data) { + if ($data['token'] === $token) { + return new Session( + token: $data['token'], + userId: $data['userId'], + createdAt: new DateTimeImmutable($data['createdAt']), + expiresAt: new DateTimeImmutable($data['expiresAt']), + ); + } + } + + return null; + } + + public function deleteByToken(string $token): void + { + $sessions = $this->readSessions(); + $filtered = array_values(array_filter( + $sessions, + function (array $data) use ($token) { + return $data['token'] !== $token; + } + )); + $this->writeSessions($filtered); + } + + private function readSessions(): array + { + if (!file_exists($this->filePath)) { + return []; + } + + $content = file_get_contents($this->filePath); + + return json_decode($content, true) ?? []; + } + + private function writeSessions(array $sessions): void + { + file_put_contents( + $this->filePath, + json_encode($sessions, JSON_PRETTY_PRINT) + ); + } +} From ef842f575881311a5341f7f21d7784466f860e29 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:22:33 +0300 Subject: [PATCH 34/86] bind session repository --- bootstrap/container.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bootstrap/container.php b/bootstrap/container.php index fb80996..c640d76 100644 --- a/bootstrap/container.php +++ b/bootstrap/container.php @@ -2,6 +2,8 @@ use DI; use DI\Container; +use App\Auth\JsonSessionRepository; +use App\Auth\SessionRepository; use App\Text\TextRepository; use App\Text\JsonTextRepository; use App\Node\NodeRepository; @@ -20,6 +22,8 @@ $container = new Container([ UserRepository::class => DI\autowire(JsonUserRepository::class), ScheduledNodeRepository::class => DI\autowire(JsonScheduledNodeRepository::class), + SessionRepository::class => + DI\autowire(JsonSessionRepository::class), ]); return $container; From cb73688a99f0a25181d4e5bbdc1b10c378944fc9 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:22:48 +0300 Subject: [PATCH 35/86] seed and wipe sessions file --- data/seedDb.php | 2 ++ data/wipeDb.php | 1 + 2 files changed, 3 insertions(+) diff --git a/data/seedDb.php b/data/seedDb.php index 5756162..9eb4128 100644 --- a/data/seedDb.php +++ b/data/seedDb.php @@ -40,6 +40,7 @@ $users = [ $plans = []; $scheduledNodes = []; +$sessions = []; $fileDataMap = [ 'texts.json' => $texts, @@ -47,6 +48,7 @@ $fileDataMap = [ 'users.json' => $users, 'plans.json' => $plans, 'scheduledNodes.json' => $scheduledNodes, + 'sessions.json' => $sessions, ]; foreach ($fileDataMap as $file => $data) { diff --git a/data/wipeDb.php b/data/wipeDb.php index 658bb2f..6428a92 100644 --- a/data/wipeDb.php +++ b/data/wipeDb.php @@ -6,6 +6,7 @@ $files = [ 'users.json', 'plans.json', 'scheduledNodes.json', + 'sessions.json', ]; foreach ($files as $file) { From a0bea204b4f402261d4c7f2a1a8a89d2be46afbc Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:23:05 +0300 Subject: [PATCH 36/86] add token generator interface --- app/Auth/TokenGenerator.php | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 app/Auth/TokenGenerator.php diff --git a/app/Auth/TokenGenerator.php b/app/Auth/TokenGenerator.php new file mode 100644 index 0000000..39b4263 --- /dev/null +++ b/app/Auth/TokenGenerator.php @@ -0,0 +1,8 @@ + Date: Fri, 24 Apr 2026 13:23:12 +0300 Subject: [PATCH 37/86] add random token generator --- app/Auth/RandomTokenGenerator.php | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 app/Auth/RandomTokenGenerator.php diff --git a/app/Auth/RandomTokenGenerator.php b/app/Auth/RandomTokenGenerator.php new file mode 100644 index 0000000..0615bff --- /dev/null +++ b/app/Auth/RandomTokenGenerator.php @@ -0,0 +1,11 @@ + Date: Fri, 24 Apr 2026 13:23:23 +0300 Subject: [PATCH 38/86] add fake token generator --- tests/Fakes/FakeTokenGenerator.php | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/Fakes/FakeTokenGenerator.php diff --git a/tests/Fakes/FakeTokenGenerator.php b/tests/Fakes/FakeTokenGenerator.php new file mode 100644 index 0000000..ffdd6d8 --- /dev/null +++ b/tests/Fakes/FakeTokenGenerator.php @@ -0,0 +1,25 @@ +callCount % count($this->predefinedTokens); + $this->callCount++; + + return $this->predefinedTokens[$index]; + } +} From 04712bdd2dd07ccd9b0c829188153d86c86d244d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:23:30 +0300 Subject: [PATCH 39/86] add clock interface --- app/Auth/Clock.php | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 app/Auth/Clock.php diff --git a/app/Auth/Clock.php b/app/Auth/Clock.php new file mode 100644 index 0000000..b96ad32 --- /dev/null +++ b/app/Auth/Clock.php @@ -0,0 +1,10 @@ + Date: Fri, 24 Apr 2026 13:23:38 +0300 Subject: [PATCH 40/86] add system clock --- app/Auth/SystemClock.php | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 app/Auth/SystemClock.php diff --git a/app/Auth/SystemClock.php b/app/Auth/SystemClock.php new file mode 100644 index 0000000..4e845b6 --- /dev/null +++ b/app/Auth/SystemClock.php @@ -0,0 +1,13 @@ + Date: Fri, 24 Apr 2026 13:23:46 +0300 Subject: [PATCH 41/86] add fake clock --- tests/Fakes/FakeClock.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 tests/Fakes/FakeClock.php diff --git a/tests/Fakes/FakeClock.php b/tests/Fakes/FakeClock.php new file mode 100644 index 0000000..bf7a7b6 --- /dev/null +++ b/tests/Fakes/FakeClock.php @@ -0,0 +1,23 @@ +currentTime; + } + + public function setTime(DateTimeImmutable $newTime): void + { + $this->currentTime = $newTime; + } +} From 2a281386a5822010c2b93880c72e448dbefaf95d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:24:01 +0300 Subject: [PATCH 42/86] test create session --- .../Unit/Auth/UseCases/CreateSessionTest.php | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 tests/Unit/Auth/UseCases/CreateSessionTest.php diff --git a/tests/Unit/Auth/UseCases/CreateSessionTest.php b/tests/Unit/Auth/UseCases/CreateSessionTest.php new file mode 100644 index 0000000..f58f66f --- /dev/null +++ b/tests/Unit/Auth/UseCases/CreateSessionTest.php @@ -0,0 +1,83 @@ +sessionRepo = new FakeSessionRepository(); + $this->tokenGenerator = new FakeTokenGenerator( + ['generated-token-abc'] + ); + $this->clock = new FakeClock( + new DateTimeImmutable('2025-01-01T12:00:00+00:00') + ); + $this->useCase = new CreateSession( + $this->sessionRepo, + $this->tokenGenerator, + $this->clock, + ); + $this->user = new User( + id: 7, + email: new EmailAddress('test@test.com'), + ); + } + + public function test_creates_session_for_user(): void + { + $session = $this->useCase->execute($this->user); + + $this->assertEquals(7, $session->getUserId()); + } + + public function test_session_token_comes_from_generator(): void + { + $session = $this->useCase->execute($this->user); + + $this->assertEquals('generated-token-abc', $session->getToken()); + } + + public function test_session_created_at_is_now(): void + { + $session = $this->useCase->execute($this->user); + + $this->assertEquals( + new DateTimeImmutable('2025-01-01T12:00:00+00:00'), + $session->getCreatedAt() + ); + } + + public function test_session_expires_in_seven_days(): void + { + $session = $this->useCase->execute($this->user); + + $this->assertEquals( + new DateTimeImmutable('2025-01-08T12:00:00+00:00'), + $session->getExpiresAt() + ); + } + + public function test_session_is_persisted(): void + { + $this->useCase->execute($this->user); + + $found = $this->sessionRepo->findByToken('generated-token-abc'); + $this->assertNotNull($found); + } +} From 05f4f334e6f4df2d30b50d9aa362399573509a1e Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:24:21 +0300 Subject: [PATCH 43/86] add create session use case --- app/Auth/UseCases/CreateSession.php | 34 +++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 app/Auth/UseCases/CreateSession.php diff --git a/app/Auth/UseCases/CreateSession.php b/app/Auth/UseCases/CreateSession.php new file mode 100644 index 0000000..2fa1f83 --- /dev/null +++ b/app/Auth/UseCases/CreateSession.php @@ -0,0 +1,34 @@ +clock->now(); + $expiresAt = $now->modify(self::SESSION_LIFETIME); + + return $this->sessionRepo->create(new CreateSessionDto( + token: $this->tokenGenerator->generate(), + userId: $user->getId(), + createdAt: $now, + expiresAt: $expiresAt, + )); + } +} From 821f654d695dc3835d008821436940d810faf603 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:24:39 +0300 Subject: [PATCH 44/86] bind clock and token generator --- bootstrap/container.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bootstrap/container.php b/bootstrap/container.php index c640d76..4568c66 100644 --- a/bootstrap/container.php +++ b/bootstrap/container.php @@ -2,8 +2,12 @@ use DI; use DI\Container; +use App\Auth\Clock; use App\Auth\JsonSessionRepository; +use App\Auth\RandomTokenGenerator; use App\Auth\SessionRepository; +use App\Auth\SystemClock; +use App\Auth\TokenGenerator; use App\Text\TextRepository; use App\Text\JsonTextRepository; use App\Node\NodeRepository; @@ -24,6 +28,8 @@ $container = new Container([ DI\autowire(JsonScheduledNodeRepository::class), SessionRepository::class => DI\autowire(JsonSessionRepository::class), + TokenGenerator::class => DI\autowire(RandomTokenGenerator::class), + Clock::class => DI\autowire(SystemClock::class), ]); return $container; From cd2168c8226ddd2ef2e97a36d929911bb6cc06f0 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:25:17 +0300 Subject: [PATCH 45/86] test auth middleware --- .../Auth/Middleware/AuthMiddlewareTest.php | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 tests/Unit/Auth/Middleware/AuthMiddlewareTest.php diff --git a/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php new file mode 100644 index 0000000..c79e6a6 --- /dev/null +++ b/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -0,0 +1,161 @@ +userRepo = new FakeUserRepository(); + $this->sessionRepo = new FakeSessionRepository(); + $this->clock = new FakeClock( + new DateTimeImmutable('2025-01-01T12:00:00+00:00') + ); + $this->user = $this->userRepo->create(new CreateUserDto( + email: new EmailAddress('test@test.com'), + passwordHash: '', + )); + $this->middleware = new AuthMiddleware( + $this->sessionRepo, + $this->userRepo, + $this->clock, + ); + } + + private function makeApiRequest( + ?string $cookieToken = null + ): ServerRequestInterface { + $request = new ServerRequestFactory() + ->createServerRequest('GET', 'http://localhost/api/texts'); + if ($cookieToken !== null) { + $request = $request->withCookieParams([ + 'auth_token' => $cookieToken, + ]); + } + return $request; + } + + private function makeHtmlRequest( + ?string $cookieToken = null + ): ServerRequestInterface { + $request = new ServerRequestFactory() + ->createServerRequest('GET', 'http://localhost/home') + ->withHeader('Accept', 'text/html'); + if ($cookieToken !== null) { + $request = $request->withCookieParams([ + 'auth_token' => $cookieToken, + ]); + } + return $request; + } + + private function makeHandler(): RequestHandlerInterface + { + return new class() implements RequestHandlerInterface { + public ?ServerRequestInterface $capturedRequest = null; + + public function handle( + ServerRequestInterface $request + ): \Psr\Http\Message\ResponseInterface { + $this->capturedRequest = $request; + return new Response(200); + } + }; + } + + public function test_returns_401_json_when_cookie_missing(): void + { + $response = $this->middleware->process( + $this->makeApiRequest(), + $this->makeHandler(), + ); + + $this->assertEquals(401, $response->getStatusCode()); + $this->assertStringContainsString( + 'application/json', + $response->getHeaderLine('Content-Type') + ); + } + + public function test_returns_401_when_token_not_in_repo(): void + { + $response = $this->middleware->process( + $this->makeApiRequest('unknown-token'), + $this->makeHandler(), + ); + + $this->assertEquals(401, $response->getStatusCode()); + } + + public function test_returns_401_when_token_expired(): void + { + $this->sessionRepo->create(new CreateSessionDto( + token: 'expired-token', + userId: $this->user->getId(), + createdAt: new DateTimeImmutable('2024-12-01'), + expiresAt: new DateTimeImmutable('2024-12-08'), + )); + + $response = $this->middleware->process( + $this->makeApiRequest('expired-token'), + $this->makeHandler(), + ); + + $this->assertEquals(401, $response->getStatusCode()); + } + + public function test_attaches_user_to_request_on_success(): void + { + $this->sessionRepo->create(new CreateSessionDto( + token: 'valid-token', + userId: $this->user->getId(), + createdAt: new DateTimeImmutable('2025-01-01'), + expiresAt: new DateTimeImmutable('2025-01-08'), + )); + $handler = $this->makeHandler(); + + $this->middleware->process( + $this->makeApiRequest('valid-token'), + $handler, + ); + + $attached = $handler->capturedRequest->getAttribute('user'); + $this->assertInstanceOf(User::class, $attached); + $this->assertEquals( + 'test@test.com', + (string) $attached->getEmail() + ); + } + + public function test_redirects_to_login_when_html_unauthenticated(): void + { + $response = $this->middleware->process( + $this->makeHtmlRequest(), + $this->makeHandler(), + ); + + $this->assertEquals(302, $response->getStatusCode()); + $this->assertEquals('/login', $response->getHeaderLine('Location')); + } +} From d549cf914f6a833516ca7d2160e251b1c3afbf5a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:25:36 +0300 Subject: [PATCH 46/86] add auth middleware --- app/Auth/AuthMiddleware.php | 84 +++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 app/Auth/AuthMiddleware.php diff --git a/app/Auth/AuthMiddleware.php b/app/Auth/AuthMiddleware.php new file mode 100644 index 0000000..e72fba8 --- /dev/null +++ b/app/Auth/AuthMiddleware.php @@ -0,0 +1,84 @@ +getCookieParams(); + $token = $cookies[self::COOKIE_NAME] ?? null; + + if ($token === null) { + return $this->unauthorized($request); + } + + $session = $this->sessionRepo->findByToken($token); + if ($session === null) { + return $this->unauthorized($request); + } + + if ($session->isExpired($this->clock->now())) { + $this->sessionRepo->deleteByToken($token); + return $this->unauthorized($request); + } + + $user = $this->userRepo->find($session->getUserId()); + if ($user === null) { + return $this->unauthorized($request); + } + + return $handler->handle( + $request->withAttribute('user', $user) + ); + } + + private function unauthorized( + ServerRequestInterface $request + ): ResponseInterface { + if ($this->wantsJson($request)) { + $response = new Response(401); + $response->getBody()->write( + json_encode(['error' => 'unauthenticated']) + ); + return $response->withHeader( + 'Content-Type', + 'application/json' + ); + } + + return new Response(302)->withHeader('Location', '/login'); + } + + private function wantsJson(ServerRequestInterface $request): bool + { + $path = $request->getUri()->getPath(); + if (str_starts_with($path, '/api/')) { + return true; + } + + $accept = $request->getHeaderLine('Accept'); + if (str_contains($accept, 'application/json')) { + return true; + } + + return false; + } +} From 2666f40c27559702f5c19fc231ad295a751849b4 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:25:52 +0300 Subject: [PATCH 47/86] add forbidden exception --- app/Exceptions/ForbiddenException.php | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 app/Exceptions/ForbiddenException.php diff --git a/app/Exceptions/ForbiddenException.php b/app/Exceptions/ForbiddenException.php new file mode 100644 index 0000000..c9b05cd --- /dev/null +++ b/app/Exceptions/ForbiddenException.php @@ -0,0 +1,5 @@ + Date: Fri, 24 Apr 2026 13:26:01 +0300 Subject: [PATCH 48/86] add forbidden template --- views/templates/forbidden.php | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 views/templates/forbidden.php diff --git a/views/templates/forbidden.php b/views/templates/forbidden.php new file mode 100644 index 0000000..43cd644 --- /dev/null +++ b/views/templates/forbidden.php @@ -0,0 +1,11 @@ + + + + Daily Goals - Forbidden + + +

403 Forbidden

+

You do not have permission to access this page.

+ Back to Home + + From 40649ded8e2e864c7802bc9c88648b324b151793 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:26:20 +0300 Subject: [PATCH 49/86] test admin middleware --- .../Auth/Middleware/AdminMiddlewareTest.php | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/Unit/Auth/Middleware/AdminMiddlewareTest.php diff --git a/tests/Unit/Auth/Middleware/AdminMiddlewareTest.php b/tests/Unit/Auth/Middleware/AdminMiddlewareTest.php new file mode 100644 index 0000000..7ff5d88 --- /dev/null +++ b/tests/Unit/Auth/Middleware/AdminMiddlewareTest.php @@ -0,0 +1,118 @@ +middleware = new AdminMiddleware(); + } + + private function makeApiRequest(?User $user): ServerRequestInterface + { + $request = new ServerRequestFactory() + ->createServerRequest('POST', 'http://localhost/api/texts'); + if ($user !== null) { + $request = $request->withAttribute('user', $user); + } + return $request; + } + + private function makeHtmlRequest(?User $user): ServerRequestInterface + { + $request = new ServerRequestFactory() + ->createServerRequest('GET', 'http://localhost/admin') + ->withHeader('Accept', 'text/html'); + if ($user !== null) { + $request = $request->withAttribute('user', $user); + } + return $request; + } + + private function makeHandler(): RequestHandlerInterface + { + return new class() implements RequestHandlerInterface { + public bool $wasCalled = false; + + public function handle( + ServerRequestInterface $request + ): \Psr\Http\Message\ResponseInterface { + $this->wasCalled = true; + return new Response(200); + } + }; + } + + private function makeUser(bool $isAdmin): User + { + return new User( + id: 1, + email: new EmailAddress('test@test.com'), + passwordHash: '', + isAdmin: $isAdmin, + ); + } + + public function test_passes_through_when_user_is_admin(): void + { + $handler = $this->makeHandler(); + + $response = $this->middleware->process( + $this->makeApiRequest($this->makeUser(isAdmin: true)), + $handler, + ); + + $this->assertTrue($handler->wasCalled); + $this->assertEquals(200, $response->getStatusCode()); + } + + public function test_returns_403_json_when_user_not_admin_for_api(): void + { + $response = $this->middleware->process( + $this->makeApiRequest($this->makeUser(isAdmin: false)), + $this->makeHandler(), + ); + + $this->assertEquals(403, $response->getStatusCode()); + $this->assertStringContainsString( + 'application/json', + $response->getHeaderLine('Content-Type') + ); + } + + public function test_returns_403_html_when_user_not_admin_for_view(): void + { + $response = $this->middleware->process( + $this->makeHtmlRequest($this->makeUser(isAdmin: false)), + $this->makeHandler(), + ); + + $this->assertEquals(403, $response->getStatusCode()); + $this->assertStringContainsString( + '403 Forbidden', + (string) $response->getBody() + ); + } + + public function test_returns_403_when_no_user_attribute(): void + { + $response = $this->middleware->process( + $this->makeApiRequest(null), + $this->makeHandler(), + ); + + $this->assertEquals(403, $response->getStatusCode()); + } +} From bb4e27a45bfd10e0a2e9d8cc0aeed1fb13f0a781 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:26:38 +0300 Subject: [PATCH 50/86] add admin middleware --- app/Auth/AdminMiddleware.php | 64 ++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 app/Auth/AdminMiddleware.php diff --git a/app/Auth/AdminMiddleware.php b/app/Auth/AdminMiddleware.php new file mode 100644 index 0000000..47e16be --- /dev/null +++ b/app/Auth/AdminMiddleware.php @@ -0,0 +1,64 @@ +getAttribute('user'); + + if (!$user instanceof User || !$user->isAdmin()) { + return $this->forbidden($request); + } + + return $handler->handle($request); + } + + private function forbidden( + ServerRequestInterface $request + ): ResponseInterface { + $response = new Response(403); + + if ($this->wantsJson($request)) { + $response->getBody()->write( + json_encode(['error' => 'forbidden']) + ); + return $response->withHeader( + 'Content-Type', + 'application/json' + ); + } + + $html = file_get_contents( + __DIR__ . '/../../views/templates/forbidden.php' + ); + $response->getBody()->write($html); + + return $response->withHeader('Content-Type', 'text/html'); + } + + private function wantsJson(ServerRequestInterface $request): bool + { + $path = $request->getUri()->getPath(); + if (str_starts_with($path, '/api/')) { + return true; + } + + $accept = $request->getHeaderLine('Accept'); + if (str_contains($accept, 'application/json')) { + return true; + } + + return false; + } +} From 6c5833af5e8b5ee091343d8db66d983deb87d8eb Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:27:15 +0300 Subject: [PATCH 51/86] return user from create user use case --- app/User/UseCases/CreateUser.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/User/UseCases/CreateUser.php b/app/User/UseCases/CreateUser.php index 49dfb71..a327940 100644 --- a/app/User/UseCases/CreateUser.php +++ b/app/User/UseCases/CreateUser.php @@ -3,6 +3,7 @@ namespace App\User\UseCases; use App\Exceptions\BadRequestException; +use App\User\User; use App\User\UserRepository; use App\ValueObjects\EmailAddress; @@ -15,7 +16,7 @@ class CreateUser /** * @throws BadRequestException */ - public function execute(CreateUserRequest $dto): void + public function execute(CreateUserRequest $dto): User { if ($dto->email === null) { throw new BadRequestException('email is required'); @@ -36,7 +37,7 @@ class CreateUser throw new BadRequestException('email already taken'); } - $this->userRepo->create(new CreateUserDto( + return $this->userRepo->create(new CreateUserDto( email: $email, passwordHash: password_hash($dto->password, PASSWORD_DEFAULT), isAdmin: $dto->isAdmin, From edfe7259a3a97364dccf52cc225e682744a2a435 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:27:49 +0300 Subject: [PATCH 52/86] test auth controller --- tests/e2e/Controllers/AuthControllerTest.php | 284 +++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 tests/e2e/Controllers/AuthControllerTest.php diff --git a/tests/e2e/Controllers/AuthControllerTest.php b/tests/e2e/Controllers/AuthControllerTest.php new file mode 100644 index 0000000..d7bc415 --- /dev/null +++ b/tests/e2e/Controllers/AuthControllerTest.php @@ -0,0 +1,284 @@ +userRepo = new FakeUserRepository(); + $this->sessionRepo = new FakeSessionRepository(); + $this->tokenGenerator = new FakeTokenGenerator( + ['session-token-xyz'] + ); + $this->clock = new FakeClock( + new DateTimeImmutable('2025-01-01T12:00:00+00:00') + ); + + $this->createUser = new CreateUser($this->userRepo); + $this->authenticateUser = new AuthenticateUser($this->userRepo); + $this->createSession = new CreateSession( + $this->sessionRepo, + $this->tokenGenerator, + $this->clock, + ); + + $this->createUser->execute(new CreateUserRequest( + email: 'existing@test.com', + password: 'password1', + )); + + $this->controller = new AuthController(); + } + + private function makeJsonRequest( + string $method, + string $path, + array $data = [], + ): ServerRequestInterface { + $body = new StreamFactory()->createStream(json_encode($data)); + return new ServerRequestFactory() + ->createServerRequest($method, 'http://localhost' . $path) + ->withHeader('Content-Type', 'application/json') + ->withBody($body); + } + + public function test_login_returns_200_and_user(): void + { + $response = $this->controller->login( + $this->makeJsonRequest('POST', '/api/auth/login', [ + 'email' => 'existing@test.com', + 'password' => 'password1', + ]), + new Response(), + $this->authenticateUser, + $this->createSession, + ); + + $this->assertEquals(200, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertEquals( + 'existing@test.com', + $body['user']['email'] + ); + } + + public function test_login_sets_auth_cookie(): void + { + $response = $this->controller->login( + $this->makeJsonRequest('POST', '/api/auth/login', [ + 'email' => 'existing@test.com', + 'password' => 'password1', + ]), + new Response(), + $this->authenticateUser, + $this->createSession, + ); + + $setCookie = $response->getHeaderLine('Set-Cookie'); + $this->assertStringContainsString( + 'auth_token=session-token-xyz', + $setCookie + ); + $this->assertStringContainsString('HttpOnly', $setCookie); + $this->assertStringContainsString('SameSite=Lax', $setCookie); + $this->assertStringContainsString('Path=/', $setCookie); + } + + public function test_login_creates_session(): void + { + $this->controller->login( + $this->makeJsonRequest('POST', '/api/auth/login', [ + 'email' => 'existing@test.com', + 'password' => 'password1', + ]), + new Response(), + $this->authenticateUser, + $this->createSession, + ); + + $this->assertNotNull( + $this->sessionRepo->findByToken('session-token-xyz') + ); + } + + public function test_login_returns_401_on_wrong_password(): void + { + $response = $this->controller->login( + $this->makeJsonRequest('POST', '/api/auth/login', [ + 'email' => 'existing@test.com', + 'password' => 'wrongpassword', + ]), + new Response(), + $this->authenticateUser, + $this->createSession, + ); + + $this->assertEquals(401, $response->getStatusCode()); + } + + public function test_login_returns_400_when_email_missing(): void + { + $response = $this->controller->login( + $this->makeJsonRequest('POST', '/api/auth/login', [ + 'password' => 'password1', + ]), + new Response(), + $this->authenticateUser, + $this->createSession, + ); + + $this->assertEquals(400, $response->getStatusCode()); + } + + public function test_register_creates_user_and_logs_in(): void + { + $response = $this->controller->register( + $this->makeJsonRequest('POST', '/api/auth/register', [ + 'email' => 'new@test.com', + 'password' => 'password1', + ]), + new Response(), + $this->createUser, + $this->createSession, + ); + + $this->assertEquals(200, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertEquals('new@test.com', $body['user']['email']); + $setCookie = $response->getHeaderLine('Set-Cookie'); + $this->assertStringContainsString( + 'auth_token=session-token-xyz', + $setCookie + ); + } + + public function test_register_returns_400_on_short_password(): void + { + $response = $this->controller->register( + $this->makeJsonRequest('POST', '/api/auth/register', [ + 'email' => 'new@test.com', + 'password' => 'short', + ]), + new Response(), + $this->createUser, + $this->createSession, + ); + + $this->assertEquals(400, $response->getStatusCode()); + } + + public function test_register_returns_400_on_duplicate_email(): void + { + $response = $this->controller->register( + $this->makeJsonRequest('POST', '/api/auth/register', [ + 'email' => 'existing@test.com', + 'password' => 'password1', + ]), + new Response(), + $this->createUser, + $this->createSession, + ); + + $this->assertEquals(400, $response->getStatusCode()); + } + + public function test_register_ignores_is_admin_in_body(): void + { + $this->controller->register( + $this->makeJsonRequest('POST', '/api/auth/register', [ + 'email' => 'sneaky@test.com', + 'password' => 'password1', + 'isAdmin' => true, + ]), + new Response(), + $this->createUser, + $this->createSession, + ); + + $newUser = $this->userRepo->findByEmail( + new EmailAddress('sneaky@test.com') + ); + $this->assertFalse($newUser->isAdmin()); + } + + public function test_logout_deletes_session_and_clears_cookie(): void + { + $this->sessionRepo->create(new CreateSessionDto( + token: 'existing-session', + userId: 0, + createdAt: new DateTimeImmutable('2025-01-01'), + expiresAt: new DateTimeImmutable('2025-01-08'), + )); + + $request = $this->makeJsonRequest( + 'POST', + '/api/auth/logout', + )->withCookieParams(['auth_token' => 'existing-session']); + + $response = $this->controller->logout( + $request, + new Response(), + $this->sessionRepo, + ); + + $this->assertEquals(204, $response->getStatusCode()); + $this->assertNull( + $this->sessionRepo->findByToken('existing-session') + ); + $this->assertStringContainsString( + 'auth_token=;', + $response->getHeaderLine('Set-Cookie') + ); + } + + public function test_me_returns_current_user(): void + { + $user = new User( + id: 5, + email: new EmailAddress('me@test.com'), + passwordHash: '', + isAdmin: true, + ); + $request = new ServerRequestFactory() + ->createServerRequest('GET', 'http://localhost/api/auth/me') + ->withAttribute('user', $user); + + $response = $this->controller->me($request, new Response()); + + $this->assertEquals(200, $response->getStatusCode()); + $body = json_decode($response->getBody(), true); + $this->assertEquals(5, $body['user']['id']); + $this->assertEquals('me@test.com', $body['user']['email']); + $this->assertTrue($body['user']['isAdmin']); + } +} From c9d5ad37b8d2833742767724076f80ee82c2e161 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:28:22 +0300 Subject: [PATCH 53/86] add auth controller --- app/Auth/AuthController.php | 166 ++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 app/Auth/AuthController.php diff --git a/app/Auth/AuthController.php b/app/Auth/AuthController.php new file mode 100644 index 0000000..9dcc342 --- /dev/null +++ b/app/Auth/AuthController.php @@ -0,0 +1,166 @@ +parseBody($request); + + try { + $user = $authenticateUser->execute( + new AuthenticateUserRequest( + email: $data['email'] ?? null, + password: $data['password'] ?? null, + ) + ); + } catch (BadRequestException $exception) { + return $this->errorResponse( + $response, + 400, + $exception->getMessage() + ); + } catch (UnauthorizedException $exception) { + return $this->errorResponse( + $response, + 401, + $exception->getMessage() + ); + } + + $session = $createSession->execute($user); + + return $this->userResponse($response, $user) + ->withHeader( + 'Set-Cookie', + $this->buildSetCookie($session->getToken()) + ); + } + + public function register( + Request $request, + Response $response, + CreateUser $createUser, + CreateSession $createSession, + ): Response { + $data = $this->parseBody($request); + + try { + $user = $createUser->execute(new CreateUserRequest( + email: $data['email'] ?? null, + password: $data['password'] ?? null, + isAdmin: false, + )); + } catch (BadRequestException $exception) { + return $this->errorResponse( + $response, + 400, + $exception->getMessage() + ); + } + + $session = $createSession->execute($user); + + return $this->userResponse($response, $user) + ->withHeader( + 'Set-Cookie', + $this->buildSetCookie($session->getToken()) + ); + } + + public function logout( + Request $request, + Response $response, + SessionRepository $sessionRepo, + ): Response { + $cookies = $request->getCookieParams(); + $token = $cookies[AuthMiddleware::COOKIE_NAME] ?? null; + + if ($token !== null) { + $sessionRepo->deleteByToken($token); + } + + return $response->withStatus(204) + ->withHeader('Set-Cookie', $this->buildClearCookie()); + } + + public function me(Request $request, Response $response): Response + { + $user = $request->getAttribute('user'); + if (!$user instanceof User) { + return $this->errorResponse( + $response, + 401, + 'unauthenticated' + ); + } + + return $this->userResponse($response, $user); + } + + private function parseBody(Request $request): array + { + return json_decode((string) $request->getBody(), true) ?? []; + } + + private function userResponse(Response $response, User $user): Response + { + $response->getBody()->write(json_encode([ + 'user' => [ + 'id' => $user->getId(), + 'email' => (string) $user->getEmail(), + 'isAdmin' => $user->isAdmin(), + ], + ])); + + return $response->withHeader( + 'Content-Type', + 'application/json' + ); + } + + private function errorResponse( + Response $response, + int $status, + string $message, + ): Response { + $response->getBody()->write( + json_encode(['error' => $message]) + ); + + return $response->withStatus($status) + ->withHeader('Content-Type', 'application/json'); + } + + private function buildSetCookie(string $token): string + { + $maxAge = self::COOKIE_MAX_AGE; + + return AuthMiddleware::COOKIE_NAME . '=' . $token + . '; Path=/; HttpOnly; SameSite=Lax; Max-Age=' . $maxAge; + } + + private function buildClearCookie(): string + { + return AuthMiddleware::COOKIE_NAME . '=;' + . ' Path=/; HttpOnly; SameSite=Lax; Max-Age=0'; + } +} From 5f207f7fcb0e19dc6d8cdc106c1d5096d4c7977a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:28:40 +0300 Subject: [PATCH 54/86] add login and register view methods --- app/View/ViewController.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/View/ViewController.php b/app/View/ViewController.php index c131683..4fd8d84 100644 --- a/app/View/ViewController.php +++ b/app/View/ViewController.php @@ -37,4 +37,24 @@ class ViewController return $response; } + + public function login(Response $response): Response + { + $html = file_get_contents( + __DIR__ . '/../../views/templates/login.php' + ); + $response->getBody()->write($html); + + return $response; + } + + public function register(Response $response): Response + { + $html = file_get_contents( + __DIR__ . '/../../views/templates/register.php' + ); + $response->getBody()->write($html); + + return $response; + } } From 74a0e5980f4a8e94965e94154646feb298adad6f Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:28:58 +0300 Subject: [PATCH 55/86] wire auth routes and middleware groups --- bootstrap/app.php | 55 +++++++++++++++++++++++++++++++++++++---------- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/bootstrap/app.php b/bootstrap/app.php index 1ceeb8e..c05dc0f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,10 @@ use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use DI\Bridge\Slim\Bridge; +use Slim\Routing\RouteCollectorProxy; +use App\Auth\AdminMiddleware; +use App\Auth\AuthController; +use App\Auth\AuthMiddleware; use App\View\ViewController; use App\Text\TextController; use App\Node\NodeController; @@ -14,19 +18,48 @@ $app = Bridge::create($container); // change first param to false for production $app->addErrorMiddleware(true, true, true); -$app->get('/home', [ViewController::class, 'home']); -$app->get('/admin', [ViewController::class, 'admin']); -$app->get('/admin/texts', [ViewController::class, 'texts']); -$app->get('/admin/texts/{textId}', [ViewController::class, 'text']); +// Public routes (no auth required) +$app->get('/login', [ViewController::class, 'login']); +$app->get('/register', [ViewController::class, 'register']); +$app->post('/api/auth/login', [AuthController::class, 'login']); +$app->post('/api/auth/register', [AuthController::class, 'register']); -$app->get('/api/texts', [TextController::class, 'getTexts']); -$app->get('/api/texts/{textId}', [TextController::class, 'getText']); -$app->post('/api/texts', [TextController::class, 'createText']); +// Authenticated routes (any logged-in user) +$app->group('', function (RouteCollectorProxy $group) { + $group->get('/home', [ViewController::class, 'home']); -$app->get('/api/nodes/{textId}', [NodeController::class, 'getNodesOfText']); -$app->post('/api/nodes/bulk', [NodeController::class, 'bulkCreateNodes']); -$app->post('/api/nodes', [NodeController::class, 'createNode']); + $group->post('/api/auth/logout', [AuthController::class, 'logout']); + $group->get('/api/auth/me', [AuthController::class, 'me']); -$app->post('/api/plans', [PlanController::class, 'createPlan']); + $group->get('/api/texts', [TextController::class, 'getTexts']); + $group->get( + '/api/texts/{textId}', + [TextController::class, 'getText'] + ); + + $group->get( + '/api/nodes/{textId}', + [NodeController::class, 'getNodesOfText'] + ); + + $group->post('/api/plans', [PlanController::class, 'createPlan']); +})->add(AuthMiddleware::class); + +// Admin-only routes +$app->group('', function (RouteCollectorProxy $group) { + $group->get('/admin', [ViewController::class, 'admin']); + $group->get('/admin/texts', [ViewController::class, 'texts']); + $group->get( + '/admin/texts/{textId}', + [ViewController::class, 'text'] + ); + + $group->post('/api/texts', [TextController::class, 'createText']); + $group->post( + '/api/nodes/bulk', + [NodeController::class, 'bulkCreateNodes'] + ); + $group->post('/api/nodes', [NodeController::class, 'createNode']); +})->add(AdminMiddleware::class)->add(AuthMiddleware::class); return $app; From 6e0cda7f3e91e3b6263a7c4e98debe21905acee6 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:29:16 +0300 Subject: [PATCH 56/86] add login template --- views/templates/login.php | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 views/templates/login.php diff --git a/views/templates/login.php b/views/templates/login.php new file mode 100644 index 0000000..93f4b56 --- /dev/null +++ b/views/templates/login.php @@ -0,0 +1,26 @@ + + + + Daily Goals - Login + + +

Login

+
+ + + +
+ +

Register

+ + + From ce029fafa2378d6826075ad99e3ce0db97d6a9d6 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:29:25 +0300 Subject: [PATCH 57/86] add register template --- views/templates/register.php | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 views/templates/register.php diff --git a/views/templates/register.php b/views/templates/register.php new file mode 100644 index 0000000..223247c --- /dev/null +++ b/views/templates/register.php @@ -0,0 +1,27 @@ + + + + Daily Goals - Register + + +

Register

+
+ + + +
+ +

Already have an account? Login

+ + + From cb697daa034deebd686aae88326c43e9c49ea51b Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:29:38 +0300 Subject: [PATCH 58/86] add auth javascript --- public/js/auth.js | 75 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 public/js/auth.js diff --git a/public/js/auth.js b/public/js/auth.js new file mode 100644 index 0000000..0e8fc96 --- /dev/null +++ b/public/js/auth.js @@ -0,0 +1,75 @@ +async function submitAuthForm(endpoint, email, password, errorElement) { + errorElement.hidden = true; + errorElement.textContent = ''; + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ email, password }), + }); + + if (response.ok) { + window.location.href = '/home'; + return; + } + + let message = 'Something went wrong'; + try { + const body = await response.json(); + if (body.error) { + message = body.error; + } + } catch (parseError) { + // fall through to generic message + } + errorElement.textContent = message; + errorElement.hidden = false; +} + +async function logout() { + await fetch('/api/auth/logout', { + method: 'POST', + credentials: 'same-origin', + }); + window.location.href = '/login'; +} + +document.addEventListener('DOMContentLoaded', () => { + const loginForm = document.getElementById('login-form'); + if (loginForm !== null) { + const errorElement = document.getElementById('login-error'); + loginForm.addEventListener('submit', async (submitEvent) => { + submitEvent.preventDefault(); + const email = document.getElementById('email').value; + const password = document.getElementById('password').value; + await submitAuthForm( + '/api/auth/login', + email, + password, + errorElement, + ); + }); + } + + const registerForm = document.getElementById('register-form'); + if (registerForm !== null) { + const errorElement = document.getElementById('register-error'); + registerForm.addEventListener('submit', async (submitEvent) => { + submitEvent.preventDefault(); + const email = document.getElementById('email').value; + const password = document.getElementById('password').value; + await submitAuthForm( + '/api/auth/register', + email, + password, + errorElement, + ); + }); + } + + const logoutButton = document.getElementById('logout'); + if (logoutButton !== null) { + logoutButton.addEventListener('click', logout); + } +}); From 8c52294b10e06ada66d4bd0c1efb346abe488517 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:29:49 +0300 Subject: [PATCH 59/86] remove hardcoded user id from home --- public/js/home.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/js/home.js b/public/js/home.js index b46a051..c943cd4 100644 --- a/public/js/home.js +++ b/public/js/home.js @@ -67,8 +67,8 @@ document.addEventListener('DOMContentLoaded', () => { const response = await fetch('/api/plans', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', body: JSON.stringify({ - userId: 0, textId: textId, name: planName, dateStart: dateStart, From e4494a05770701afe4fe0673cbeeea000649dd42 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:30:04 +0300 Subject: [PATCH 60/86] add logout button to home --- views/templates/home.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/views/templates/home.php b/views/templates/home.php index ddbc361..4f7a096 100644 --- a/views/templates/home.php +++ b/views/templates/home.php @@ -5,6 +5,7 @@

Home

+
+ From 4e039fb583fb294737fed595d19db7ca9f3fb8bb Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:30:14 +0300 Subject: [PATCH 61/86] add logout button to admin --- views/templates/admin.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/views/templates/admin.php b/views/templates/admin.php index 7f463d4..f1fc83d 100644 --- a/views/templates/admin.php +++ b/views/templates/admin.php @@ -4,6 +4,8 @@ Daily Goals - Admin + Texts + From c649dbbcc233852c970b13637cda57d67a6542b9 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:30:54 +0300 Subject: [PATCH 62/86] include credentials on fetch calls --- public/js/home.js | 4 +++- public/js/text.js | 6 ++++-- public/js/texts.js | 5 ++++- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/public/js/home.js b/public/js/home.js index c943cd4..e7abc7c 100644 --- a/public/js/home.js +++ b/public/js/home.js @@ -3,7 +3,9 @@ document.addEventListener('DOMContentLoaded', () => { const createPlanModal = document.getElementById('create-plan-modal'); async function loadTexts() { - const response = await fetch('/api/texts'); + const response = await fetch('/api/texts', { + credentials: 'same-origin', + }); const texts = await response.json(); textsList.innerHTML = texts .map(text => diff --git a/public/js/text.js b/public/js/text.js index 0388bf6..59fc4ee 100644 --- a/public/js/text.js +++ b/public/js/text.js @@ -1,7 +1,7 @@ document.addEventListener('DOMContentLoaded', () => { const textId = window.location.pathname.split('/').pop(); - fetch('/api/texts/' + textId) + fetch('/api/texts/' + textId, { credentials: 'same-origin' }) .then(res => res.json()) .then(text => { const h1 = document.createElement('h1'); @@ -13,7 +13,7 @@ document.addEventListener('DOMContentLoaded', () => { }); function fetchAndRenderNodes(textId) { - return fetch('/api/nodes/' + textId) + return fetch('/api/nodes/' + textId, { credentials: 'same-origin' }) .then(res => res.json()) .then(nodes => { const existing = document.querySelector('#text-detail > ul'); @@ -113,6 +113,7 @@ function toggleAddForm(li, parentNodeId, textId) { fetch('/api/nodes', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', body: JSON.stringify({ textId: parseInt(textId), title, parentNodeId }), }) .then(res => { @@ -157,6 +158,7 @@ function toggleBulkAddForm(li, parentNodeId, textId) { fetch('/api/nodes/bulk', { method: 'POST', headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', body: JSON.stringify({ textId: parseInt(textId), parentNodeId, titlePrefix, count }), }) .then(res => { diff --git a/public/js/texts.js b/public/js/texts.js index 1937749..029196d 100644 --- a/public/js/texts.js +++ b/public/js/texts.js @@ -3,7 +3,9 @@ document.addEventListener('DOMContentLoaded', () => { const form = document.getElementById('texts-form'); async function loadTexts() { - const res = await fetch('/api/texts'); + const res = await fetch('/api/texts', { + credentials: 'same-origin', + }); const texts = await res.json(); textsList.innerHTML = texts.map(text => '
  • { const formData = new FormData(form); const res = await fetch('/api/texts', { method: 'POST', + credentials: 'same-origin', body: formData, }); if (res.ok) { From 05374991c51b3d1cee3047dadf707089d2efcbb0 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:31:44 +0300 Subject: [PATCH 63/86] update plan controller tests for auth --- tests/e2e/Controllers/PlanControllerTest.php | 58 +++++++------------- 1 file changed, 19 insertions(+), 39 deletions(-) diff --git a/tests/e2e/Controllers/PlanControllerTest.php b/tests/e2e/Controllers/PlanControllerTest.php index a14fd7d..cf0eeb0 100644 --- a/tests/e2e/Controllers/PlanControllerTest.php +++ b/tests/e2e/Controllers/PlanControllerTest.php @@ -8,6 +8,7 @@ use App\Plan\UseCases\CreatePlan; use App\ScheduledNode\UseCases\CreateScheduledNode; use App\Text\CreateTextDto; use App\User\UseCases\CreateUserDto; +use App\User\User; use App\ValueObjects\EmailAddress; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -29,6 +30,7 @@ class PlanControllerTest extends TestCase private FakeScheduledNodeRepository $scheduledNodeRepo; private CreatePlan $createPlan; private PlanController $controller; + private User $user; public function setUp(): void { @@ -38,7 +40,7 @@ class PlanControllerTest extends TestCase $this->nodeRepo = new FakeNodeRepository(); $this->scheduledNodeRepo = new FakeScheduledNodeRepository(); - $this->userRepo->create(new CreateUserDto( + $this->user = $this->userRepo->create(new CreateUserDto( email: new EmailAddress('test@test.com'), passwordHash: '', )); @@ -69,6 +71,7 @@ class PlanControllerTest extends TestCase return new ServerRequestFactory() ->createServerRequest('POST', 'http://localhost/api/plans') ->withHeader('Content-Type', 'application/json') + ->withAttribute('user', $this->user) ->withBody($body); } @@ -76,7 +79,6 @@ class PlanControllerTest extends TestCase { $response = $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 0, 'name' => 'My Plan', 'dateStart' => '2025-01-01', @@ -92,29 +94,33 @@ class PlanControllerTest extends TestCase $this->assertEquals('My Plan', $body['name']); } - public function test_create_plan_returns_400_when_user_id_missing(): void + public function test_create_plan_returns_401_when_no_user(): void { + $requestWithoutUser = new ServerRequestFactory() + ->createServerRequest('POST', 'http://localhost/api/plans') + ->withHeader('Content-Type', 'application/json') + ->withBody( + new StreamFactory()->createStream(json_encode([ + 'textId' => 0, + 'name' => 'My Plan', + 'dateStart' => '2025-01-01', + 'dateEnd' => '2025-01-01', + ])) + ); + $response = $this->controller->createPlan( - $this->makeRequest([ - 'textId' => 0, - 'name' => 'My Plan', - 'dateStart' => '2025-01-01', - 'dateEnd' => '2025-01-01', - ]), + $requestWithoutUser, new Response(), $this->createPlan, ); - $this->assertEquals(400, $response->getStatusCode()); - $body = json_decode($response->getBody(), true); - $this->assertArrayHasKey('error', $body); + $this->assertEquals(401, $response->getStatusCode()); } public function test_create_plan_returns_400_when_text_id_missing(): void { $response = $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'name' => 'My Plan', 'dateStart' => '2025-01-01', 'dateEnd' => '2025-01-01', @@ -132,7 +138,6 @@ class PlanControllerTest extends TestCase { $response = $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 0, 'dateStart' => '2025-01-01', 'dateEnd' => '2025-01-01', @@ -150,7 +155,6 @@ class PlanControllerTest extends TestCase { $response = $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 0, 'name' => 'My Plan', 'dateEnd' => '2025-01-01', @@ -168,7 +172,6 @@ class PlanControllerTest extends TestCase { $response = $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 0, 'name' => 'My Plan', 'dateStart' => '2025-01-01', @@ -186,7 +189,6 @@ class PlanControllerTest extends TestCase { $response = $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 0, 'name' => 'My Plan', 'dateStart' => '2025-01-02', @@ -201,30 +203,10 @@ class PlanControllerTest extends TestCase $this->assertArrayHasKey('error', $body); } - public function test_create_plan_returns_404_when_user_not_found(): void - { - $response = $this->controller->createPlan( - $this->makeRequest([ - 'userId' => 99, - 'textId' => 0, - 'name' => 'My Plan', - 'dateStart' => '2025-01-01', - 'dateEnd' => '2025-01-01', - ]), - new Response(), - $this->createPlan, - ); - - $this->assertEquals(404, $response->getStatusCode()); - $body = json_decode($response->getBody(), true); - $this->assertArrayHasKey('error', $body); - } - public function test_create_plan_returns_404_when_text_not_found(): void { $response = $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 99, 'name' => 'My Plan', 'dateStart' => '2025-01-01', @@ -243,7 +225,6 @@ class PlanControllerTest extends TestCase { $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 0, 'name' => 'Persistent Plan', 'dateStart' => '2025-01-01', @@ -262,7 +243,6 @@ class PlanControllerTest extends TestCase { $this->controller->createPlan( $this->makeRequest([ - 'userId' => 0, 'textId' => 0, 'name' => 'Scheduling Plan', 'dateStart' => '2025-01-01', From 5a24f5bde47284200bb42e380333e34ee4399372 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 13:32:04 +0300 Subject: [PATCH 64/86] read user from request in plan controller --- app/Plan/PlanController.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/Plan/PlanController.php b/app/Plan/PlanController.php index cb88de8..f7aa4ec 100644 --- a/app/Plan/PlanController.php +++ b/app/Plan/PlanController.php @@ -5,6 +5,7 @@ namespace App\Plan; use App\Exceptions\BadRequestException; use App\Plan\UseCases\CreatePlan; use App\Plan\UseCases\CreatePlanRequest; +use App\User\User; use DomainException; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -16,9 +17,17 @@ class PlanController Response $response, CreatePlan $createPlanUseCase, ): Response { + $user = $request->getAttribute('user'); + if (!$user instanceof User) { + $response->getBody()->write( + json_encode(['error' => 'unauthenticated']) + ); + return $response->withStatus(401) + ->withHeader('Content-Type', 'application/json'); + } + $data = json_decode((string) $request->getBody(), true) ?? []; - $userId = isset($data['userId']) ? (int) $data['userId'] : null; $textId = isset($data['textId']) ? (int) $data['textId'] : null; $name = $data['name'] ?? null; $dateStart = $data['dateStart'] ?? null; @@ -26,7 +35,7 @@ class PlanController try { $plan = $createPlanUseCase->execute(new CreatePlanRequest( - userId: $userId, + userId: $user->getId(), textId: $textId, name: $name, dateStart: $dateStart, From 95f7f1cb7825223116297fe086cb1e1818a4c0c5 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 16:06:59 +0300 Subject: [PATCH 65/86] seed regular user for cypress --- data/seedDb.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/data/seedDb.php b/data/seedDb.php index 9eb4128..aaa329f 100644 --- a/data/seedDb.php +++ b/data/seedDb.php @@ -28,7 +28,9 @@ $nodes = [ ], ]; -// Default admin credentials: admin@example.com / admin1234 +// Default credentials: +// admin@example.com / admin1234 (admin) +// user@example.com / password1 (regular user) $users = [ [ 'id' => 0, @@ -36,6 +38,12 @@ $users = [ 'passwordHash' => password_hash('admin1234', PASSWORD_DEFAULT), 'isAdmin' => true, ], + [ + 'id' => 1, + 'email' => 'user@example.com', + 'passwordHash' => password_hash('password1', PASSWORD_DEFAULT), + 'isAdmin' => false, + ], ]; $plans = []; From 49c5ed49b0749ccac65a63d2830f1f05dde3bea2 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 16:07:07 +0300 Subject: [PATCH 66/86] add cypress login commands --- cypress/support/commands.js | 40 ++++++++++++++----------------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index 66ea16e..61e6549 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -1,25 +1,15 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) \ No newline at end of file +Cypress.Commands.add('login', (email, password) => { + cy.request({ + method: 'POST', + url: '/api/auth/login', + body: { email, password }, + }) +}) + +Cypress.Commands.add('loginAsAdmin', () => { + cy.login('admin@example.com', 'admin1234') +}) + +Cypress.Commands.add('loginAsUser', () => { + cy.login('user@example.com', 'password1') +}) From cddc72e6cf29eb9e76b0c7555a5424ee0cd5dbc8 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 16:07:16 +0300 Subject: [PATCH 67/86] login as user in home cypress spec --- cypress/e2e/home.cy.js | 1 + 1 file changed, 1 insertion(+) diff --git a/cypress/e2e/home.cy.js b/cypress/e2e/home.cy.js index d0ba1fe..83c6984 100644 --- a/cypress/e2e/home.cy.js +++ b/cypress/e2e/home.cy.js @@ -1,6 +1,7 @@ describe('The home page', () => { beforeEach(() => { cy.exec('npm run db:seed') + cy.loginAsUser() }) afterEach(() => { cy.exec('npm run db:wipe') From 8bfc110ed3955a9c5dbcb4a8b02c4390eb8ccd71 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 16:07:25 +0300 Subject: [PATCH 68/86] login as user in home create plan cypress spec --- cypress/e2e/homeCreatePlan.cy.js | 1 + 1 file changed, 1 insertion(+) diff --git a/cypress/e2e/homeCreatePlan.cy.js b/cypress/e2e/homeCreatePlan.cy.js index 3c9ee87..dbffa44 100644 --- a/cypress/e2e/homeCreatePlan.cy.js +++ b/cypress/e2e/homeCreatePlan.cy.js @@ -1,6 +1,7 @@ describe('Create plan modal on the home page', () => { beforeEach(() => { cy.exec('npm run db:seed') + cy.loginAsUser() cy.intercept('GET', '/api/texts').as('getTexts') cy.visit('/home') cy.wait('@getTexts') From 6e93bd3872045c94efb17af0e05c08d4022a5358 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 16:07:36 +0300 Subject: [PATCH 69/86] update create plan body assertion --- cypress/e2e/homeCreatePlan.cy.js | 1 - 1 file changed, 1 deletion(-) diff --git a/cypress/e2e/homeCreatePlan.cy.js b/cypress/e2e/homeCreatePlan.cy.js index dbffa44..8509a61 100644 --- a/cypress/e2e/homeCreatePlan.cy.js +++ b/cypress/e2e/homeCreatePlan.cy.js @@ -61,7 +61,6 @@ describe('Create plan modal on the home page', () => { cy.wait('@createPlan').then((createPlanRequest) => { expect(createPlanRequest.response.statusCode).to.eq(201) expect(createPlanRequest.request.body).to.deep.equal({ - userId: 0, textId: 0, name: 'My reading plan', dateStart: '2025-01-01', From 5f2bba070cab8600a580b6ad34a45fed17cd2e01 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 16:07:44 +0300 Subject: [PATCH 70/86] login as admin in admin cypress spec --- cypress/e2e/admin.cy.js | 1 + 1 file changed, 1 insertion(+) diff --git a/cypress/e2e/admin.cy.js b/cypress/e2e/admin.cy.js index 2c8054c..3bd205f 100644 --- a/cypress/e2e/admin.cy.js +++ b/cypress/e2e/admin.cy.js @@ -1,6 +1,7 @@ describe('The admin page', () => { beforeEach(() => { cy.exec('npm run db:seed') + cy.loginAsAdmin() cy.visit('/admin') }) afterEach(() => { From 3ee60579789d29d9cf98a9f58873401af676259d Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 16:07:53 +0300 Subject: [PATCH 71/86] login as admin in admin text cypress spec --- cypress/e2e/adminText.cy.js | 1 + 1 file changed, 1 insertion(+) diff --git a/cypress/e2e/adminText.cy.js b/cypress/e2e/adminText.cy.js index 84be4cd..f5cad89 100644 --- a/cypress/e2e/adminText.cy.js +++ b/cypress/e2e/adminText.cy.js @@ -1,6 +1,7 @@ describe('The admin text detail page', () => { beforeEach(() => { cy.exec('npm run db:seed') + cy.loginAsAdmin() cy.intercept('GET', '/api/texts/0').as('getText') cy.intercept('GET', '/api/nodes/0').as('getNodes') cy.visit('/admin/texts/0') From e8fcac654b30d564f454d260804da0931bcf4143 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 16:08:03 +0300 Subject: [PATCH 72/86] login as admin in admin text toggle cypress spec --- cypress/e2e/adminTextToggle.cy.js | 1 + 1 file changed, 1 insertion(+) diff --git a/cypress/e2e/adminTextToggle.cy.js b/cypress/e2e/adminTextToggle.cy.js index c75132b..23cf830 100644 --- a/cypress/e2e/adminTextToggle.cy.js +++ b/cypress/e2e/adminTextToggle.cy.js @@ -1,6 +1,7 @@ describe('Toggle display of child nodes', () => { beforeEach(() => { cy.exec('npm run db:seed') + cy.loginAsAdmin() cy.intercept('GET', '/api/texts/0').as('getText') cy.intercept('GET', '/api/nodes/0').as('getNodes') cy.visit('/admin/texts/0') From 4c393e813a3d70041c97942d2ab58e77a107d367 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 16:08:14 +0300 Subject: [PATCH 73/86] login as admin in admin text bulk add cypress spec --- cypress/e2e/adminTextBulkAdd.cy.js | 1 + 1 file changed, 1 insertion(+) diff --git a/cypress/e2e/adminTextBulkAdd.cy.js b/cypress/e2e/adminTextBulkAdd.cy.js index 03159a2..7d1afc2 100644 --- a/cypress/e2e/adminTextBulkAdd.cy.js +++ b/cypress/e2e/adminTextBulkAdd.cy.js @@ -1,6 +1,7 @@ describe('Bulk add children on the admin text detail page', () => { beforeEach(() => { cy.exec('npm run db:seed') + cy.loginAsAdmin() cy.intercept('GET', '/api/texts/0').as('getText') cy.intercept('GET', '/api/nodes/0').as('getNodes') cy.visit('/admin/texts/0') From 4975da19be4531f7fde5632cc17f5e0b1bfa8722 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Fri, 24 Apr 2026 16:08:32 +0300 Subject: [PATCH 74/86] test auth flows in cypress --- cypress/e2e/auth.cy.js | 87 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 cypress/e2e/auth.cy.js diff --git a/cypress/e2e/auth.cy.js b/cypress/e2e/auth.cy.js new file mode 100644 index 0000000..ae22d22 --- /dev/null +++ b/cypress/e2e/auth.cy.js @@ -0,0 +1,87 @@ +describe('Authentication flows', () => { + beforeEach(() => { + cy.exec('npm run db:seed') + }) + + afterEach(() => { + cy.exec('npm run db:wipe') + }) + + it('unauthenticated home redirects to login', () => { + cy.visit('/home') + cy.url().should('include', '/login') + }) + + it('login form submits and redirects to home', () => { + cy.visit('/login') + cy.get('#email').type('user@example.com') + cy.get('#password').type('password1') + cy.get('#login-form').submit() + cy.url().should('include', '/home') + cy.get('h1').should('contain', 'Home') + }) + + it('login shows error on wrong password', () => { + cy.visit('/login') + cy.get('#email').type('user@example.com') + cy.get('#password').type('wrongpassword') + cy.get('#login-form').submit() + cy.get('#login-error').should('be.visible') + cy.url().should('include', '/login') + }) + + it('register creates user and redirects to home', () => { + cy.visit('/register') + cy.get('#email').type('fresh@example.com') + cy.get('#password').type('password1') + cy.get('#register-form').submit() + cy.url().should('include', '/home') + }) + + it('register shows error on short password', () => { + cy.visit('/register') + cy.get('#email').type('another@example.com') + cy.get('#password').invoke( + 'removeAttr', + 'minlength' + ) + cy.get('#password').type('short') + cy.get('#register-form').submit() + cy.get('#register-error').should('be.visible') + cy.url().should('include', '/register') + }) + + it('register shows error on duplicate email', () => { + cy.visit('/register') + cy.get('#email').type('user@example.com') + cy.get('#password').type('password1') + cy.get('#register-form').submit() + cy.get('#register-error').should('be.visible') + cy.url().should('include', '/register') + }) + + it('logout clears session and redirects to login', () => { + cy.loginAsUser() + cy.visit('/home') + cy.get('#logout').click() + cy.url().should('include', '/login') + cy.visit('/home') + cy.url().should('include', '/login') + }) + + it('non-admin user hitting /admin gets 403', () => { + cy.loginAsUser() + cy.request({ + url: '/admin', + failOnStatusCode: false, + }).then((response) => { + expect(response.status).to.eq(403) + }) + }) + + it('admin user can access /admin', () => { + cy.loginAsAdmin() + cy.visit('/admin') + cy.get('#texts').should('exist') + }) +}) From d93b668d5af4943177353d4bf6cc6236edb2b50a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sat, 25 Apr 2026 22:08:25 +0300 Subject: [PATCH 75/86] add session entity --- DailyGoals.drawio | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/DailyGoals.drawio b/DailyGoals.drawio index ca3101c..b0937cb 100644 --- a/DailyGoals.drawio +++ b/DailyGoals.drawio @@ -37,6 +37,12 @@ + + + + + + From b1247d2fa10be17354f9462165d9688c662a53ad Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 26 Apr 2026 09:06:17 +0300 Subject: [PATCH 76/86] add PasswordHasher interface with bcrypt implementation Introduce an injectable abstraction over password_hash and password_verify so callers can be swapped for a fast fake in tests without paying bcrypt's CPU cost. The bcrypt implementation is a direct passthrough using PASSWORD_DEFAULT, matching the prior inline behavior, so existing stored hashes continue to verify. Wired into the DI container alongside the other auth primitives (Clock, TokenGenerator). No callers reference it yet, so production behavior is unchanged. --- app/Auth/BcryptPasswordHasher.php | 16 ++++++++++++++++ app/Auth/PasswordHasher.php | 10 ++++++++++ bootstrap/container.php | 3 +++ 3 files changed, 29 insertions(+) create mode 100644 app/Auth/BcryptPasswordHasher.php create mode 100644 app/Auth/PasswordHasher.php diff --git a/app/Auth/BcryptPasswordHasher.php b/app/Auth/BcryptPasswordHasher.php new file mode 100644 index 0000000..8593710 --- /dev/null +++ b/app/Auth/BcryptPasswordHasher.php @@ -0,0 +1,16 @@ + DI\autowire(RandomTokenGenerator::class), Clock::class => DI\autowire(SystemClock::class), + PasswordHasher::class => DI\autowire(BcryptPasswordHasher::class), ]); return $container; From 632085f5b62b8df09b3710ccfd64148579447e2e Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 26 Apr 2026 09:06:21 +0300 Subject: [PATCH 77/86] inject PasswordHasher into CreateUser and AuthenticateUser Replace direct password_hash and password_verify calls with the injected PasswordHasher so the bcrypt cost can be substituted out in tests. Production wiring is handled by the container's autowiring of BcryptPasswordHasher. This commit alone breaks the test suite because the existing tests construct these use cases without the new dependency; the next commit restores green by introducing FakePasswordHasher. --- app/User/UseCases/AuthenticateUser.php | 4 +++- app/User/UseCases/CreateUser.php | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/User/UseCases/AuthenticateUser.php b/app/User/UseCases/AuthenticateUser.php index 56281b1..a889a06 100644 --- a/app/User/UseCases/AuthenticateUser.php +++ b/app/User/UseCases/AuthenticateUser.php @@ -2,6 +2,7 @@ namespace App\User\UseCases; +use App\Auth\PasswordHasher; use App\Exceptions\BadRequestException; use App\Exceptions\UnauthorizedException; use App\User\User; @@ -12,6 +13,7 @@ class AuthenticateUser { public function __construct( private UserRepository $userRepo, + private PasswordHasher $passwordHasher, ) {} /** @@ -35,7 +37,7 @@ class AuthenticateUser throw new UnauthorizedException('invalid credentials'); } - $passwordMatches = password_verify( + $passwordMatches = $this->passwordHasher->verify( $request->password, $user->getPasswordHash() ); diff --git a/app/User/UseCases/CreateUser.php b/app/User/UseCases/CreateUser.php index a327940..cf89fdd 100644 --- a/app/User/UseCases/CreateUser.php +++ b/app/User/UseCases/CreateUser.php @@ -2,6 +2,7 @@ namespace App\User\UseCases; +use App\Auth\PasswordHasher; use App\Exceptions\BadRequestException; use App\User\User; use App\User\UserRepository; @@ -11,6 +12,7 @@ class CreateUser { public function __construct( private UserRepository $userRepo, + private PasswordHasher $passwordHasher, ) {} /** @@ -39,7 +41,7 @@ class CreateUser return $this->userRepo->create(new CreateUserDto( email: $email, - passwordHash: password_hash($dto->password, PASSWORD_DEFAULT), + passwordHash: $this->passwordHasher->hash($dto->password), isAdmin: $dto->isAdmin, )); } From bb6bd7cbb31950cc3c10b99231c5077672f96bfc Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 26 Apr 2026 09:06:26 +0300 Subject: [PATCH 78/86] use FakePasswordHasher in tests to eliminate bcrypt cost Add a trivial prefix-based PasswordHasher fake and inject it into the three test files that exercise CreateUser or AuthenticateUser. Drops the full phpunit suite from ~7.4s to ~30ms (about 224x) without losing coverage: the round-trip through hash/verify still validates that CreateUser stores something other than the plaintext and that AuthenticateUser only succeeds on a matching hash. CreateUserTest is also refactored to use a setUp method, matching the pattern already used in AuthenticateUserTest and AuthControllerTest. --- tests/Fakes/FakePasswordHasher.php | 20 ++++++ .../User/UseCases/AuthenticateUserTest.php | 13 +++- tests/Unit/User/UseCases/CreateUserTest.php | 65 +++++++++---------- tests/e2e/Controllers/AuthControllerTest.php | 17 +++-- 4 files changed, 76 insertions(+), 39 deletions(-) create mode 100644 tests/Fakes/FakePasswordHasher.php diff --git a/tests/Fakes/FakePasswordHasher.php b/tests/Fakes/FakePasswordHasher.php new file mode 100644 index 0000000..4e9a17d --- /dev/null +++ b/tests/Fakes/FakePasswordHasher.php @@ -0,0 +1,20 @@ +userRepo = new FakeUserRepository(); - $createUser = new CreateUser($this->userRepo); + $this->passwordHasher = new FakePasswordHasher(); + $createUser = new CreateUser( + $this->userRepo, + $this->passwordHasher, + ); $createUser->execute(new CreateUserRequest( email: 'test@test.com', password: 'password1', )); - $this->useCase = new AuthenticateUser($this->userRepo); + $this->useCase = new AuthenticateUser( + $this->userRepo, + $this->passwordHasher, + ); } public function test_returns_user_on_valid_credentials(): void diff --git a/tests/Unit/User/UseCases/CreateUserTest.php b/tests/Unit/User/UseCases/CreateUserTest.php index 93971c4..941c98d 100644 --- a/tests/Unit/User/UseCases/CreateUserTest.php +++ b/tests/Unit/User/UseCases/CreateUserTest.php @@ -6,67 +6,71 @@ use App\Exceptions\BadRequestException; use App\User\User; use App\User\UseCases\CreateUser; use App\User\UseCases\CreateUserRequest; +use Tests\Fakes\FakePasswordHasher; use Tests\Fakes\FakeUserRepository; use PHPUnit\Framework\TestCase; class CreateUserTest extends TestCase { + private FakeUserRepository $userRepo; + private FakePasswordHasher $passwordHasher; + private CreateUser $useCase; + + public function setUp(): void + { + $this->userRepo = new FakeUserRepository(); + $this->passwordHasher = new FakePasswordHasher(); + $this->useCase = new CreateUser( + $this->userRepo, + $this->passwordHasher, + ); + } + public function test_create_user(): void { - $userRepo = new FakeUserRepository(); - $useCase = new CreateUser($userRepo); - $useCase->execute(new CreateUserRequest( + $this->useCase->execute(new CreateUserRequest( email: 'test@test.com', password: 'password1', )); - $user = $userRepo->find(0); + $user = $this->userRepo->find(0); $this->assertInstanceOf(User::class, $user); $this->assertEquals('test@test.com', $user->getEmail()); } public function test_throws_if_email_is_null(): void { - $userRepo = new FakeUserRepository(); - $useCase = new CreateUser($userRepo); - $this->expectException(BadRequestException::class); $this->expectExceptionMessage('email is required'); - $useCase->execute(new CreateUserRequest( + $this->useCase->execute(new CreateUserRequest( email: null, )); } public function test_is_admin_defaults_to_false(): void { - $userRepo = new FakeUserRepository(); - $useCase = new CreateUser($userRepo); - $useCase->execute(new CreateUserRequest( + $this->useCase->execute(new CreateUserRequest( email: 'test@test.com', password: 'password1', )); - $user = $userRepo->find(0); + $user = $this->userRepo->find(0); $this->assertFalse($user->isAdmin()); } public function test_is_admin_can_be_set_true(): void { - $userRepo = new FakeUserRepository(); - $useCase = new CreateUser($userRepo); - $useCase->execute(new CreateUserRequest( + $this->useCase->execute(new CreateUserRequest( email: 'test@test.com', password: 'password1', isAdmin: true, )); - $user = $userRepo->find(0); + $user = $this->userRepo->find(0); $this->assertTrue($user->isAdmin()); } public function test_throws_when_email_already_taken(): void { - $userRepo = new FakeUserRepository(); - $useCase = new CreateUser($userRepo); - $useCase->execute(new CreateUserRequest( + $this->useCase->execute(new CreateUserRequest( email: 'test@test.com', password: 'password1', )); @@ -74,7 +78,7 @@ class CreateUserTest extends TestCase $this->expectException(BadRequestException::class); $this->expectExceptionMessage('email already taken'); - $useCase->execute(new CreateUserRequest( + $this->useCase->execute(new CreateUserRequest( email: 'test@test.com', password: 'password1', )); @@ -82,13 +86,10 @@ class CreateUserTest extends TestCase public function test_throws_if_password_is_null(): void { - $userRepo = new FakeUserRepository(); - $useCase = new CreateUser($userRepo); - $this->expectException(BadRequestException::class); $this->expectExceptionMessage('password is required'); - $useCase->execute(new CreateUserRequest( + $this->useCase->execute(new CreateUserRequest( email: 'test@test.com', password: null, )); @@ -96,15 +97,12 @@ class CreateUserTest extends TestCase public function test_throws_if_password_too_short(): void { - $userRepo = new FakeUserRepository(); - $useCase = new CreateUser($userRepo); - $this->expectException(BadRequestException::class); $this->expectExceptionMessage( 'password must be at least 8 characters' ); - $useCase->execute(new CreateUserRequest( + $this->useCase->execute(new CreateUserRequest( email: 'test@test.com', password: 'short', )); @@ -112,16 +110,17 @@ class CreateUserTest extends TestCase public function test_stores_hashed_password(): void { - $userRepo = new FakeUserRepository(); - $useCase = new CreateUser($userRepo); - $useCase->execute(new CreateUserRequest( + $this->useCase->execute(new CreateUserRequest( email: 'test@test.com', password: 'password1', )); - $user = $userRepo->find(0); + $user = $this->userRepo->find(0); $this->assertNotEquals('password1', $user->getPasswordHash()); $this->assertTrue( - password_verify('password1', $user->getPasswordHash()) + $this->passwordHasher->verify( + 'password1', + $user->getPasswordHash() + ) ); } } diff --git a/tests/e2e/Controllers/AuthControllerTest.php b/tests/e2e/Controllers/AuthControllerTest.php index d7bc415..58b209d 100644 --- a/tests/e2e/Controllers/AuthControllerTest.php +++ b/tests/e2e/Controllers/AuthControllerTest.php @@ -3,7 +3,6 @@ namespace Tests\e2e\Controllers; use App\Auth\AuthController; -use App\Auth\AuthMiddleware; use App\Auth\CreateSessionDto; use App\Auth\UseCases\CreateSession; use App\User\UseCases\AuthenticateUser; @@ -18,6 +17,7 @@ use Slim\Psr7\Factory\ServerRequestFactory; use Slim\Psr7\Factory\StreamFactory; use Slim\Psr7\Response; use Tests\Fakes\FakeClock; +use Tests\Fakes\FakePasswordHasher; use Tests\Fakes\FakeSessionRepository; use Tests\Fakes\FakeTokenGenerator; use Tests\Fakes\FakeUserRepository; @@ -28,6 +28,7 @@ class AuthControllerTest extends TestCase private FakeSessionRepository $sessionRepo; private FakeTokenGenerator $tokenGenerator; private FakeClock $clock; + private FakePasswordHasher $passwordHasher; private CreateUser $createUser; private AuthenticateUser $authenticateUser; private CreateSession $createSession; @@ -43,9 +44,16 @@ class AuthControllerTest extends TestCase $this->clock = new FakeClock( new DateTimeImmutable('2025-01-01T12:00:00+00:00') ); + $this->passwordHasher = new FakePasswordHasher(); - $this->createUser = new CreateUser($this->userRepo); - $this->authenticateUser = new AuthenticateUser($this->userRepo); + $this->createUser = new CreateUser( + $this->userRepo, + $this->passwordHasher, + ); + $this->authenticateUser = new AuthenticateUser( + $this->userRepo, + $this->passwordHasher, + ); $this->createSession = new CreateSession( $this->sessionRepo, $this->tokenGenerator, @@ -63,7 +71,7 @@ class AuthControllerTest extends TestCase private function makeJsonRequest( string $method, string $path, - array $data = [], + array $data, ): ServerRequestInterface { $body = new StreamFactory()->createStream(json_encode($data)); return new ServerRequestFactory() @@ -243,6 +251,7 @@ class AuthControllerTest extends TestCase $request = $this->makeJsonRequest( 'POST', '/api/auth/logout', + [] )->withCookieParams(['auth_token' => 'existing-session']); $response = $this->controller->logout( From 40726c3984ed362325fbfde9bfd44d001397bfbb Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 26 Apr 2026 10:11:23 +0300 Subject: [PATCH 79/86] loosen commit granularity rule in prompts Replace the strict "one commit per file" guidance with grouping by related changes, while keeping the small-and-focused intent. Add explicit guidance on when to include a commit body and how to format it (blank line separator, ~72 col wrap). Applied to both backend and frontend prompt templates. --- ai/backend_prompt_template.md | 22 ++++++++++++++-------- ai/frontend_prompt_template.md | 22 ++++++++++++++-------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/ai/backend_prompt_template.md b/ai/backend_prompt_template.md index 1aa846d..5b9cf44 100644 --- a/ai/backend_prompt_template.md +++ b/ai/backend_prompt_template.md @@ -32,18 +32,24 @@ Code patterns to follow: - Variable names: use explicit, descriptive names — never single-letter or abbreviated variables (e.g., use $sponsorship not $s, $event not $e) Git commit style: -- Present tense, imperative mood (add, create, test, fix) -- Lowercase -- Short (3-6 words) -- Match patterns found in git history +- Subject: present tense, imperative mood (add, create, test, fix) +- Subject: lowercase, short (3-6 words) +- Match subject patterns found in git history +- Add a body when the change needs explanation beyond the subject — + e.g., why the change was made, non-obvious tradeoffs, or notable + implementation details. Skip the body for trivial/self-evident commits. +- Separate subject and body with a blank line; wrap body at ~72 columns Git commits: - Tests should be committed first, before implementation -- One commit per file - each new file gets its own commit -- Make commits SMALL and FREQUENT - every meaningful change should be a commit +- Group related changes together in a single commit (e.g., a new class + plus its registration, or a getter plus the property it exposes). + Avoid mixing unrelated concerns in one commit. +- Keep commits small and focused — prefer many small commits over few + large ones, but don't artificially split a single logical change + across multiple commits - Commits are for reviewing and documenting the development of code -- A commit can be as simple as adding one import, one getter, one property, etc. -- Don't wait to commit - commit as you go +- Don't wait to commit — commit as you go - Run `php-cs-fixer fix` on worked on directories before committing Branch naming: diff --git a/ai/frontend_prompt_template.md b/ai/frontend_prompt_template.md index 24eb7c3..80852e2 100644 --- a/ai/frontend_prompt_template.md +++ b/ai/frontend_prompt_template.md @@ -22,18 +22,24 @@ Code patterns to follow: - Variable names: use explicit, descriptive names — never single-letter or abbreviated variables (e.g., use sponsorship not s, event not e) Git commit style: -- Present tense, imperative mood (add, create, test, fix) -- Lowercase -- Short (3-6 words) -- Match patterns found in git history +- Subject: present tense, imperative mood (add, create, test, fix) +- Subject: lowercase, short (3-6 words) +- Match subject patterns found in git history +- Add a body when the change needs explanation beyond the subject — + e.g., why the change was made, non-obvious tradeoffs, or notable + implementation details. Skip the body for trivial/self-evident commits. +- Separate subject and body with a blank line; wrap body at ~72 columns Git commits: - Tests should be committed first, before implementation -- One commit per file - each new file gets its own commit -- Make commits SMALL and FREQUENT - every meaningful change should be a commit +- Group related changes together in a single commit (e.g., a new class + plus its registration, or a getter plus the property it exposes). + Avoid mixing unrelated concerns in one commit. +- Keep commits small and focused — prefer many small commits over few + large ones, but don't artificially split a single logical change + across multiple commits - Commits are for reviewing and documenting the development of code -- A commit can be as simple as adding one import, one getter, one property, etc. -- Don't wait to commit - commit as you go +- Don't wait to commit — commit as you go Branch naming: - Use kebab-case (e.g., node-page text-page) From 099883a13d259623c9805f8fc12b5a7e84c773e1 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 26 Apr 2026 10:15:03 +0300 Subject: [PATCH 80/86] change em dashes to hyphen and add rule outlawing emdashes --- ai/backend_prompt_template.md | 15 +++++++++------ ai/frontend_prompt_template.md | 13 ++++++++----- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/ai/backend_prompt_template.md b/ai/backend_prompt_template.md index 5b9cf44..c8d0edf 100644 --- a/ai/backend_prompt_template.md +++ b/ai/backend_prompt_template.md @@ -26,16 +26,19 @@ Code patterns to follow: - Look at tests/Fakes/ for examples - Find/lookup methods must return a new instance of the entity, not the stored reference - Tests: follow existing patterns in tests/Unit/[Entity]/UseCases/ - - In setUp, only use fake repositories for entities under test — construct dependency objects directly with `new` (e.g., `new Text(....)`) instead of creating them through their fake repositories -- Lines should not exceed 80 columns, but should use up to 80 columns when possible — do not split lines unnecessarily + - In setUp, only use fake repositories for entities under test - construct dependency objects directly with `new` (e.g., `new Text(....)`) instead of creating them through their fake repositories +- Lines should not exceed 80 columns, but should use up to 80 columns when possible - do not split lines unnecessarily - Imports: always put use statements at the top of the file, never use inline imports (e.g., \App\Foo\Bar::class) -- Variable names: use explicit, descriptive names — never single-letter or abbreviated variables (e.g., use $sponsorship not $s, $event not $e) +- Variable names: use explicit, descriptive names - never single-letter or abbreviated variables (e.g., use $sponsorship not $s, $event not $e) +- Never use em-dashes (—) in code, comments, commit messages, or any + written output. Use a regular hyphen (-), a colon, or rephrase + with parentheses instead. Git commit style: - Subject: present tense, imperative mood (add, create, test, fix) - Subject: lowercase, short (3-6 words) - Match subject patterns found in git history -- Add a body when the change needs explanation beyond the subject — +- Add a body when the change needs explanation beyond the subject - e.g., why the change was made, non-obvious tradeoffs, or notable implementation details. Skip the body for trivial/self-evident commits. - Separate subject and body with a blank line; wrap body at ~72 columns @@ -45,11 +48,11 @@ Git commits: - Group related changes together in a single commit (e.g., a new class plus its registration, or a getter plus the property it exposes). Avoid mixing unrelated concerns in one commit. -- Keep commits small and focused — prefer many small commits over few +- Keep commits small and focused - prefer many small commits over few large ones, but don't artificially split a single logical change across multiple commits - Commits are for reviewing and documenting the development of code -- Don't wait to commit — commit as you go +- Don't wait to commit - commit as you go - Run `php-cs-fixer fix` on worked on directories before committing Branch naming: diff --git a/ai/frontend_prompt_template.md b/ai/frontend_prompt_template.md index 80852e2..a865577 100644 --- a/ai/frontend_prompt_template.md +++ b/ai/frontend_prompt_template.md @@ -17,15 +17,18 @@ Code patterns to follow: - First, explore the codebase to understand existing entity patterns - Look at similar pages for reference - Tests: follow existing patterns in cypress/e2e/ -- Lines should not exceed 80 columns, but should use up to 80 columns when possible — do not split lines unnecessarily +- Lines should not exceed 80 columns, but should use up to 80 columns when possible - do not split lines unnecessarily - Imports: always put imports at the top of the file -- Variable names: use explicit, descriptive names — never single-letter or abbreviated variables (e.g., use sponsorship not s, event not e) +- Variable names: use explicit, descriptive names - never single-letter or abbreviated variables (e.g., use sponsorship not s, event not e) +- Never use em-dashes (—) in code, comments, commit messages, or any + written output. Use a regular hyphen (-), a colon, or rephrase + with parentheses instead. Git commit style: - Subject: present tense, imperative mood (add, create, test, fix) - Subject: lowercase, short (3-6 words) - Match subject patterns found in git history -- Add a body when the change needs explanation beyond the subject — +- Add a body when the change needs explanation beyond the subject - e.g., why the change was made, non-obvious tradeoffs, or notable implementation details. Skip the body for trivial/self-evident commits. - Separate subject and body with a blank line; wrap body at ~72 columns @@ -35,11 +38,11 @@ Git commits: - Group related changes together in a single commit (e.g., a new class plus its registration, or a getter plus the property it exposes). Avoid mixing unrelated concerns in one commit. -- Keep commits small and focused — prefer many small commits over few +- Keep commits small and focused - prefer many small commits over few large ones, but don't artificially split a single logical change across multiple commits - Commits are for reviewing and documenting the development of code -- Don't wait to commit — commit as you go +- Don't wait to commit - commit as you go Branch naming: - Use kebab-case (e.g., node-page text-page) From a65c9259faffeb0fda5090a7314e27efad412f94 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 26 Apr 2026 10:19:38 +0300 Subject: [PATCH 81/86] dont cast email to string, use new value method --- app/Auth/AuthController.php | 2 +- app/User/JsonUserRepository.php | 4 ++-- app/ValueObjects/EmailAddress.php | 5 +++++ tests/Fakes/FakeUserRepository.php | 2 +- tests/Unit/Auth/Middleware/AuthMiddlewareTest.php | 2 +- tests/Unit/User/FakeUserRepositoryTest.php | 2 +- tests/Unit/User/UseCases/AuthenticateUserTest.php | 2 +- 7 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/Auth/AuthController.php b/app/Auth/AuthController.php index 9dcc342..0864730 100644 --- a/app/Auth/AuthController.php +++ b/app/Auth/AuthController.php @@ -126,7 +126,7 @@ class AuthController $response->getBody()->write(json_encode([ 'user' => [ 'id' => $user->getId(), - 'email' => (string) $user->getEmail(), + 'email' => $user->getEmail()->value(), 'isAdmin' => $user->isAdmin(), ], ])); diff --git a/app/User/JsonUserRepository.php b/app/User/JsonUserRepository.php index 954ec48..d5de3a2 100644 --- a/app/User/JsonUserRepository.php +++ b/app/User/JsonUserRepository.php @@ -21,7 +21,7 @@ class JsonUserRepository implements UserRepository $users[] = [ 'id' => $id, - 'email' => (string) $dto->email, + 'email' => $dto->email->value(), 'passwordHash' => $dto->passwordHash, 'isAdmin' => $dto->isAdmin, ]; @@ -53,7 +53,7 @@ class JsonUserRepository implements UserRepository $users = $this->readUsers(); foreach ($users as $data) { - if ($data['email'] === (string) $email) { + if ($data['email'] === $email->value()) { return $this->hydrate($data); } } diff --git a/app/ValueObjects/EmailAddress.php b/app/ValueObjects/EmailAddress.php index 6952ae9..e646f4c 100644 --- a/app/ValueObjects/EmailAddress.php +++ b/app/ValueObjects/EmailAddress.php @@ -11,6 +11,11 @@ class EmailAddress $this->normalized = $email; } + public function value(): string + { + return $this->normalized; + } + public function __toString(): string { return $this->normalized; diff --git a/tests/Fakes/FakeUserRepository.php b/tests/Fakes/FakeUserRepository.php index 2538213..b2e766a 100644 --- a/tests/Fakes/FakeUserRepository.php +++ b/tests/Fakes/FakeUserRepository.php @@ -39,7 +39,7 @@ class FakeUserRepository implements UserRepository $user = array_find( $this->existingUsers, function (User $user) use ($email) { - return (string) $user->getEmail() === (string) $email; + return $user->getEmail()->value() === $email->value(); } ); if ($user === null) { diff --git a/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php index c79e6a6..5953a13 100644 --- a/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php +++ b/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -144,7 +144,7 @@ class AuthMiddlewareTest extends TestCase $this->assertInstanceOf(User::class, $attached); $this->assertEquals( 'test@test.com', - (string) $attached->getEmail() + $attached->getEmail()->value() ); } diff --git a/tests/Unit/User/FakeUserRepositoryTest.php b/tests/Unit/User/FakeUserRepositoryTest.php index 6d583a5..4df07f0 100644 --- a/tests/Unit/User/FakeUserRepositoryTest.php +++ b/tests/Unit/User/FakeUserRepositoryTest.php @@ -21,7 +21,7 @@ class FakeUserRepositoryTest extends TestCase $user = $userRepo->findByEmail(new EmailAddress('test@test.com')); $this->assertInstanceOf(User::class, $user); - $this->assertEquals('test@test.com', (string) $user->getEmail()); + $this->assertEquals('test@test.com', $user->getEmail()->value()); } public function test_find_by_email_returns_null_when_not_found(): void diff --git a/tests/Unit/User/UseCases/AuthenticateUserTest.php b/tests/Unit/User/UseCases/AuthenticateUserTest.php index 1ef35bf..822eda3 100644 --- a/tests/Unit/User/UseCases/AuthenticateUserTest.php +++ b/tests/Unit/User/UseCases/AuthenticateUserTest.php @@ -45,7 +45,7 @@ class AuthenticateUserTest extends TestCase )); $this->assertInstanceOf(User::class, $user); - $this->assertEquals('test@test.com', (string) $user->getEmail()); + $this->assertEquals('test@test.com', $user->getEmail()->value()); } public function test_throws_bad_request_when_email_null(): void From 2fe41a5fe7006fd41bda4646a4184c047cb66407 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 26 Apr 2026 10:23:57 +0300 Subject: [PATCH 82/86] no need to test concrete implementations --- ai/backend_prompt_template.md | 3 + tests/Unit/Auth/FakeSessionRepositoryTest.php | 68 ------------------- tests/Unit/User/FakeUserRepositoryTest.php | 52 -------------- 3 files changed, 3 insertions(+), 120 deletions(-) delete mode 100644 tests/Unit/Auth/FakeSessionRepositoryTest.php delete mode 100644 tests/Unit/User/FakeUserRepositoryTest.php diff --git a/ai/backend_prompt_template.md b/ai/backend_prompt_template.md index c8d0edf..c16ef26 100644 --- a/ai/backend_prompt_template.md +++ b/ai/backend_prompt_template.md @@ -20,6 +20,9 @@ Code patterns to follow: - Entities: constructor with properties, getters - DTOs: simple data containers for creation - Repositories: interfaces that define data access + - Do not write unit tests for concrete repository implementations + (e.g., Doctrine/persistence-backed). They are exercised by e2e + tests. Use cases are tested with fake repositories. - Use cases: business logic with Request objects - When throwing exceptions, add @throws docblock - Fakes: in-memory implementations for testing diff --git a/tests/Unit/Auth/FakeSessionRepositoryTest.php b/tests/Unit/Auth/FakeSessionRepositoryTest.php deleted file mode 100644 index 6e2bce4..0000000 --- a/tests/Unit/Auth/FakeSessionRepositoryTest.php +++ /dev/null @@ -1,68 +0,0 @@ -sessionRepo = new FakeSessionRepository(); - } - - public function test_create_and_find_by_token(): void - { - $this->sessionRepo->create(new CreateSessionDto( - token: 'abc123', - userId: 0, - createdAt: new DateTimeImmutable('2025-01-01'), - expiresAt: new DateTimeImmutable('2025-01-08'), - )); - - $session = $this->sessionRepo->findByToken('abc123'); - - $this->assertInstanceOf(Session::class, $session); - $this->assertEquals('abc123', $session->getToken()); - $this->assertEquals(0, $session->getUserId()); - } - - public function test_find_by_token_returns_null_when_missing(): void - { - $this->assertNull($this->sessionRepo->findByToken('nope')); - } - - public function test_find_by_token_returns_fresh_instance(): void - { - $created = $this->sessionRepo->create(new CreateSessionDto( - token: 'abc123', - userId: 0, - createdAt: new DateTimeImmutable('2025-01-01'), - expiresAt: new DateTimeImmutable('2025-01-08'), - )); - - $fetched = $this->sessionRepo->findByToken('abc123'); - - $this->assertNotSame($created, $fetched); - } - - public function test_delete_by_token(): void - { - $this->sessionRepo->create(new CreateSessionDto( - token: 'abc123', - userId: 0, - createdAt: new DateTimeImmutable('2025-01-01'), - expiresAt: new DateTimeImmutable('2025-01-08'), - )); - - $this->sessionRepo->deleteByToken('abc123'); - - $this->assertNull($this->sessionRepo->findByToken('abc123')); - } -} diff --git a/tests/Unit/User/FakeUserRepositoryTest.php b/tests/Unit/User/FakeUserRepositoryTest.php deleted file mode 100644 index 4df07f0..0000000 --- a/tests/Unit/User/FakeUserRepositoryTest.php +++ /dev/null @@ -1,52 +0,0 @@ -create(new CreateUserDto( - email: new EmailAddress('test@test.com'), - passwordHash: '', - )); - - $user = $userRepo->findByEmail(new EmailAddress('test@test.com')); - - $this->assertInstanceOf(User::class, $user); - $this->assertEquals('test@test.com', $user->getEmail()->value()); - } - - public function test_find_by_email_returns_null_when_not_found(): void - { - $userRepo = new FakeUserRepository(); - - $user = $userRepo->findByEmail( - new EmailAddress('missing@test.com') - ); - - $this->assertNull($user); - } - - public function test_find_by_email_returns_fresh_instance(): void - { - $userRepo = new FakeUserRepository(); - $created = $userRepo->create(new CreateUserDto( - email: new EmailAddress('test@test.com'), - passwordHash: '', - )); - - $fetched = $userRepo->findByEmail( - new EmailAddress('test@test.com') - ); - - $this->assertNotSame($created, $fetched); - } -} From 13da7c311aa6f78915f5b1326fb23db8b539f64a Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 26 Apr 2026 10:32:08 +0300 Subject: [PATCH 83/86] return utc from clock --- app/Auth/Clock.php | 3 +++ app/Auth/SystemClock.php | 3 ++- tests/Fakes/FakeClock.php | 15 ++++++++++++++- tests/Unit/Auth/Middleware/AuthMiddlewareTest.php | 10 +++++----- tests/e2e/Controllers/AuthControllerTest.php | 4 ++-- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/app/Auth/Clock.php b/app/Auth/Clock.php index b96ad32..ad89957 100644 --- a/app/Auth/Clock.php +++ b/app/Auth/Clock.php @@ -6,5 +6,8 @@ use DateTimeImmutable; interface Clock { + /** + * Returns the current time in UTC. + */ public function now(): DateTimeImmutable; } diff --git a/app/Auth/SystemClock.php b/app/Auth/SystemClock.php index 4e845b6..dd77c28 100644 --- a/app/Auth/SystemClock.php +++ b/app/Auth/SystemClock.php @@ -3,11 +3,12 @@ namespace App\Auth; use DateTimeImmutable; +use DateTimeZone; class SystemClock implements Clock { public function now(): DateTimeImmutable { - return new DateTimeImmutable(); + return new DateTimeImmutable('now', new DateTimeZone('UTC')); } } diff --git a/tests/Fakes/FakeClock.php b/tests/Fakes/FakeClock.php index bf7a7b6..31e649b 100644 --- a/tests/Fakes/FakeClock.php +++ b/tests/Fakes/FakeClock.php @@ -4,12 +4,15 @@ namespace Tests\Fakes; use App\Auth\Clock; use DateTimeImmutable; +use InvalidArgumentException; class FakeClock implements Clock { public function __construct( private DateTimeImmutable $currentTime, - ) {} + ) { + $this->assertUtc($currentTime); + } public function now(): DateTimeImmutable { @@ -18,6 +21,16 @@ class FakeClock implements Clock public function setTime(DateTimeImmutable $newTime): void { + $this->assertUtc($newTime); $this->currentTime = $newTime; } + + private function assertUtc(DateTimeImmutable $time): void + { + if ($time->getTimezone()->getOffset($time) !== 0) { + throw new InvalidArgumentException( + 'FakeClock requires a DateTimeImmutable in UTC.' + ); + } + } } diff --git a/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php b/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php index 5953a13..8408384 100644 --- a/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php +++ b/tests/Unit/Auth/Middleware/AuthMiddlewareTest.php @@ -72,7 +72,7 @@ class AuthMiddlewareTest extends TestCase private function makeHandler(): RequestHandlerInterface { - return new class() implements RequestHandlerInterface { + return new class implements RequestHandlerInterface { public ?ServerRequestInterface $capturedRequest = null; public function handle( @@ -113,8 +113,8 @@ class AuthMiddlewareTest extends TestCase $this->sessionRepo->create(new CreateSessionDto( token: 'expired-token', userId: $this->user->getId(), - createdAt: new DateTimeImmutable('2024-12-01'), - expiresAt: new DateTimeImmutable('2024-12-08'), + createdAt: new DateTimeImmutable('2024-12-01T00:00:00+00:00'), + expiresAt: new DateTimeImmutable('2024-12-08T00:00:00+00:00'), )); $response = $this->middleware->process( @@ -130,8 +130,8 @@ class AuthMiddlewareTest extends TestCase $this->sessionRepo->create(new CreateSessionDto( token: 'valid-token', userId: $this->user->getId(), - createdAt: new DateTimeImmutable('2025-01-01'), - expiresAt: new DateTimeImmutable('2025-01-08'), + createdAt: new DateTimeImmutable('2025-01-01T00:00:00+00:00'), + expiresAt: new DateTimeImmutable('2025-01-08T00:00:00+00:00'), )); $handler = $this->makeHandler(); diff --git a/tests/e2e/Controllers/AuthControllerTest.php b/tests/e2e/Controllers/AuthControllerTest.php index 58b209d..19c5770 100644 --- a/tests/e2e/Controllers/AuthControllerTest.php +++ b/tests/e2e/Controllers/AuthControllerTest.php @@ -244,8 +244,8 @@ class AuthControllerTest extends TestCase $this->sessionRepo->create(new CreateSessionDto( token: 'existing-session', userId: 0, - createdAt: new DateTimeImmutable('2025-01-01'), - expiresAt: new DateTimeImmutable('2025-01-08'), + createdAt: new DateTimeImmutable('2025-01-01T00:00:00+00:00'), + expiresAt: new DateTimeImmutable('2025-01-08T00:00:00+00:00'), )); $request = $this->makeJsonRequest( From f95adddaaffe5bcc04d9571addc9e6703827b029 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 26 Apr 2026 10:45:57 +0300 Subject: [PATCH 84/86] fix code style in test files --- tests/Unit/Auth/Middleware/AdminMiddlewareTest.php | 2 +- tests/Unit/Plan/UseCases/CreatePlanTest.php | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/Unit/Auth/Middleware/AdminMiddlewareTest.php b/tests/Unit/Auth/Middleware/AdminMiddlewareTest.php index 7ff5d88..54eecd3 100644 --- a/tests/Unit/Auth/Middleware/AdminMiddlewareTest.php +++ b/tests/Unit/Auth/Middleware/AdminMiddlewareTest.php @@ -43,7 +43,7 @@ class AdminMiddlewareTest extends TestCase private function makeHandler(): RequestHandlerInterface { - return new class() implements RequestHandlerInterface { + return new class implements RequestHandlerInterface { public bool $wasCalled = false; public function handle( diff --git a/tests/Unit/Plan/UseCases/CreatePlanTest.php b/tests/Unit/Plan/UseCases/CreatePlanTest.php index f64c0be..f88bc89 100644 --- a/tests/Unit/Plan/UseCases/CreatePlanTest.php +++ b/tests/Unit/Plan/UseCases/CreatePlanTest.php @@ -233,8 +233,8 @@ class CreatePlanTest extends TestCase )); } - public function test_scheduled_nodes_are_scheduled_on_different_days(): void - { + public function test_scheduled_nodes_are_scheduled_on_different_days(): void + { $text = $this->textRepo->find(0); $rootNode = $this->nodeRepo->create(new CreateNodeDto( text: $text, @@ -270,10 +270,10 @@ class CreatePlanTest extends TestCase new DateTimeImmutable('2025-01-02'), $childTwo->getDate() ); - } + } - public function test_more_scheduled_nodes_than_days(): void - { + public function test_more_scheduled_nodes_than_days(): void + { $text = $this->textRepo->find(0); $rootNode = $this->nodeRepo->create(new CreateNodeDto( text: $text, @@ -320,5 +320,5 @@ class CreatePlanTest extends TestCase new DateTimeImmutable('2025-01-02'), $childThree->getDate() ); - } + } } From cd40483cd43c04242816ed7121b283caa17ce724 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 26 Apr 2026 10:46:07 +0300 Subject: [PATCH 85/86] remove default values from user constructors Forcing every call site to be explicit about admin status and password eliminates a class of bugs where an unintended isAdmin=false or empty passwordHash could silently slip through. The CreateUserTest case that asserted the isAdmin default is dropped since the default no longer exists. --- app/User/UseCases/CreateUserRequest.php | 4 ++-- app/User/User.php | 4 ++-- tests/Unit/Auth/UseCases/CreateSessionTest.php | 2 ++ .../UseCases/CreateScheduledNodeTest.php | 7 ++++++- .../Unit/User/UseCases/AuthenticateUserTest.php | 1 + tests/Unit/User/UseCases/CreateUserTest.php | 16 +++++++--------- tests/e2e/Controllers/AuthControllerTest.php | 1 + 7 files changed, 21 insertions(+), 14 deletions(-) diff --git a/app/User/UseCases/CreateUserRequest.php b/app/User/UseCases/CreateUserRequest.php index 52ead72..c70ef72 100644 --- a/app/User/UseCases/CreateUserRequest.php +++ b/app/User/UseCases/CreateUserRequest.php @@ -6,7 +6,7 @@ class CreateUserRequest { public function __construct( public ?string $email, - public ?string $password = null, - public bool $isAdmin = false, + public ?string $password, + public bool $isAdmin, ) {} } diff --git a/app/User/User.php b/app/User/User.php index 9cdc469..9e8ae97 100644 --- a/app/User/User.php +++ b/app/User/User.php @@ -9,8 +9,8 @@ class User public function __construct( private int $id, private EmailAddress $email, - private string $passwordHash = '', - private bool $isAdmin = false, + private string $passwordHash, + private bool $isAdmin, ) {} public function getId(): int diff --git a/tests/Unit/Auth/UseCases/CreateSessionTest.php b/tests/Unit/Auth/UseCases/CreateSessionTest.php index f58f66f..a79eb9c 100644 --- a/tests/Unit/Auth/UseCases/CreateSessionTest.php +++ b/tests/Unit/Auth/UseCases/CreateSessionTest.php @@ -36,6 +36,8 @@ class CreateSessionTest extends TestCase $this->user = new User( id: 7, email: new EmailAddress('test@test.com'), + passwordHash: 'hashed:password1', + isAdmin: false, ); } diff --git a/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php b/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php index d2bfc2b..477a07a 100644 --- a/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php +++ b/tests/Unit/ScheduledNode/UseCases/CreateScheduledNodeTest.php @@ -30,7 +30,12 @@ class CreateScheduledNodeTest extends TestCase $this->planRepo = new FakePlanRepository(); $this->planRepo->create(new CreatePlanDto( name: 'testplan', - user: new User(0, new EmailAddress('test@test.com')), + user: new User( + id: 0, + email: new EmailAddress('test@test.com'), + passwordHash: 'hashed:password1', + isAdmin: false, + ), )); $this->useCase = new CreateScheduledNode( $this->scheduledNodeRepo, diff --git a/tests/Unit/User/UseCases/AuthenticateUserTest.php b/tests/Unit/User/UseCases/AuthenticateUserTest.php index 822eda3..88bb1a9 100644 --- a/tests/Unit/User/UseCases/AuthenticateUserTest.php +++ b/tests/Unit/User/UseCases/AuthenticateUserTest.php @@ -30,6 +30,7 @@ class AuthenticateUserTest extends TestCase $createUser->execute(new CreateUserRequest( email: 'test@test.com', password: 'password1', + isAdmin: false, )); $this->useCase = new AuthenticateUser( $this->userRepo, diff --git a/tests/Unit/User/UseCases/CreateUserTest.php b/tests/Unit/User/UseCases/CreateUserTest.php index 941c98d..0333a52 100644 --- a/tests/Unit/User/UseCases/CreateUserTest.php +++ b/tests/Unit/User/UseCases/CreateUserTest.php @@ -31,6 +31,7 @@ class CreateUserTest extends TestCase $this->useCase->execute(new CreateUserRequest( email: 'test@test.com', password: 'password1', + isAdmin: false, )); $user = $this->userRepo->find(0); $this->assertInstanceOf(User::class, $user); @@ -44,17 +45,9 @@ class CreateUserTest extends TestCase $this->useCase->execute(new CreateUserRequest( email: null, - )); - } - - public function test_is_admin_defaults_to_false(): void - { - $this->useCase->execute(new CreateUserRequest( - email: 'test@test.com', password: 'password1', + isAdmin: false, )); - $user = $this->userRepo->find(0); - $this->assertFalse($user->isAdmin()); } public function test_is_admin_can_be_set_true(): void @@ -73,6 +66,7 @@ class CreateUserTest extends TestCase $this->useCase->execute(new CreateUserRequest( email: 'test@test.com', password: 'password1', + isAdmin: false, )); $this->expectException(BadRequestException::class); @@ -81,6 +75,7 @@ class CreateUserTest extends TestCase $this->useCase->execute(new CreateUserRequest( email: 'test@test.com', password: 'password1', + isAdmin: false )); } @@ -92,6 +87,7 @@ class CreateUserTest extends TestCase $this->useCase->execute(new CreateUserRequest( email: 'test@test.com', password: null, + isAdmin: false, )); } @@ -105,6 +101,7 @@ class CreateUserTest extends TestCase $this->useCase->execute(new CreateUserRequest( email: 'test@test.com', password: 'short', + isAdmin: false, )); } @@ -113,6 +110,7 @@ class CreateUserTest extends TestCase $this->useCase->execute(new CreateUserRequest( email: 'test@test.com', password: 'password1', + isAdmin: false, )); $user = $this->userRepo->find(0); $this->assertNotEquals('password1', $user->getPasswordHash()); diff --git a/tests/e2e/Controllers/AuthControllerTest.php b/tests/e2e/Controllers/AuthControllerTest.php index 19c5770..50e39a5 100644 --- a/tests/e2e/Controllers/AuthControllerTest.php +++ b/tests/e2e/Controllers/AuthControllerTest.php @@ -63,6 +63,7 @@ class AuthControllerTest extends TestCase $this->createUser->execute(new CreateUserRequest( email: 'existing@test.com', password: 'password1', + isAdmin: false, )); $this->controller = new AuthController(); From b41652af7127c8bf10e1e0483151b649d933dc15 Mon Sep 17 00:00:00 2001 From: Yisroel Baum Date: Sun, 26 Apr 2026 10:51:03 +0300 Subject: [PATCH 86/86] remove default value in fake token generator --- tests/Fakes/FakeTokenGenerator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Fakes/FakeTokenGenerator.php b/tests/Fakes/FakeTokenGenerator.php index ffdd6d8..fbe8913 100644 --- a/tests/Fakes/FakeTokenGenerator.php +++ b/tests/Fakes/FakeTokenGenerator.php @@ -12,7 +12,7 @@ class FakeTokenGenerator implements TokenGenerator * @param string[] $predefinedTokens */ public function __construct( - private array $predefinedTokens = ['fake-token-0'], + private array $predefinedTokens, ) {} public function generate(): string