From c28ce7c48837bfdafca00dd9064bb02068414ac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Sun, 20 Sep 2026 17:16:35 +0200 Subject: [PATCH] feat(shared): implement entity identity abstractions and base value objects --- .../DDD/Exceptions/NotFoundException.php | 2 +- src/Shared/DDD/Models/Uuidv7EntityId.php | 40 ++++++++++++ .../{Models => }/ValueObjects/Email.php | 17 ++++- src/Shared/{Models => }/ValueObjects/Rcs.php | 17 ++++- .../Unit/Shared/DDD/Models/DummyEntityId.php | 11 ++++ .../Shared/DDD/Models/DummyUuidv7EntityId.php | 16 +++++ tests/Unit/Shared/DDD/Models/EntityIdTest.php | 36 +++++++++++ .../Shared/DDD/Models/Uuidv7EntityIdTest.php | 62 +++++++++++++++++++ .../{Models => }/ValueObjects/EmailTest.php | 4 +- .../{Models => }/ValueObjects/RcsTest.php | 4 +- 10 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 src/Shared/DDD/Models/Uuidv7EntityId.php rename src/Shared/{Models => }/ValueObjects/Email.php (68%) rename src/Shared/{Models => }/ValueObjects/Rcs.php (66%) create mode 100644 tests/Unit/Shared/DDD/Models/DummyEntityId.php create mode 100644 tests/Unit/Shared/DDD/Models/DummyUuidv7EntityId.php create mode 100644 tests/Unit/Shared/DDD/Models/EntityIdTest.php create mode 100644 tests/Unit/Shared/DDD/Models/Uuidv7EntityIdTest.php rename tests/Unit/Shared/{Models => }/ValueObjects/EmailTest.php (92%) rename tests/Unit/Shared/{Models => }/ValueObjects/RcsTest.php (93%) diff --git a/src/Shared/DDD/Exceptions/NotFoundException.php b/src/Shared/DDD/Exceptions/NotFoundException.php index d2ef656..2a7e714 100644 --- a/src/Shared/DDD/Exceptions/NotFoundException.php +++ b/src/Shared/DDD/Exceptions/NotFoundException.php @@ -4,6 +4,6 @@ declare(strict_types=1); namespace Shared\DDD\Exceptions; -class NotFoundException extends \DomainException +abstract class NotFoundException extends \DomainException { } diff --git a/src/Shared/DDD/Models/Uuidv7EntityId.php b/src/Shared/DDD/Models/Uuidv7EntityId.php new file mode 100644 index 0000000..a5f5a09 --- /dev/null +++ b/src/Shared/DDD/Models/Uuidv7EntityId.php @@ -0,0 +1,40 @@ +toString()); + + try { + UuidHelper::validateUuid($identifier); + } catch (\InvalidArgumentException $e) { + throw new \DomainException( + "Invalid value '{$identifier->toString()}' is not a valid UUID", + 0, + $e + ); + } + + if (! UuidHelper::isV7($identifier)) { + throw new \DomainException( + "Invalid value '{$identifier}' is not a valid V7 UUID", + ); + } + } + + public function toDateTime(): \DateTimeInterface + { + return UuidHelper::toDateTime($this->identifier); + } + + abstract public static function generate(): static; +} diff --git a/src/Shared/Models/ValueObjects/Email.php b/src/Shared/ValueObjects/Email.php similarity index 68% rename from src/Shared/Models/ValueObjects/Email.php rename to src/Shared/ValueObjects/Email.php index c3bdde0..fba31ab 100644 --- a/src/Shared/Models/ValueObjects/Email.php +++ b/src/Shared/ValueObjects/Email.php @@ -2,12 +2,14 @@ declare(strict_types=1); -namespace Shared\Models\ValueObjects; +namespace Shared\ValueObjects; + +use Shared\DDD\Models\Equatable; /** * Represents a valid email. */ -final readonly class Email +final readonly class Email implements \Stringable, Equatable { public string $value; @@ -34,4 +36,15 @@ final readonly class Email $this->value = mb_strtolower($trimmed, 'UTF-8'); } + + #[\Override] + public function equals(mixed $other): bool + { + return $other instanceof self && $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } } diff --git a/src/Shared/Models/ValueObjects/Rcs.php b/src/Shared/ValueObjects/Rcs.php similarity index 66% rename from src/Shared/Models/ValueObjects/Rcs.php rename to src/Shared/ValueObjects/Rcs.php index 0f5604f..bef5e2b 100644 --- a/src/Shared/Models/ValueObjects/Rcs.php +++ b/src/Shared/ValueObjects/Rcs.php @@ -2,13 +2,15 @@ declare(strict_types=1); -namespace Shared\Models\ValueObjects; +namespace Shared\ValueObjects; + +use Shared\DDD\Models\Equatable; /** * Represents a valid Rcs. * See: https://entreprendre.service-public.gouv.fr/vosdroits/F31190. */ -final readonly class Rcs +final readonly class Rcs implements \Stringable, Equatable { private const string REGEX = '/^RCS\s+[A-ZÀ-ÖØ-Ý][A-ZÀ-ÖØ-Ý\s-]*\s+\d{3}\s\d{3}\s\d{3}$/u'; @@ -29,4 +31,15 @@ final readonly class Rcs throw new \DomainException('Rcs is not a valid Rcs text.'); } } + + #[\Override] + public function equals(mixed $other): bool + { + return $other instanceof self && $this->value === $other->value; + } + + public function __toString(): string + { + return $this->value; + } } diff --git a/tests/Unit/Shared/DDD/Models/DummyEntityId.php b/tests/Unit/Shared/DDD/Models/DummyEntityId.php new file mode 100644 index 0000000..d2227d2 --- /dev/null +++ b/tests/Unit/Shared/DDD/Models/DummyEntityId.php @@ -0,0 +1,11 @@ +value)->toBe('123'); + }); + + it('can be casted to string', function (): void { + // GIVEN / WHEN + $id = new DummyEntityId('123'); + + // THEN + expect((string) $id)->toBe('123'); + }); + + it('can be compared for equality', function (): void { + // GIVEN / WHEN + $id1 = new DummyEntityId('123'); + $id2 = new DummyEntityId('123'); + + // THEN + expect($id1->equals($id2))->toBeTrue(); + }); +}); diff --git a/tests/Unit/Shared/DDD/Models/Uuidv7EntityIdTest.php b/tests/Unit/Shared/DDD/Models/Uuidv7EntityIdTest.php new file mode 100644 index 0000000..6d829af --- /dev/null +++ b/tests/Unit/Shared/DDD/Models/Uuidv7EntityIdTest.php @@ -0,0 +1,62 @@ +value)->toBe((string) $uuid); + }); + + it('can be casted to string', function (): void { + // GIVEN / WHEN + $uuid = Uuid::uuid7(); + $id = new DummyUuidv7EntityId($uuid); + + // THEN + expect((string) $id)->toBe((string) $uuid); + }); + + it('can be compared for equality', function (): void { + // GIVEN / WHEN + $uuid = Uuid::uuid7(); + $id1 = new DummyUuidv7EntityId($uuid); + $id2 = new DummyUuidv7EntityId($uuid); + + // THEN + expect($id1->equals($id2))->toBeTrue(); + }); + + it('can be converted to a datetime', function (): void { + // GIVEN / WHEN + $uuid = Uuid::uuid7(); + $id = new DummyUuidv7EntityId($uuid); + + // THEN + expect($id->toDateTime())->toBeInstanceOf(\DateTimeImmutable::class); + }); +}); + +describe('uuidv7 entity id validation rules', function (): void { + it('should throw an exception when non v7 UUID is used', function (): void { + // GIVEN + $uuid = Uuid::uuid4(); + + // THEN + $this->expectException(\DomainException::class); + + // WHEN + new DummyUuidv7EntityId($uuid); + }); +}); diff --git a/tests/Unit/Shared/Models/ValueObjects/EmailTest.php b/tests/Unit/Shared/ValueObjects/EmailTest.php similarity index 92% rename from tests/Unit/Shared/Models/ValueObjects/EmailTest.php rename to tests/Unit/Shared/ValueObjects/EmailTest.php index fb45ab3..e0e87c8 100644 --- a/tests/Unit/Shared/Models/ValueObjects/EmailTest.php +++ b/tests/Unit/Shared/ValueObjects/EmailTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Tests\Unit\Shared\Models\ValueObjects; +namespace Tests\Unit\Shared\ValueObjects; -use Shared\Models\ValueObjects\Email; +use Shared\ValueObjects\Email; covers(Email::class); diff --git a/tests/Unit/Shared/Models/ValueObjects/RcsTest.php b/tests/Unit/Shared/ValueObjects/RcsTest.php similarity index 93% rename from tests/Unit/Shared/Models/ValueObjects/RcsTest.php rename to tests/Unit/Shared/ValueObjects/RcsTest.php index dd19b53..7474192 100644 --- a/tests/Unit/Shared/Models/ValueObjects/RcsTest.php +++ b/tests/Unit/Shared/ValueObjects/RcsTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace Tests\Unit\Shared\Models\ValueObjects; +namespace Tests\Unit\Shared\ValueObjects; -use Shared\Models\ValueObjects\Rcs; +use Shared\ValueObjects\Rcs; covers(Rcs::class);