Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
23 / 23
CRAP
100.00% covered (success)
100.00%
1 / 1
User
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
23 / 23
26
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 registerWithoutPassword
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 update
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 changePassword
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 grantAdmin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 revokeAdmin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isAdmin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 suspend
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 activate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 status
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 email
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 password
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hasPassword
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 preferredCurrency
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 surnames
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 birthDate
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 createdAt
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 avatarName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 changeAvatar
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 sanitizeName
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace App\Identity\Domain;
6
7use App\Identity\Domain\ValueObject\BirthDate;
8use App\Identity\Domain\ValueObject\Email;
9use App\Identity\Domain\ValueObject\HashedPassword;
10use App\Identity\Domain\ValueObject\UserId;
11use App\Identity\Domain\ValueObject\UserStatus;
12use App\Shared\Domain\ValueObject\Currency;
13use DateTimeImmutable;
14use Doctrine\ORM\Mapping as ORM;
15use InvalidArgumentException;
16
17#[ORM\Entity]
18#[ORM\Table(name: 'users')]
19final 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}