feat(brand): handle on-demand brand renaming with BrandRenamed event and historical preservation

This commit is contained in:
Jérémy Munsch 2026-08-21 11:36:37 +02:00
parent cc368c7102
commit 709568f1ed
13 changed files with 364 additions and 4 deletions

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace Domain\Events;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Shared\DDD\Event\Event;
final readonly class BrandRenamed implements Event
{
public function __construct(
public BrandId $brandId,
public BrandName $oldName,
public BrandName $newName,
public BrandCode $brandCode,
public ArcadiaId $arcadiaId,
public \DateTimeImmutable $occurredAt,
) {
}
}

View file

@ -65,4 +65,14 @@ final readonly class Brand implements Entity, HasTimestampIdentifier
arcadiaId: $arcadiaId,
);
}
public function rename(BrandName $newName): self
{
return new self(
id: $this->id,
name: $newName,
code: $this->code,
arcadiaId: $this->arcadiaId,
);
}
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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();
}

View file

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Infra\Listeners;
use Domain\Events\BrandRenamed;
use Psr\Log\LoggerInterface;
final readonly class LogBrandRenamed
{
public function __construct(
private LoggerInterface $logger,
) {
}
public function handle(BrandRenamed $event): void
{
$this->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,
]
);
}
}

View file

@ -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,

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Events;
use Domain\Events\BrandRenamed;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Ramsey\Uuid\Uuid;
covers(BrandRenamed::class);
describe('BrandRenamed Domain Event Unit Tests', function (): void {
it('holds correct event data upon instantiation', function (): void {
// GIVEN
$brandId = new BrandId(Uuid::uuid7());
$oldName = new BrandName('Le Gaulois');
$newName = new BrandName('Le Gaulois France');
$brandCode = new BrandCode('LGL01');
$arcadiaId = new ArcadiaId(1);
$now = new \DateTimeImmutable();
// WHEN
$event = new BrandRenamed(
brandId: $brandId,
oldName: $oldName,
newName: $newName,
brandCode: $brandCode,
arcadiaId: $arcadiaId,
occurredAt: $now,
);
// THEN
expect($event->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);
});
});

View file

@ -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);
});
});

View file

@ -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();
});
});

View file

@ -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<Event> */
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<Event> */
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();
});
});

View file

@ -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();
});
});

View file

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Infra\Listeners;
use Domain\Events\BrandRenamed;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Infra\Listeners\LogBrandRenamed;
use Mockery;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
covers(LogBrandRenamed::class);
describe('LogBrandRenamed Listener Unit Tests', function (): void {
it('logs informative message with event details when brand is renamed', function (): void {
// GIVEN
$brandId = new BrandId(Uuid::uuid7());
$oldName = new BrandName('Le Gaulois');
$newName = new BrandName('Le Gaulois France');
$brandCode = new BrandCode('LGL01');
$arcadiaId = new ArcadiaId(1);
$event = new BrandRenamed(
brandId: $brandId,
oldName: $oldName,
newName: $newName,
brandCode: $brandCode,
arcadiaId: $arcadiaId,
occurredAt: new \DateTimeImmutable(),
);
/** @var LoggerInterface&Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(LoggerInterface::class);
$loggerMock->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();
});
});