Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
52 / 52 |
|
100.00% |
23 / 23 |
CRAP | |
100.00% |
1 / 1 |
| User | |
100.00% |
52 / 52 |
|
100.00% |
23 / 23 |
26 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| register | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
| registerWithoutPassword | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
1 | |||
| update | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| changePassword | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| grantAdmin | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| revokeAdmin | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| isAdmin | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| suspend | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| activate | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| status | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| id | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | ||||
| password | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| hasPassword | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| preferredCurrency | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| name | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| surnames | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| birthDate | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| createdAt | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| avatarName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| changeAvatar | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| sanitizeName | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace App\Identity\Domain; |
| 6 | |
| 7 | use App\Identity\Domain\ValueObject\BirthDate; |
| 8 | use App\Identity\Domain\ValueObject\Email; |
| 9 | use App\Identity\Domain\ValueObject\HashedPassword; |
| 10 | use App\Identity\Domain\ValueObject\UserId; |
| 11 | use App\Identity\Domain\ValueObject\UserStatus; |
| 12 | use App\Shared\Domain\ValueObject\Currency; |
| 13 | use DateTimeImmutable; |
| 14 | use Doctrine\ORM\Mapping as ORM; |
| 15 | use InvalidArgumentException; |
| 16 | |
| 17 | #[ORM\Entity] |
| 18 | #[ORM\Table(name: 'users')] |
| 19 | final class User |
| 20 | { |
| 21 | private function __construct( |
| 22 | #[ORM\Id] |
| 23 | #[ORM\Column(type: 'user_id')] |
| 24 | private readonly UserId $id, |
| 25 | #[ORM\Column(type: 'email', unique: true)] |
| 26 | private Email $email, |
| 27 | // Null for an account that only signs in through an external provider: |
| 28 | // there is no password to verify, and the framework guards against it |
| 29 | // (CheckCredentialsListener refuses a null password outright). |
| 30 | #[ORM\Column(type: 'hashed_password', nullable: true)] |
| 31 | private ?HashedPassword $password, |
| 32 | #[ORM\Column(name: 'preferred_currency', type: 'currency')] |
| 33 | private Currency $preferredCurrency, |
| 34 | #[ORM\Column] |
| 35 | private string $name, |
| 36 | #[ORM\Column] |
| 37 | private string $surnames, |
| 38 | #[ORM\Column(name: 'birth_date', type: 'date_immutable', nullable: true)] |
| 39 | private ?DateTimeImmutable $birthDate, |
| 40 | #[ORM\Column(name: 'created_at', type: 'datetime_immutable')] |
| 41 | private readonly DateTimeImmutable $createdAt, |
| 42 | // ponytail: one bit, not a roles table — "is this user an admin" is the only |
| 43 | // question the app asks. Promote to a role set when a third role exists. |
| 44 | #[ORM\Column(name: 'is_admin', type: 'boolean', options: ['default' => false])] |
| 45 | private bool $admin = false, |
| 46 | #[ORM\Column(name: 'status', type: 'user_status', length: 20, options: ['default' => UserStatus::ACTIVE->value])] |
| 47 | private UserStatus $status = UserStatus::ACTIVE, |
| 48 | // Name of the avatar file in the uploads root, or null when the user has |
| 49 | // none. The user never knows where files live — that is ImageStorage's job. |
| 50 | #[ORM\Column(name: 'avatar_name', nullable: true)] |
| 51 | private ?string $avatarName = null, |
| 52 | ) { |
| 53 | } |
| 54 | |
| 55 | public static function register( |
| 56 | UserId $id, |
| 57 | Email $email, |
| 58 | HashedPassword $password, |
| 59 | Currency $preferredCurrency, |
| 60 | string $name, |
| 61 | string $surnames, |
| 62 | ?BirthDate $birthDate, |
| 63 | ): self { |
| 64 | [$name, $surnames] = self::sanitizeName($name, $surnames); |
| 65 | |
| 66 | // Self-registration never grants admin: it is bootstrapped out of band |
| 67 | // (app:user:grant-admin) and granted by another admin thereafter. |
| 68 | return new self( |
| 69 | $id, |
| 70 | $email, |
| 71 | $password, |
| 72 | $preferredCurrency, |
| 73 | $name, |
| 74 | $surnames, |
| 75 | $birthDate?->toDateTimeImmutable(), |
| 76 | new DateTimeImmutable(), |
| 77 | ); |
| 78 | } |
| 79 | |
| 80 | /** |
| 81 | * Registers an account whose credentials live at an external provider. Stays |
| 82 | * provider-agnostic on purpose: the domain never learns which one it was, that |
| 83 | * is the UserIdentity's job. |
| 84 | */ |
| 85 | public static function registerWithoutPassword( |
| 86 | UserId $id, |
| 87 | Email $email, |
| 88 | Currency $preferredCurrency, |
| 89 | string $name, |
| 90 | string $surnames, |
| 91 | ?BirthDate $birthDate = null, |
| 92 | ): self { |
| 93 | [$name, $surnames] = self::sanitizeName($name, $surnames); |
| 94 | |
| 95 | return new self( |
| 96 | $id, |
| 97 | $email, |
| 98 | null, |
| 99 | $preferredCurrency, |
| 100 | $name, |
| 101 | $surnames, |
| 102 | $birthDate?->toDateTimeImmutable(), |
| 103 | new DateTimeImmutable(), |
| 104 | ); |
| 105 | } |
| 106 | |
| 107 | /** |
| 108 | * Mutate the editable profile attributes. Email uniqueness is enforced at the |
| 109 | * application boundary; the password is changed separately (it must be re-hashed). |
| 110 | */ |
| 111 | public function update( |
| 112 | Email $email, |
| 113 | Currency $preferredCurrency, |
| 114 | string $name, |
| 115 | string $surnames, |
| 116 | ?BirthDate $birthDate, |
| 117 | ): void { |
| 118 | [$name, $surnames] = self::sanitizeName($name, $surnames); |
| 119 | |
| 120 | $this->email = $email; |
| 121 | $this->preferredCurrency = $preferredCurrency; |
| 122 | $this->name = $name; |
| 123 | $this->surnames = $surnames; |
| 124 | $this->birthDate = $birthDate?->toDateTimeImmutable(); |
| 125 | } |
| 126 | |
| 127 | public function changePassword(HashedPassword $password): void |
| 128 | { |
| 129 | $this->password = $password; |
| 130 | } |
| 131 | |
| 132 | public function grantAdmin(): void |
| 133 | { |
| 134 | $this->admin = true; |
| 135 | } |
| 136 | |
| 137 | public function revokeAdmin(): void |
| 138 | { |
| 139 | $this->admin = false; |
| 140 | } |
| 141 | |
| 142 | public function isAdmin(): bool |
| 143 | { |
| 144 | return $this->admin; |
| 145 | } |
| 146 | |
| 147 | public function suspend(): void |
| 148 | { |
| 149 | $this->status = UserStatus::SUSPENDED; |
| 150 | } |
| 151 | |
| 152 | public function activate(): void |
| 153 | { |
| 154 | $this->status = UserStatus::ACTIVE; |
| 155 | } |
| 156 | |
| 157 | public function status(): UserStatus |
| 158 | { |
| 159 | return $this->status; |
| 160 | } |
| 161 | |
| 162 | public function id(): UserId |
| 163 | { |
| 164 | return $this->id; |
| 165 | } |
| 166 | |
| 167 | public function email(): Email |
| 168 | { |
| 169 | return $this->email; |
| 170 | } |
| 171 | |
| 172 | public function password(): ?HashedPassword |
| 173 | { |
| 174 | return $this->password; |
| 175 | } |
| 176 | |
| 177 | public function hasPassword(): bool |
| 178 | { |
| 179 | return $this->password !== null; |
| 180 | } |
| 181 | |
| 182 | public function preferredCurrency(): Currency |
| 183 | { |
| 184 | return $this->preferredCurrency; |
| 185 | } |
| 186 | |
| 187 | public function name(): string |
| 188 | { |
| 189 | return $this->name; |
| 190 | } |
| 191 | |
| 192 | public function surnames(): string |
| 193 | { |
| 194 | return $this->surnames; |
| 195 | } |
| 196 | |
| 197 | public function birthDate(): ?BirthDate |
| 198 | { |
| 199 | return $this->birthDate === null ? null : BirthDate::fromDate($this->birthDate); |
| 200 | } |
| 201 | |
| 202 | public function createdAt(): DateTimeImmutable |
| 203 | { |
| 204 | return $this->createdAt; |
| 205 | } |
| 206 | |
| 207 | public function avatarName(): ?string |
| 208 | { |
| 209 | return $this->avatarName; |
| 210 | } |
| 211 | |
| 212 | /** Sets the avatar image, or clears it with null. The old file is removed by the caller. */ |
| 213 | public function changeAvatar(?string $avatarName): void |
| 214 | { |
| 215 | $this->avatarName = $avatarName; |
| 216 | } |
| 217 | |
| 218 | /** |
| 219 | * @return array{0: string, 1: string} |
| 220 | */ |
| 221 | private static function sanitizeName(string $name, string $surnames): array |
| 222 | { |
| 223 | $name = trim($name); |
| 224 | $surnames = trim($surnames); |
| 225 | |
| 226 | if ($name === '' || $surnames === '') { |
| 227 | throw new InvalidArgumentException('Name and surnames cannot be empty.'); |
| 228 | } |
| 229 | |
| 230 | return [$name, $surnames]; |
| 231 | } |
| 232 | } |