feat(event): implement BrandCreated event and listeners

- Added BrandCreated event to encapsulate brand creation details.
- Created LogBrandCreated listener to log brand creation events.
- Updated BrandResolver to dispatch BrandCreated event upon new brand creation.
- Introduced EventServiceProvider to register event listeners.
- Added unit tests for BrandCreated event and LogBrandCreated listener.
This commit is contained in:
Jérémy Munsch 2026-08-21 09:45:06 +02:00
parent 9c2c109d51
commit abe60df95f
9 changed files with 252 additions and 37 deletions

View file

@ -4,10 +4,12 @@ declare(strict_types=1);
use Infra\Providers\AppServiceProvider;
use Infra\Providers\DomainServiceProvider;
use Infra\Providers\EventServiceProvider;
use Infra\Providers\SecurityServiceProvider;
return [
AppServiceProvider::class,
DomainServiceProvider::class,
EventServiceProvider::class,
SecurityServiceProvider::class,
];

View file

@ -0,0 +1,23 @@
<?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 BrandCreated implements Event
{
public function __construct(
public BrandId $brandId,
public BrandName $brandName,
public BrandCode $brandCode,
public ArcadiaId $arcadiaId,
public \DateTimeImmutable $occurredAt,
) {
}
}

View file

@ -4,25 +4,22 @@ declare(strict_types=1);
namespace Domain\Services;
use Domain\Events\BrandCreated;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Domain\Ports\Directories\ArcadiaBrandDirectory;
use Domain\Ports\Repositories\BrandRepository;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use Shared\DDD\Event\DomainEventDispatcher;
final readonly class BrandResolver
{
private LoggerInterface $logger;
public function __construct(
private ArcadiaBrandDirectory $arcadiaBrandDirectory,
private BrandRepository $brandRepository,
private BrandCodeGenerator $brandCodeGenerator,
?LoggerInterface $logger = null,
private DomainEventDispatcher $dispatcher,
) {
$this->logger = $logger ?? new NullLogger();
}
/**
@ -56,20 +53,13 @@ final readonly class BrandResolver
$this->brandRepository->persist($brand);
$this->logger->info(
sprintf(
'Created new brand code "%s" for brand "%s" (Arcadia ID %d)',
$code->value,
$arcadiaBrand->name->value,
$arcadiaBrand->id->value
),
[
'brand_id' => $brand->id()->value,
'code' => $code->value,
'name' => $arcadiaBrand->name->value,
'arcadia_id' => $arcadiaBrand->id->value,
]
);
$this->dispatcher->dispatch(new BrandCreated(
brandId: $brand->id,
brandName: $brand->name,
brandCode: $brand->code,
arcadiaId: $brand->arcadiaId,
occurredAt: new \DateTimeImmutable(),
));
return $brand;
}

View file

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace Infra\Listeners;
use Domain\Events\BrandCreated;
use Psr\Log\LoggerInterface;
final readonly class LogBrandCreated
{
public function __construct(
private LoggerInterface $logger,
) {
}
public function handle(BrandCreated $event): void
{
$this->logger->info(
sprintf(
'Created new brand code "%s" for brand "%s" (Arcadia ID %d)',
$event->brandCode->value,
$event->brandName->value,
$event->arcadiaId->value
),
[
'brand_id' => $event->brandId->value,
'code' => $event->brandCode->value,
'name' => $event->brandName->value,
'arcadia_id' => $event->arcadiaId->value,
]
);
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Infra\Providers;
use Domain\Events\BrandCreated;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Infra\Listeners\LogBrandCreated;
class EventServiceProvider extends ServiceProvider
{
public function boot(): void
{
Event::listen(
BrandCreated::class,
LogBrandCreated::class,
);
}
}

View file

@ -18,8 +18,9 @@ use Domain\Services\BrandCodeGenerator;
use Domain\Services\BrandResolver;
use Infra\Adapters\Directories\InMemoryArcadiaBrandDirectory;
use Infra\Adapters\Repositories\InMemoryBrandRepository;
use Psr\Log\NullLogger;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Event\DomainEventDispatcher;
use Shared\DDD\Event\Event;
use Shared\Pagination;
covers(
@ -33,14 +34,19 @@ describe('FindAllBrands Usecase Unit Tests', function (): void {
$arcadiaDirectory = new InMemoryArcadiaBrandDirectory();
$brandRepository = new InMemoryBrandRepository();
$codeGenerator = new BrandCodeGenerator();
$logger = new NullLogger();
$dispatcher = new class () implements DomainEventDispatcher
{
public function dispatch(Event $event): void
{
}
};
beforeEach(function () use ($arcadiaDirectory, $brandRepository): void {
$arcadiaDirectory->clear();
$brandRepository->clear();
});
it('returns existing brands and generates missing ones automatically', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
it('returns existing brands and generates missing ones automatically', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $dispatcher): void {
// GIVEN
$arcadia1 = new ArcadiaBrand(new ArcadiaId(1), new BrandName('Le Gaulois'));
$arcadia2 = new ArcadiaBrand(new ArcadiaId(2), new BrandName('Maître CoQ'));
@ -55,7 +61,7 @@ describe('FindAllBrands Usecase Unit Tests', function (): void {
);
$brandRepository->add($existingBrand);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $dispatcher);
$usecase = new FindAllBrands($arcadiaDirectory, $resolver);
$input = new Input(Pagination::ofPage(1, 25));
@ -83,14 +89,14 @@ describe('FindAllBrands Usecase Unit Tests', function (): void {
expect($brandRepository->findByArcadiaId(new ArcadiaId(2)))->not->toBeNull();
});
it('slices brands according to pagination', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
it('slices brands according to pagination', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $dispatcher): void {
// GIVEN
$arcadia1 = new ArcadiaBrand(new ArcadiaId(1), new BrandName('Le Gaulois'));
$arcadia2 = new ArcadiaBrand(new ArcadiaId(2), new BrandName('Maître CoQ'));
$arcadia3 = new ArcadiaBrand(new ArcadiaId(3), new BrandName('Loué'));
$arcadiaDirectory->add($arcadia1, $arcadia2, $arcadia3);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $dispatcher);
$usecase = new FindAllBrands($arcadiaDirectory, $resolver);
$input = new Input(Pagination::ofPage(2, 2));
@ -105,9 +111,9 @@ describe('FindAllBrands Usecase Unit Tests', function (): void {
->and($result->perPage)->toBe(2);
});
it('returns an empty list when there are no arcadia brands', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
it('returns an empty list when there are no arcadia brands', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $dispatcher): void {
// GIVEN
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $dispatcher);
$usecase = new FindAllBrands($arcadiaDirectory, $resolver);
$input = new Input(Pagination::ofPage(1, 25));

View file

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Domain\Events;
use Domain\Events\BrandCreated;
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(BrandCreated::class);
describe('BrandCreated Domain Event Unit Tests', function (): void {
it('holds correct event data upon instantiation', function (): void {
// GIVEN
$brandId = new BrandId(Uuid::uuid7());
$brandName = new BrandName('Duc');
$brandCode = new BrandCode('DUC01');
$arcadiaId = new ArcadiaId(42);
$now = new \DateTimeImmutable();
// WHEN
$event = new BrandCreated(
brandId: $brandId,
brandName: $brandName,
brandCode: $brandCode,
arcadiaId: $arcadiaId,
occurredAt: $now,
);
// THEN
expect($event->brandId)->toBe($brandId)
->and($event->brandName)->toBe($brandName)
->and($event->brandCode)->toBe($brandCode)
->and($event->arcadiaId)->toBe($arcadiaId)
->and($event->occurredAt)->toBe($now);
});
});

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Tests\Unit\Domain\Services;
use Domain\Events\BrandCreated;
use Domain\Exceptions\BrandNotFoundException;
use Domain\Models\Entities\Brand;
use Domain\Models\ValueObjects\Brand\ArcadiaBrand;
@ -15,8 +16,9 @@ use Domain\Services\BrandCodeGenerator;
use Domain\Services\BrandResolver;
use Infra\Adapters\Directories\InMemoryArcadiaBrandDirectory;
use Infra\Adapters\Repositories\InMemoryBrandRepository;
use Psr\Log\NullLogger;
use Ramsey\Uuid\Uuid;
use Shared\DDD\Event\DomainEventDispatcher;
use Shared\DDD\Event\Event;
covers(BrandResolver::class);
@ -24,14 +26,13 @@ describe('BrandResolver Unit Tests', function (): void {
$arcadiaDirectory = new InMemoryArcadiaBrandDirectory();
$brandRepository = new InMemoryBrandRepository();
$codeGenerator = new BrandCodeGenerator();
$logger = new NullLogger();
beforeEach(function () use ($arcadiaDirectory, $brandRepository): void {
$arcadiaDirectory->clear();
$brandRepository->clear();
});
it('returns existing brand if already present in repository', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
it('returns existing brand if already present in repository without dispatching event', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator): void {
// GIVEN
$existing = Brand::hydrate(
id: new BrandId(Uuid::uuid7()),
@ -41,19 +42,43 @@ describe('BrandResolver Unit Tests', function (): void {
);
$brandRepository->add($existing);
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$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)->toBe($existing);
expect($resolved)->toBe($existing)
->and($dispatcher->dispatched)->toBeEmpty();
});
it('generates, persists and logs a new brand if not in repository but in Arcadia directory', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
it('generates, persists and dispatches BrandCreated event when brand is not in repository but in Arcadia directory', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator): void {
// GIVEN
$arcadiaDirectory->add(new ArcadiaBrand(new ArcadiaId(2), new BrandName('Maître CoQ')));
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$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(2));
@ -64,11 +89,28 @@ describe('BrandResolver Unit Tests', function (): void {
->and(str_starts_with($resolved->code->value, 'MCQ'))->toBeTrue();
expect($brandRepository->findByArcadiaId(new ArcadiaId(2)))->not->toBeNull();
expect($dispatcher->dispatched)->toHaveCount(1)
->and($dispatcher->dispatched[0])->toBeInstanceOf(BrandCreated::class);
/** @var BrandCreated $event */
$event = $dispatcher->dispatched[0];
expect($event->arcadiaId->value)->toBe(2)
->and($event->brandName->value)->toBe('Maître CoQ')
->and($event->brandCode->value)->toBe($resolved->code->value)
->and($event->brandId->value)->toBe($resolved->id->value);
});
it('throws BrandNotFoundException when resolving an unknown ArcadiaId', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator, $logger): void {
it('throws BrandNotFoundException when resolving an unknown ArcadiaId', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator): void {
// GIVEN
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $logger);
$dispatcher = new class () implements DomainEventDispatcher
{
public function dispatch(Event $event): void
{
}
};
$resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $dispatcher);
// WHEN & THEN
expect(fn () => $resolver->resolve(new ArcadiaId(999)))

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Infra\Listeners;
use Domain\Events\BrandCreated;
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\LogBrandCreated;
use Mockery;
use Psr\Log\LoggerInterface;
use Ramsey\Uuid\Uuid;
covers(LogBrandCreated::class);
describe('LogBrandCreated Listener Unit Tests', function (): void {
it('logs informative message with event details when brand is created', function (): void {
// GIVEN
$brandId = new BrandId(Uuid::uuid7());
$brandName = new BrandName('Le Gaulois');
$brandCode = new BrandCode('LGL01');
$arcadiaId = new ArcadiaId(1);
$event = new BrandCreated(
brandId: $brandId,
brandName: $brandName,
brandCode: $brandCode,
arcadiaId: $arcadiaId,
occurredAt: new \DateTimeImmutable(),
);
/** @var LoggerInterface&Mockery\MockInterface $loggerMock */
$loggerMock = Mockery::mock(LoggerInterface::class);
$loggerMock->shouldReceive('info')
->once()
->with(
'Created new brand code "LGL01" for brand "Le Gaulois" (Arcadia ID 1)',
[
'brand_id' => $brandId->value,
'code' => 'LGL01',
'name' => 'Le Gaulois',
'arcadia_id' => 1,
]
);
$listener = new LogBrandCreated($loggerMock);
// WHEN
$listener->handle($event);
// THEN
expect(true)->toBeTrue();
});
});