From ab06c895c74773bafce789221d455f58ec94b281 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Fri, 19 Jun 2026 08:54:11 +0200 Subject: [PATCH 1/4] feat(user): add name and status fields per ADR 006 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lands the User-entity work of ADR 006 — a `UserStatus` PHP enum (`pending | approved | blocked`) and a matching `status` column on the entity, plus the long-pending `name` display column. The `UserManager::createUser()` service grows a required `name` argument and an optional `status` (defaults to `Approved` for the console / fixture path; the registration flow in #62 will pass `Pending`). The `app:user:create` console command grows a third `name` argument; fixtures seed Alice + Bob with display names. A single migration adds both columns with a default value so existing rows in long-running environments backfill cleanly: `name = ''` and `status = 'approved'` (historic rows are assumed already-trusted accounts). Combines #45 (User.name) and #83 (UserStatus enum + status field) in one PR, since the migration is most coherent as a single ALTER TABLE and the entity tests fight if either field lands without the other. Sequenced as PR 1 of the User management milestone plan (`docs/plans/user-management-milestone.md`, written locally) — PR 3 (UserChecker, #63) and PR 4 (Registration, #62) stack on top of this branch with `do-not-merge` labels until it merges. Closes #45. Closes #83. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 12 ++++++ migrations/Version20260619080000.php | 42 +++++++++++++++++++ src/Command/UserCreateCommand.php | 4 +- src/DataFixtures/UserFixtures.php | 4 +- src/Entity/User.php | 32 ++++++++++++++ src/Enum/UserStatus.php | 19 +++++++++ src/Security/UserManager.php | 21 ++++++++-- .../Command/UserCreateCommandTest.php | 2 + .../Repository/UserRepositoryTest.php | 23 ++++++++++ .../Integration/Security/UserManagerTest.php | 23 ++++++++-- tests/Unit/DataFixtures/UserFixturesTest.php | 15 +++++-- tests/Unit/Entity/UserTest.php | 16 +++++++ 12 files changed, 199 insertions(+), 14 deletions(-) create mode 100644 migrations/Version20260619080000.php create mode 100644 src/Enum/UserStatus.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ccda751..dfc670b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `User.name` (display name) and `User.status` (lifecycle enum: + `pending | approved | blocked`) per ADR 006, plus the + `App\Enum\UserStatus` PHP enum. `UserManager::createUser()` now + requires `name` and accepts an optional `status` (default + `Approved` for the console / fixture path; the registration flow + in #62 will pass `Pending`). The `app:user:create` console command + takes a third `name` argument; fixtures seed Alice + Bob with + display names. Schema is added via a single migration that + backfills any existing rows with `name = ''` and + `status = 'approved'` + ([#45](https://github.com/itk-dev/ai-lib/issues/45), + [#83](https://github.com/itk-dev/ai-lib/issues/83)). - Initial Symfony 8 application scaffold on the ITK Dev Docker `symfony-8` template (phpfpm 8.4, nginx, MariaDB, Mailpit, Traefik), including dev dependencies for coding standards (`php-cs-fixer`, diff --git a/migrations/Version20260619080000.php b/migrations/Version20260619080000.php new file mode 100644 index 0000000..611a99e --- /dev/null +++ b/migrations/Version20260619080000.php @@ -0,0 +1,42 @@ +getTable('user'); + $user->addColumn('name', Types::STRING, ['length' => 255, 'notnull' => true, 'default' => '']); + $user->addColumn('status', Types::STRING, ['length' => 32, 'notnull' => true, 'default' => 'approved']); + } + + public function down(Schema $schema): void + { + $user = $schema->getTable('user'); + $user->dropColumn('status'); + $user->dropColumn('name'); + } +} diff --git a/src/Command/UserCreateCommand.php b/src/Command/UserCreateCommand.php index c9d642b..e1c7591 100644 --- a/src/Command/UserCreateCommand.php +++ b/src/Command/UserCreateCommand.php @@ -38,6 +38,7 @@ protected function configure(): void { $this ->addArgument('email', InputArgument::REQUIRED, 'The user\'s e-mail address (must be unique).') + ->addArgument('name', InputArgument::REQUIRED, 'The user\'s display name.') ->addArgument('password', InputArgument::REQUIRED, 'The user\'s password in clear-text — will be hashed.'); } @@ -53,10 +54,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $email = (string) $input->getArgument('email'); + $name = (string) $input->getArgument('name'); $password = (string) $input->getArgument('password'); try { - $user = $this->userManager->createUser($email, $password); + $user = $this->userManager->createUser($email, $name, $password); } catch (\DomainException|\InvalidArgumentException $e) { $io->error($e->getMessage()); diff --git a/src/DataFixtures/UserFixtures.php b/src/DataFixtures/UserFixtures.php index 67e4dd7..c713ed1 100644 --- a/src/DataFixtures/UserFixtures.php +++ b/src/DataFixtures/UserFixtures.php @@ -31,7 +31,7 @@ public function __construct(private readonly UserManager $userManager) */ public function load(ObjectManager $manager): void { - $this->userManager->createUser('alice@example.test', 'password'); - $this->userManager->createUser('bob@example.test', 'password'); + $this->userManager->createUser('alice@example.test', 'Alice', 'password'); + $this->userManager->createUser('bob@example.test', 'Bob', 'password'); } } diff --git a/src/Entity/User.php b/src/Entity/User.php index 8fee99a..6315ef2 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -2,7 +2,9 @@ namespace App\Entity; +use App\Enum\UserStatus; use App\Repository\UserRepository; +use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; @@ -32,6 +34,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface #[ORM\Column] private ?string $password = null; + #[ORM\Column(length: 255)] + private string $name = ''; + + #[ORM\Column(type: Types::STRING, length: 32, enumType: UserStatus::class)] + private UserStatus $status = UserStatus::Pending; + public function getId(): ?int { return $this->id; @@ -96,6 +104,30 @@ public function setPassword(string $password): static return $this; } + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getStatus(): UserStatus + { + return $this->status; + } + + public function setStatus(UserStatus $status): static + { + $this->status = $status; + + return $this; + } + /** * Ensure the session doesn't contain actual password hashes by CRC32C-hashing them, as supported since Symfony 7.3. */ diff --git a/src/Enum/UserStatus.php b/src/Enum/UserStatus.php new file mode 100644 index 0000000..ad96874 --- /dev/null +++ b/src/Enum/UserStatus.php @@ -0,0 +1,19 @@ + $roles additional roles beyond the implicit `ROLE_USER` + * @param UserStatus $status identity-lifecycle status; defaults to {@see UserStatus::Approved} * * @return User the persisted user with an assigned id * * @throws \DomainException when a user with the same e-mail already exists * @throws \InvalidArgumentException when `$plainPassword` is empty */ - public function createUser(string $email, string $plainPassword, array $roles = []): User - { + public function createUser( + string $email, + string $name, + string $plainPassword, + array $roles = [], + UserStatus $status = UserStatus::Approved, + ): User { if ('' === $plainPassword) { throw new \InvalidArgumentException('Password must not be empty.'); } @@ -54,7 +67,9 @@ public function createUser(string $email, string $plainPassword, array $roles = $user = (new User()) ->setEmail($email) - ->setRoles($roles); + ->setName($name) + ->setRoles($roles) + ->setStatus($status); $user->setPassword($this->passwordHasher->hashPassword($user, $plainPassword)); $this->entityManager->persist($user); diff --git a/tests/Integration/Command/UserCreateCommandTest.php b/tests/Integration/Command/UserCreateCommandTest.php index 880756b..39c27f7 100644 --- a/tests/Integration/Command/UserCreateCommandTest.php +++ b/tests/Integration/Command/UserCreateCommandTest.php @@ -30,6 +30,7 @@ public function testCreatesUser(): void { $exit = $this->tester->execute([ 'email' => 'charlie@example.test', + 'name' => 'Charlie', 'password' => 'secret', ]); @@ -42,6 +43,7 @@ public function testReportsFailureWhenEmailAlreadyExists(): void // alice@example.test is in the baseline fixtures. $exit = $this->tester->execute([ 'email' => 'alice@example.test', + 'name' => 'Alice', 'password' => 'second', ]); diff --git a/tests/Integration/Repository/UserRepositoryTest.php b/tests/Integration/Repository/UserRepositoryTest.php index 239fa3d..bcc6742 100644 --- a/tests/Integration/Repository/UserRepositoryTest.php +++ b/tests/Integration/Repository/UserRepositoryTest.php @@ -4,7 +4,10 @@ namespace App\Tests\Integration\Repository; +use App\Entity\User; +use App\Enum\UserStatus; use App\Repository\UserRepository; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\Security\Core\Exception\UnsupportedUserException; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; @@ -62,4 +65,24 @@ public function getPassword(): ?string $this->repository->upgradePassword($foreignUser, 'irrelevant'); } + + public function testStatusEnumRoundTripsThroughThePersistedRow(): void + { + $em = self::getContainer()->get(EntityManagerInterface::class); + + $user = (new User()) + ->setEmail('eve@example.test') + ->setName('Eve') + ->setPassword('hash') + ->setStatus(UserStatus::Blocked); + $em->persist($user); + $em->flush(); + $em->clear(); + + $reloaded = $this->repository->find($user->getId()); + + self::assertNotNull($reloaded); + self::assertSame('Eve', $reloaded->getName()); + self::assertSame(UserStatus::Blocked, $reloaded->getStatus()); + } } diff --git a/tests/Integration/Security/UserManagerTest.php b/tests/Integration/Security/UserManagerTest.php index 56b150a..87eeecb 100644 --- a/tests/Integration/Security/UserManagerTest.php +++ b/tests/Integration/Security/UserManagerTest.php @@ -4,6 +4,7 @@ namespace App\Tests\Integration\Security; +use App\Enum\UserStatus; use App\Repository\UserRepository; use App\Security\UserManager; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; @@ -35,10 +36,12 @@ protected function setUp(): void public function testCreatesAndPersistsUserWithHashedPassword(): void { - $user = $this->userManager->createUser('charlie@example.test', 'secret'); + $user = $this->userManager->createUser('charlie@example.test', 'Charlie', 'secret'); self::assertNotNull($user->getId()); self::assertSame('charlie@example.test', $user->getEmail()); + self::assertSame('Charlie', $user->getName()); + self::assertSame(UserStatus::Approved, $user->getStatus()); self::assertSame(['ROLE_USER'], $user->getRoles()); self::assertNotSame('secret', $user->getPassword(), 'Password must be hashed.'); self::assertTrue( @@ -50,18 +53,30 @@ public function testCreatesAndPersistsUserWithHashedPassword(): void public function testCreateUserStoresExtraRoles(): void { - $user = $this->userManager->createUser('admin@example.test', 'secret', ['ROLE_ADMIN']); + $user = $this->userManager->createUser('admin@example.test', 'Admin', 'secret', ['ROLE_ADMIN']); self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], $user->getRoles()); } + public function testCreateUserHonoursExplicitStatus(): void + { + $user = $this->userManager->createUser( + 'dora@example.test', + 'Dora', + 'secret', + status: UserStatus::Pending, + ); + + self::assertSame(UserStatus::Pending, $user->getStatus()); + } + public function testCreateUserRejectsDuplicateEmail(): void { // alice@example.test is loaded by UserFixtures in the bootstrap. $this->expectException(\DomainException::class); $this->expectExceptionMessage('alice@example.test'); - $this->userManager->createUser('alice@example.test', 'other'); + $this->userManager->createUser('alice@example.test', 'Alice', 'other'); } public function testCreateUserRejectsEmptyPassword(): void @@ -69,7 +84,7 @@ public function testCreateUserRejectsEmptyPassword(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Password must not be empty.'); - $this->userManager->createUser('charlie@example.test', ''); + $this->userManager->createUser('charlie@example.test', 'Charlie', ''); } public function testChangePasswordReplacesTheHash(): void diff --git a/tests/Unit/DataFixtures/UserFixturesTest.php b/tests/Unit/DataFixtures/UserFixturesTest.php index 7a6dd7e..1ff2465 100644 --- a/tests/Unit/DataFixtures/UserFixturesTest.php +++ b/tests/Unit/DataFixtures/UserFixturesTest.php @@ -6,6 +6,7 @@ use App\DataFixtures\UserFixtures; use App\Entity\User; +use App\Enum\UserStatus; use App\Repository\UserRepository; use App\Security\UserManager; use Doctrine\ORM\EntityManagerInterface; @@ -30,12 +31,12 @@ public function testLoadPersistsAliceAndBobWithFixturePassword(): void $userRepository->method('findOneBy')->willReturn(null); $passwordHasher->method('hashPassword')->willReturn('hashed'); - $persistedEmails = []; + $persisted = []; $entityManager->expects(self::exactly(2)) ->method('persist') - ->willReturnCallback(function (object $entity) use (&$persistedEmails): void { + ->willReturnCallback(function (object $entity) use (&$persisted): void { \assert($entity instanceof User); - $persistedEmails[] = $entity->getEmail(); + $persisted[] = $entity; }); $entityManager->expects(self::exactly(2))->method('flush'); @@ -44,6 +45,12 @@ public function testLoadPersistsAliceAndBobWithFixturePassword(): void $fixture->load($this->createMock(ObjectManager::class)); - self::assertSame(['alice@example.test', 'bob@example.test'], $persistedEmails); + $emails = array_map(static fn (User $u): ?string => $u->getEmail(), $persisted); + $names = array_map(static fn (User $u): string => $u->getName(), $persisted); + $statuses = array_map(static fn (User $u): UserStatus => $u->getStatus(), $persisted); + + self::assertSame(['alice@example.test', 'bob@example.test'], $emails); + self::assertSame(['Alice', 'Bob'], $names); + self::assertSame([UserStatus::Approved, UserStatus::Approved], $statuses); } } diff --git a/tests/Unit/Entity/UserTest.php b/tests/Unit/Entity/UserTest.php index c549749..0eef22f 100644 --- a/tests/Unit/Entity/UserTest.php +++ b/tests/Unit/Entity/UserTest.php @@ -5,10 +5,20 @@ namespace App\Tests\Unit\Entity; use App\Entity\User; +use App\Enum\UserStatus; use PHPUnit\Framework\TestCase; final class UserTest extends TestCase { + public function testConstructorDefaultsStatusToPending(): void + { + $user = new User(); + + self::assertSame(UserStatus::Pending, $user->getStatus()); + self::assertSame('', $user->getName()); + } + + public function testGetRolesAlwaysIncludesRoleUser(): void { $user = new User(); @@ -69,6 +79,12 @@ public function testSettersMutateAndReturnStatic(): void self::assertSame($user, $user->setRoles(['ROLE_EDITOR'])); self::assertSame(['ROLE_EDITOR', 'ROLE_USER'], $user->getRoles()); + self::assertSame($user, $user->setName('Bob')); + self::assertSame('Bob', $user->getName()); + + self::assertSame($user, $user->setStatus(UserStatus::Approved)); + self::assertSame(UserStatus::Approved, $user->getStatus()); + self::assertNull($user->getId()); } } From 09b31a73ab8c3e12fb89941673c88f90ccad2ad7 Mon Sep 17 00:00:00 2001 From: Martin Yde Granath Date: Fri, 19 Jun 2026 11:04:01 +0200 Subject: [PATCH 2/4] Refine comment for user columns migration Updated migration comment to remove ADR reference. --- migrations/Version20260619080000.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/Version20260619080000.php b/migrations/Version20260619080000.php index 611a99e..7edae34 100644 --- a/migrations/Version20260619080000.php +++ b/migrations/Version20260619080000.php @@ -9,7 +9,7 @@ use Doctrine\Migrations\AbstractMigration; /** - * Add `name` and `status` columns to `user` per ADR 006. + * Add `name` and `status` columns to `user`. * * `name` is the display name introduced by #45; `status` is the * `UserStatus` enum (`pending | approved | blocked`) introduced by From 3b72cb991f72e638044a4eaf04ff7db821f6aa22 Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Fri, 19 Jun 2026 11:16:28 +0200 Subject: [PATCH 3/4] docs(changelog): trim user fields entry to fields + issue refs Per review on PR #86 - keep the CHANGELOG entry to what was added and the issue references; details belong in the PR description. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfc670b..202fe5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,16 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- `User.name` (display name) and `User.status` (lifecycle enum: - `pending | approved | blocked`) per ADR 006, plus the - `App\Enum\UserStatus` PHP enum. `UserManager::createUser()` now - requires `name` and accepts an optional `status` (default - `Approved` for the console / fixture path; the registration flow - in #62 will pass `Pending`). The `app:user:create` console command - takes a third `name` argument; fixtures seed Alice + Bob with - display names. Schema is added via a single migration that - backfills any existing rows with `name = ''` and - `status = 'approved'` +- `User.name` (display name) and `User.status` (`UserStatus` enum: + `pending | approved | blocked`) fields ([#45](https://github.com/itk-dev/ai-lib/issues/45), [#83](https://github.com/itk-dev/ai-lib/issues/83)). - Initial Symfony 8 application scaffold on the ITK Dev Docker From 0238ac927d6e3b16c43a1cc140e014c2d1609edb Mon Sep 17 00:00:00 2001 From: martinydeAI Date: Fri, 19 Jun 2026 12:47:31 +0200 Subject: [PATCH 4/4] chore(tests): add one-line intent comments to user test files Per review on PR #86 - apply the test-comment convention to every test method in UserTest, UserManagerTest, and UserRepositoryTest. Each method now opens with a single-line "Tests ...", "Ensures ...", or "Verifies ..." comment naming what it asserts. Pure documentation change - no test logic touched. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/Integration/Repository/UserRepositoryTest.php | 3 +++ tests/Integration/Security/UserManagerTest.php | 8 ++++++++ tests/Unit/Entity/UserTest.php | 8 +++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/Integration/Repository/UserRepositoryTest.php b/tests/Integration/Repository/UserRepositoryTest.php index bcc6742..e276c62 100644 --- a/tests/Integration/Repository/UserRepositoryTest.php +++ b/tests/Integration/Repository/UserRepositoryTest.php @@ -36,6 +36,7 @@ protected function setUp(): void $this->repository = $container->get(UserRepository::class); } + // Tests that upgradePassword persists the new hash and a reload reflects it. public function testUpgradePasswordWritesTheNewHash(): void { $alice = $this->repository->findOneBy(['email' => 'alice@example.test']); @@ -52,6 +53,7 @@ public function testUpgradePasswordWritesTheNewHash(): void self::assertNotSame($oldHash, $reloaded->getPassword()); } + // Ensures upgradePassword raises UnsupportedUserException for non-App User implementations. public function testUpgradePasswordRejectsForeignUserType(): void { $foreignUser = new class () implements PasswordAuthenticatedUserInterface { @@ -66,6 +68,7 @@ public function getPassword(): ?string $this->repository->upgradePassword($foreignUser, 'irrelevant'); } + // Verifies the UserStatus enum mapping round-trips: persisted then reloaded keeps the same enum case. public function testStatusEnumRoundTripsThroughThePersistedRow(): void { $em = self::getContainer()->get(EntityManagerInterface::class); diff --git a/tests/Integration/Security/UserManagerTest.php b/tests/Integration/Security/UserManagerTest.php index 87eeecb..203d01b 100644 --- a/tests/Integration/Security/UserManagerTest.php +++ b/tests/Integration/Security/UserManagerTest.php @@ -34,6 +34,7 @@ protected function setUp(): void $this->passwordHasher = $container->get(UserPasswordHasherInterface::class); } + // Tests the happy path: createUser persists a user with hashed password, name, default Approved status, and ROLE_USER. public function testCreatesAndPersistsUserWithHashedPassword(): void { $user = $this->userManager->createUser('charlie@example.test', 'Charlie', 'secret'); @@ -51,6 +52,7 @@ public function testCreatesAndPersistsUserWithHashedPassword(): void self::assertSame($user->getId(), $this->userRepository->findOneBy(['email' => 'charlie@example.test'])?->getId()); } + // Verifies extra roles passed to createUser are persisted alongside the implicit ROLE_USER. public function testCreateUserStoresExtraRoles(): void { $user = $this->userManager->createUser('admin@example.test', 'Admin', 'secret', ['ROLE_ADMIN']); @@ -58,6 +60,7 @@ public function testCreateUserStoresExtraRoles(): void self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], $user->getRoles()); } + // Tests that an explicit UserStatus argument overrides the Approved default. public function testCreateUserHonoursExplicitStatus(): void { $user = $this->userManager->createUser( @@ -70,6 +73,7 @@ public function testCreateUserHonoursExplicitStatus(): void self::assertSame(UserStatus::Pending, $user->getStatus()); } + // Ensures createUser raises DomainException when the email already exists. public function testCreateUserRejectsDuplicateEmail(): void { // alice@example.test is loaded by UserFixtures in the bootstrap. @@ -79,6 +83,7 @@ public function testCreateUserRejectsDuplicateEmail(): void $this->userManager->createUser('alice@example.test', 'Alice', 'other'); } + // Ensures createUser raises InvalidArgumentException on an empty password. public function testCreateUserRejectsEmptyPassword(): void { $this->expectException(\InvalidArgumentException::class); @@ -87,6 +92,7 @@ public function testCreateUserRejectsEmptyPassword(): void $this->userManager->createUser('charlie@example.test', 'Charlie', ''); } + // Tests that changePassword swaps the persisted hash and the new password verifies. public function testChangePasswordReplacesTheHash(): void { $alice = $this->userRepository->findOneBy(['email' => 'alice@example.test']); @@ -101,6 +107,7 @@ public function testChangePasswordReplacesTheHash(): void self::assertFalse($this->passwordHasher->isPasswordValid($updated, 'password')); } + // Ensures changePassword raises DomainException when no user matches the email. public function testChangePasswordFailsWhenUserMissing(): void { $this->expectException(\DomainException::class); @@ -109,6 +116,7 @@ public function testChangePasswordFailsWhenUserMissing(): void $this->userManager->changePassword('nobody@example.test', 'whatever'); } + // Ensures changePassword raises InvalidArgumentException on an empty new password. public function testChangePasswordRejectsEmptyPassword(): void { $this->expectException(\InvalidArgumentException::class); diff --git a/tests/Unit/Entity/UserTest.php b/tests/Unit/Entity/UserTest.php index 0eef22f..e60247c 100644 --- a/tests/Unit/Entity/UserTest.php +++ b/tests/Unit/Entity/UserTest.php @@ -10,6 +10,7 @@ final class UserTest extends TestCase { + // Tests that a freshly-constructed User defaults to Pending status and an empty name. public function testConstructorDefaultsStatusToPending(): void { $user = new User(); @@ -18,7 +19,7 @@ public function testConstructorDefaultsStatusToPending(): void self::assertSame('', $user->getName()); } - + // Ensures ROLE_USER is always present in getRoles() output, even when not set explicitly. public function testGetRolesAlwaysIncludesRoleUser(): void { $user = new User(); @@ -29,6 +30,7 @@ public function testGetRolesAlwaysIncludesRoleUser(): void self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], $user->getRoles()); } + // Ensures duplicate ROLE_USER entries are deduplicated in getRoles(). public function testGetRolesDeduplicatesRoleUserWhenAlreadyPresent(): void { $user = new User(); @@ -37,6 +39,7 @@ public function testGetRolesDeduplicatesRoleUserWhenAlreadyPresent(): void self::assertSame(['ROLE_USER', 'ROLE_ADMIN'], $user->getRoles()); } + // Tests that getUserIdentifier returns '' when no email has been set. public function testGetUserIdentifierReturnsEmptyStringWhenEmailIsNull(): void { $user = new User(); @@ -44,6 +47,7 @@ public function testGetUserIdentifierReturnsEmptyStringWhenEmailIsNull(): void self::assertSame('', $user->getUserIdentifier()); } + // Tests that getUserIdentifier returns the email when set. public function testGetUserIdentifierReturnsEmailWhenSet(): void { $user = new User(); @@ -52,6 +56,7 @@ public function testGetUserIdentifierReturnsEmailWhenSet(): void self::assertSame('alice@example.test', $user->getUserIdentifier()); } + // Verifies __serialize replaces the password hash with a CRC32C hash so the session never carries the original. public function testSerializeReplacesPasswordWithCrc32cHash(): void { $user = new User(); @@ -66,6 +71,7 @@ public function testSerializeReplacesPasswordWithCrc32cHash(): void self::assertNotContains('plaintext-hash', $data, 'Serialised payload must not contain the original password hash.'); } + // Tests that every setter returns $this (fluent) and mutates the underlying value. public function testSettersMutateAndReturnStatic(): void { $user = new User();