feat(brand): handle on-demand brand renaming with BrandRenamed event and historical preservation
This commit is contained in:
parent
cc368c7102
commit
709568f1ed
13 changed files with 364 additions and 4 deletions
24
src/Domain/Events/BrandRenamed.php
Normal file
24
src/Domain/Events/BrandRenamed.php
Normal 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
36
src/Infra/Listeners/LogBrandRenamed.php
Normal file
36
src/Infra/Listeners/LogBrandRenamed.php
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
44
tests/Unit/Domain/Events/BrandRenamedTest.php
Normal file
44
tests/Unit/Domain/Events/BrandRenamedTest.php
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
59
tests/Unit/Infra/Listeners/LogBrandRenamedTest.php
Normal file
59
tests/Unit/Infra/Listeners/LogBrandRenamedTest.php
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue