From 709568f1ed159eb7dca5cfe53d47c917d54f8d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Fri, 21 Aug 2026 11:36:37 +0200 Subject: [PATCH] feat(brand): handle on-demand brand renaming with BrandRenamed event and historical preservation --- src/Domain/Events/BrandRenamed.php | 24 ++++++ src/Domain/Models/Entities/Brand.php | 10 +++ .../Models/ValueObjects/Brand/BrandName.php | 10 ++- src/Domain/Services/BrandResolver.php | 20 ++++- src/Infra/Listeners/InvalidateBrandCache.php | 3 +- src/Infra/Listeners/LogBrandRenamed.php | 36 ++++++++ src/Infra/Providers/EventServiceProvider.php | 12 +++ tests/Unit/Domain/Events/BrandRenamedTest.php | 44 ++++++++++ .../Unit/Domain/Models/Entities/BrandTest.php | 26 ++++++ .../ValueObjects/Brand/BrandNameTest.php | 12 +++ .../Domain/Services/BrandResolverTest.php | 86 +++++++++++++++++++ .../Listeners/InvalidateBrandCacheTest.php | 26 ++++++ .../Infra/Listeners/LogBrandRenamedTest.php | 59 +++++++++++++ 13 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 src/Domain/Events/BrandRenamed.php create mode 100644 src/Infra/Listeners/LogBrandRenamed.php create mode 100644 tests/Unit/Domain/Events/BrandRenamedTest.php create mode 100644 tests/Unit/Infra/Listeners/LogBrandRenamedTest.php diff --git a/src/Domain/Events/BrandRenamed.php b/src/Domain/Events/BrandRenamed.php new file mode 100644 index 0000000..95ccd85 --- /dev/null +++ b/src/Domain/Events/BrandRenamed.php @@ -0,0 +1,24 @@ +id, + name: $newName, + code: $this->code, + arcadiaId: $this->arcadiaId, + ); + } } diff --git a/src/Domain/Models/ValueObjects/Brand/BrandName.php b/src/Domain/Models/ValueObjects/Brand/BrandName.php index 3b05544..b6e251a 100644 --- a/src/Domain/Models/ValueObjects/Brand/BrandName.php +++ b/src/Domain/Models/ValueObjects/Brand/BrandName.php @@ -4,7 +4,9 @@ declare(strict_types=1); namespace Domain\Models\ValueObjects\Brand; -final readonly class BrandName +use Shared\DDD\Model\Equatable; + +final readonly class BrandName implements Equatable { public function __construct( public string $value, @@ -13,4 +15,10 @@ final readonly class BrandName throw new \DomainException('Brand name cannot be empty.'); } } + + #[\Override] + public function equals(mixed $other): bool + { + return $other instanceof self && $this->value === $other->value; + } } diff --git a/src/Domain/Services/BrandResolver.php b/src/Domain/Services/BrandResolver.php index 6f5ea85..eaa7df8 100644 --- a/src/Domain/Services/BrandResolver.php +++ b/src/Domain/Services/BrandResolver.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Domain\Services; use Domain\Events\BrandCreated; +use Domain\Events\BrandRenamed; use Domain\Exceptions\BrandNotFoundException; use Domain\Models\Entities\Brand; use Domain\Models\ValueObjects\Brand\ArcadiaId; @@ -30,13 +31,28 @@ final readonly class BrandResolver public function resolve(ArcadiaId $arcadiaBrandCode): Brand { $existingBrand = $this->brandRepository->findByArcadiaId($arcadiaBrandCode); + $arcadiaBrand = $this->arcadiaBrandDirectory->find($arcadiaBrandCode); if ($existingBrand !== null) { + if ($arcadiaBrand !== null && ! $existingBrand->name->equals($arcadiaBrand->name)) { + $oldName = $existingBrand->name; + $existingBrand = $existingBrand->rename($arcadiaBrand->name); + + $this->brandRepository->persist($existingBrand); + + $this->dispatcher->dispatch(new BrandRenamed( + brandId: $existingBrand->id, + oldName: $oldName, + newName: $existingBrand->name, + brandCode: $existingBrand->code, + arcadiaId: $existingBrand->arcadiaId, + occurredAt: new \DateTimeImmutable(), + )); + } + return $existingBrand; } - $arcadiaBrand = $this->arcadiaBrandDirectory->find($arcadiaBrandCode); - if ($arcadiaBrand === null) { throw BrandNotFoundException::forArcadiaId($arcadiaBrandCode); } diff --git a/src/Infra/Listeners/InvalidateBrandCache.php b/src/Infra/Listeners/InvalidateBrandCache.php index 919fcae..881357d 100644 --- a/src/Infra/Listeners/InvalidateBrandCache.php +++ b/src/Infra/Listeners/InvalidateBrandCache.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Infra\Listeners; use Domain\Events\BrandCreated; +use Domain\Events\BrandRenamed; use Infra\Cache\BrandCache; final readonly class InvalidateBrandCache @@ -14,7 +15,7 @@ final readonly class InvalidateBrandCache ) { } - public function handle(BrandCreated $_): void + public function handle(BrandCreated | BrandRenamed $_): void { $this->brandCache->reset(); } diff --git a/src/Infra/Listeners/LogBrandRenamed.php b/src/Infra/Listeners/LogBrandRenamed.php new file mode 100644 index 0000000..3c5bc71 --- /dev/null +++ b/src/Infra/Listeners/LogBrandRenamed.php @@ -0,0 +1,36 @@ +logger->info( + sprintf( + 'Brand "%s" (code %s, Arcadia ID %d) renamed to "%s"', + $event->oldName->value, + $event->brandCode->value, + $event->arcadiaId->value, + $event->newName->value + ), + [ + 'brand_id' => $event->brandId->value, + 'code' => $event->brandCode->value, + 'old_name' => $event->oldName->value, + 'new_name' => $event->newName->value, + 'arcadia_id' => $event->arcadiaId->value, + ] + ); + } +} diff --git a/src/Infra/Providers/EventServiceProvider.php b/src/Infra/Providers/EventServiceProvider.php index 352834d..c7a1070 100644 --- a/src/Infra/Providers/EventServiceProvider.php +++ b/src/Infra/Providers/EventServiceProvider.php @@ -8,10 +8,12 @@ use Application\Security\Events\ClientSecretGenerated; use Application\Security\Events\ClientSecretRegenerated; use Application\Security\Events\ClientStatusToggled; use Domain\Events\BrandCreated; +use Domain\Events\BrandRenamed; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; use Infra\Listeners\InvalidateBrandCache; use Infra\Listeners\LogBrandCreated; +use Infra\Listeners\LogBrandRenamed; use Infra\Listeners\LogClientSecretRotated; use Infra\Listeners\LogClientStatusChanged; @@ -29,6 +31,16 @@ class EventServiceProvider extends ServiceProvider InvalidateBrandCache::class, ); + Event::listen( + BrandRenamed::class, + LogBrandRenamed::class, + ); + + Event::listen( + BrandRenamed::class, + InvalidateBrandCache::class, + ); + Event::listen( ClientSecretGenerated::class, LogClientSecretRotated::class, diff --git a/tests/Unit/Domain/Events/BrandRenamedTest.php b/tests/Unit/Domain/Events/BrandRenamedTest.php new file mode 100644 index 0000000..0803ad8 --- /dev/null +++ b/tests/Unit/Domain/Events/BrandRenamedTest.php @@ -0,0 +1,44 @@ +brandId)->toBe($brandId) + ->and($event->oldName)->toBe($oldName) + ->and($event->newName)->toBe($newName) + ->and($event->brandCode)->toBe($brandCode) + ->and($event->arcadiaId)->toBe($arcadiaId) + ->and($event->occurredAt)->toBe($now); + }); +}); diff --git a/tests/Unit/Domain/Models/Entities/BrandTest.php b/tests/Unit/Domain/Models/Entities/BrandTest.php index 28a0900..d6ee47c 100644 --- a/tests/Unit/Domain/Models/Entities/BrandTest.php +++ b/tests/Unit/Domain/Models/Entities/BrandTest.php @@ -62,4 +62,30 @@ describe('Brand Entity', function (): void { ->and($brand->createdAt())->toBeInstanceOf(\DateTimeInterface::class) ->and($brand->createdAt()->getTimestamp())->toBeLessThanOrEqual(time()); }); + + it('renames a brand immutably', function (): void { + // GIVEN + $brandId = new BrandId(Uuid::uuid7()); + $brandCode = new BrandCode('LGL01'); + $arcadiaId = new ArcadiaId(1); + $oldName = new BrandName('Le Gaulois'); + $newName = new BrandName('Le Gaulois France'); + + $brand = Brand::hydrate( + id: $brandId, + name: $oldName, + code: $brandCode, + arcadiaId: $arcadiaId, + ); + + // WHEN + $renamed = $brand->rename($newName); + + // THEN + expect($renamed->id())->toBe($brandId) + ->and($renamed->name)->toBe($newName) + ->and($renamed->code)->toBe($brandCode) + ->and($renamed->arcadiaId)->toBe($arcadiaId) + ->and($brand->name)->toBe($oldName); + }); }); diff --git a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php index 7cf9e31..75abaad 100644 --- a/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php +++ b/tests/Unit/Domain/Models/ValueObjects/Brand/BrandNameTest.php @@ -32,4 +32,16 @@ describe('BrandName Value Object Validation', function (): void { // THEN ->toThrow(\DomainException::class); }); + + it('correctly compares equality with another BrandName', function (): void { + // GIVEN + $name1 = new BrandName('Le Gaulois'); + $name2 = new BrandName('Le Gaulois'); + $name3 = new BrandName('MaƮtre CoQ'); + + // WHEN & THEN + expect($name1->equals($name2))->toBeTrue() + ->and($name1->equals($name3))->toBeFalse() + ->and($name1->equals('Le Gaulois'))->toBeFalse(); + }); }); diff --git a/tests/Unit/Domain/Services/BrandResolverTest.php b/tests/Unit/Domain/Services/BrandResolverTest.php index 24a767c..bf5092e 100644 --- a/tests/Unit/Domain/Services/BrandResolverTest.php +++ b/tests/Unit/Domain/Services/BrandResolverTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Unit\Domain\Services; use Domain\Events\BrandCreated; +use Domain\Events\BrandRenamed; use Domain\Exceptions\BrandNotFoundException; use Domain\Models\Entities\Brand; use Domain\Models\ValueObjects\Brand\ArcadiaBrand; @@ -116,4 +117,89 @@ describe('BrandResolver Unit Tests', function (): void { expect(fn () => $resolver->resolve(new ArcadiaId(999))) ->toThrow(BrandNotFoundException::class, 'Arcadia brand with ID 999 not found.'); }); + + it('updates brand name, persists and dispatches BrandRenamed when Arcadia brand name changes', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator): void { + // GIVEN + $brandId = new BrandId(Uuid::uuid7()); + $oldBrand = Brand::hydrate( + id: $brandId, + name: new BrandName('Le Gaulois'), + code: new BrandCode('LGL01'), + arcadiaId: new ArcadiaId(1), + ); + $brandRepository->add($oldBrand); + + // Arcadia has renamed the brand + $arcadiaDirectory->add(new ArcadiaBrand(new ArcadiaId(1), new BrandName('Le Gaulois France'))); + + $dispatcher = new class () implements DomainEventDispatcher + { + /** @var list */ + public array $dispatched = []; + + public function dispatch(Event $event): void + { + $this->dispatched[] = $event; + } + }; + + $resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $dispatcher); + + // WHEN + $resolved = $resolver->resolve(new ArcadiaId(1)); + + // THEN + expect($resolved->name->value)->toBe('Le Gaulois France') + ->and($resolved->id()->value)->toBe($brandId->value) + ->and($resolved->code->value)->toBe('LGL01'); + + $persisted = $brandRepository->findByArcadiaId(new ArcadiaId(1)); + expect($persisted)->not->toBeNull() + ->and($persisted->name->value)->toBe('Le Gaulois France'); + + expect($dispatcher->dispatched)->toHaveCount(1) + ->and($dispatcher->dispatched[0])->toBeInstanceOf(BrandRenamed::class); + + /** @var \Domain\Events\BrandRenamed $event */ + $event = $dispatcher->dispatched[0]; + expect($event->oldName->value)->toBe('Le Gaulois') + ->and($event->newName->value)->toBe('Le Gaulois France') + ->and($event->brandId->value)->toBe($brandId->value); + }); + + it('preserves and returns existing brand when brand was removed from Arcadia directory', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator): void { + // GIVEN + $brandId = new BrandId(Uuid::uuid7()); + $existing = Brand::hydrate( + id: $brandId, + name: new BrandName('Historical Brand'), + code: new BrandCode('HST01'), + arcadiaId: new ArcadiaId(77), + ); + $brandRepository->add($existing); + + // Arcadia directory does NOT have brand 77 (deleted/purged amont) + // $arcadiaDirectory is empty for 77 + + $dispatcher = new class () implements DomainEventDispatcher + { + /** @var list */ + public array $dispatched = []; + + public function dispatch(Event $event): void + { + $this->dispatched[] = $event; + } + }; + + $resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $dispatcher); + + // WHEN + $resolved = $resolver->resolve(new ArcadiaId(77)); + + // THEN + expect($resolved)->toBe($existing) + ->and($resolved->name->value)->toBe('Historical Brand') + ->and($dispatcher->dispatched)->toBeEmpty(); + }); }); diff --git a/tests/Unit/Infra/Listeners/InvalidateBrandCacheTest.php b/tests/Unit/Infra/Listeners/InvalidateBrandCacheTest.php index 0de90f8..7520424 100644 --- a/tests/Unit/Infra/Listeners/InvalidateBrandCacheTest.php +++ b/tests/Unit/Infra/Listeners/InvalidateBrandCacheTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Unit\Infra\Listeners; use Domain\Events\BrandCreated; +use Domain\Events\BrandRenamed; use Domain\Models\ValueObjects\Brand\ArcadiaId; use Domain\Models\ValueObjects\Brand\BrandCode; use Domain\Models\ValueObjects\Brand\BrandId; @@ -40,4 +41,29 @@ describe('InvalidateBrandCache Listener Unit Tests', function (): void { // THEN expect(true)->toBeTrue(); }); + + it('resets brand cache when BrandRenamed event is handled', function (): void { + // GIVEN + $event = new BrandRenamed( + brandId: new BrandId(Uuid::uuid7()), + oldName: new BrandName('Le Gaulois'), + newName: new BrandName('Le Gaulois France'), + brandCode: new BrandCode('LGL01'), + arcadiaId: new ArcadiaId(1), + occurredAt: new \DateTimeImmutable(), + ); + + /** @var BrandCache&Mockery\MockInterface $brandCacheMock */ + $brandCacheMock = Mockery::mock(BrandCache::class); + $brandCacheMock->shouldReceive('reset') + ->once(); + + $listener = new InvalidateBrandCache($brandCacheMock); + + // WHEN + $listener->handle($event); + + // THEN + expect(true)->toBeTrue(); + }); }); diff --git a/tests/Unit/Infra/Listeners/LogBrandRenamedTest.php b/tests/Unit/Infra/Listeners/LogBrandRenamedTest.php new file mode 100644 index 0000000..865efad --- /dev/null +++ b/tests/Unit/Infra/Listeners/LogBrandRenamedTest.php @@ -0,0 +1,59 @@ +shouldReceive('info') + ->once() + ->with( + 'Brand "Le Gaulois" (code LGL01, Arcadia ID 1) renamed to "Le Gaulois France"', + [ + 'brand_id' => $brandId->value, + 'code' => 'LGL01', + 'old_name' => 'Le Gaulois', + 'new_name' => 'Le Gaulois France', + 'arcadia_id' => 1, + ] + ); + + $listener = new LogBrandRenamed($loggerMock); + + // WHEN + $listener->handle($event); + + // THEN + expect(true)->toBeTrue(); + }); +});