diff --git a/migrations/Version20260619080000.php b/migrations/Version20260619080000.php new file mode 100644 index 0000000..7edae34 --- /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 c4cd8d2..bd8c392 100644 --- a/tests/Integration/Command/UserCreateCommandTest.php +++ b/tests/Integration/Command/UserCreateCommandTest.php @@ -31,6 +31,7 @@ public function testCreatesUser(): void { $exit = $this->tester->execute([ 'email' => 'charlie@example.test', + 'name' => 'Charlie', 'password' => 'secret', ]); @@ -44,6 +45,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 619545d..598606f 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; @@ -64,4 +67,25 @@ 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); + + $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 8c51812..4ca04fb 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; @@ -33,13 +34,15 @@ protected function setUp(): void $this->passwordHasher = $container->get(UserPasswordHasherInterface::class); } - // Tests that createUser() persists a new user with the password hashed and verifiable against the original plaintext. + // 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', '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( @@ -52,7 +55,7 @@ public function testCreatesAndPersistsUserWithHashedPassword(): void // Verifies that extra roles passed to createUser() are stored alongside the implicit ROLE_USER. 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()); } @@ -64,7 +67,7 @@ public function testCreateUserRejectsDuplicateEmail(): void $this->expectException(\DomainException::class); $this->expectExceptionMessage('alice@example.test'); - $this->userManager->createUser('alice@example.test', 'other'); + $this->userManager->createUser('alice@example.test', 'Alice', 'other'); } // Ensures createUser() throws InvalidArgumentException when the password is empty. @@ -73,7 +76,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', ''); } // Tests that changePassword() replaces the stored hash and the new password verifies (while the old one no longer does). diff --git a/tests/Unit/DataFixtures/UserFixturesTest.php b/tests/Unit/DataFixtures/UserFixturesTest.php index c2fd99c..f53767c 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; @@ -31,12 +32,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'); @@ -45,6 +46,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 d68e3d6..e60247c 100644 --- a/tests/Unit/Entity/UserTest.php +++ b/tests/Unit/Entity/UserTest.php @@ -5,11 +5,21 @@ namespace App\Tests\Unit\Entity; use App\Entity\User; +use App\Enum\UserStatus; use PHPUnit\Framework\TestCase; final class UserTest extends TestCase { - // Tests that getRoles() always appends ROLE_USER, both for a fresh user and for one with extra roles set. + // Tests that a freshly-constructed User defaults to Pending status and an empty name. + public function testConstructorDefaultsStatusToPending(): void + { + $user = new User(); + + self::assertSame(UserStatus::Pending, $user->getStatus()); + 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(); @@ -20,7 +30,7 @@ public function testGetRolesAlwaysIncludesRoleUser(): void self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], $user->getRoles()); } - // Ensures getRoles() deduplicates ROLE_USER when the caller has already set it explicitly. + // Ensures duplicate ROLE_USER entries are deduplicated in getRoles(). public function testGetRolesDeduplicatesRoleUserWhenAlreadyPresent(): void { $user = new User(); @@ -29,7 +39,7 @@ public function testGetRolesDeduplicatesRoleUserWhenAlreadyPresent(): void self::assertSame(['ROLE_USER', 'ROLE_ADMIN'], $user->getRoles()); } - // Verifies that getUserIdentifier() returns '' when the email is null (the `(string) null` fallback). + // Tests that getUserIdentifier returns '' when no email has been set. public function testGetUserIdentifierReturnsEmptyStringWhenEmailIsNull(): void { $user = new User(); @@ -37,7 +47,7 @@ public function testGetUserIdentifierReturnsEmptyStringWhenEmailIsNull(): void self::assertSame('', $user->getUserIdentifier()); } - // Tests that getUserIdentifier() returns the email value when one is set. + // Tests that getUserIdentifier returns the email when set. public function testGetUserIdentifierReturnsEmailWhenSet(): void { $user = new User(); @@ -46,7 +56,7 @@ public function testGetUserIdentifierReturnsEmailWhenSet(): void self::assertSame('alice@example.test', $user->getUserIdentifier()); } - // Ensures __serialize() replaces the password with its CRC32C hash so the session never carries the original hash. + // Verifies __serialize replaces the password hash with a CRC32C hash so the session never carries the original. public function testSerializeReplacesPasswordWithCrc32cHash(): void { $user = new User(); @@ -61,7 +71,7 @@ public function testSerializeReplacesPasswordWithCrc32cHash(): void self::assertNotContains('plaintext-hash', $data, 'Serialised payload must not contain the original password hash.'); } - // Tests that each setter mutates its field and returns `$this`, and that getId() is null on a fresh user. + // Tests that every setter returns $this (fluent) and mutates the underlying value. public function testSettersMutateAndReturnStatic(): void { $user = new User(); @@ -75,6 +85,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()); } }