feat(brand): add dedicated BrandCache service, event invalidation, and persist brand name locally

This commit is contained in:
Jérémy Munsch 2026-08-21 11:27:32 +02:00
parent dc83df4105
commit cc368c7102
14 changed files with 550 additions and 169 deletions

View file

@ -24,11 +24,13 @@ final class BrandFactory extends Factory
#[\Override]
public function definition(): array
{
$name = fake()->company();
$generator = new BrandCodeGenerator();
$code = $generator->generate(new BrandName(fake()->company()));
$code = $generator->generate(new BrandName($name));
return [
'id' => Uuid::uuid7()->toString(),
'name' => $name,
'arcadia_id' => ArcadiaBrand::factory(),
'uwil_public_id' => $code->value,
];

View file

@ -15,7 +15,7 @@ return new class () extends Migration
{
Schema::create('brands', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('name')->comment('The human readable brand name');
$table->string('uwil_public_id')->unique()
->comment('A unique public id that is generated spefically for the UWIL eco system');
$table->bigInteger('arcadia_id')->unique()

View file

@ -36,6 +36,7 @@ final class BrandSeeder extends Seeder
['arcadia_id' => $brandData['code']],
[
'id' => Uuid::uuid7()->toString(),
'name' => $brandData['libelle'],
'uwil_public_id' => $brandData['uwil_public_id'],
],
);

View file

@ -11,34 +11,33 @@ use Domain\Models\ValueObjects\Brand\BrandCode;
use Domain\Models\ValueObjects\Brand\BrandId;
use Domain\Models\ValueObjects\Brand\BrandName;
use Domain\Ports\Repositories\BrandRepository;
use Illuminate\Support\Facades\Cache;
use Infra\Cache\BrandCache;
use Infra\Database\Models\Brand as BrandModel;
use Ramsey\Uuid\Uuid;
final class EloquentBrandRepository implements BrandRepository
final readonly class EloquentBrandRepository implements BrandRepository
{
private const int CACHE_TTL_SECONDS = 86400;
public function __construct(
private BrandCache $brandCache,
) {
}
#[\Override]
public function find(BrandId $id): ?Brand
{
$key = "brand:id:{$id->value}";
$brand = $this->fromCacheData(Cache::get($key));
if ($brand !== null) {
return $brand;
$cached = $this->brandCache->getById($id);
if ($cached !== null) {
return $cached;
}
$model = BrandModel::query()
->with('arcadiaBrand')
->find($id->value);
$model = BrandModel::query()->find($id->value);
if ($model === null) {
return null;
}
$brand = $this->toDomain($model);
Cache::put($key, $this->toCacheData($brand), self::CACHE_TTL_SECONDS);
$this->brandCache->put($brand);
return $brand;
}
@ -46,15 +45,12 @@ final class EloquentBrandRepository implements BrandRepository
#[\Override]
public function findByCode(BrandCode $code): ?Brand
{
$key = "brand:code:{$code->value}";
$brand = $this->fromCacheData(Cache::get($key));
if ($brand !== null) {
return $brand;
$cached = $this->brandCache->getByCode($code);
if ($cached !== null) {
return $cached;
}
$model = BrandModel::query()
->with('arcadiaBrand')
->where('uwil_public_id', $code->value)
->first();
@ -63,7 +59,7 @@ final class EloquentBrandRepository implements BrandRepository
}
$brand = $this->toDomain($model);
Cache::put($key, $this->toCacheData($brand), self::CACHE_TTL_SECONDS);
$this->brandCache->put($brand);
return $brand;
}
@ -71,15 +67,12 @@ final class EloquentBrandRepository implements BrandRepository
#[\Override]
public function findByArcadiaId(ArcadiaId $arcadiaId): ?Brand
{
$key = "brand:arcadia:{$arcadiaId->value}";
$brand = $this->fromCacheData(Cache::get($key));
if ($brand !== null) {
return $brand;
$cached = $this->brandCache->getByArcadiaId($arcadiaId);
if ($cached !== null) {
return $cached;
}
$model = BrandModel::query()
->with('arcadiaBrand')
->where('arcadia_id', $arcadiaId->value)
->first();
@ -88,7 +81,7 @@ final class EloquentBrandRepository implements BrandRepository
}
$brand = $this->toDomain($model);
Cache::put($key, $this->toCacheData($brand), self::CACHE_TTL_SECONDS);
$this->brandCache->put($brand);
return $brand;
}
@ -96,32 +89,19 @@ final class EloquentBrandRepository implements BrandRepository
#[\Override]
public function findAll(): array
{
$key = 'brand:all';
$data = Cache::get($key);
if (is_array($data)) {
$brands = [];
foreach ($data as $item) {
$brand = $this->fromCacheData($item);
if ($brand !== null) {
$brands[] = $brand;
}
}
if (! empty($brands) && count($brands) === count($data)) {
return $brands;
}
$cached = $this->brandCache->getAll();
if ($cached !== null) {
return $cached;
}
$brands = array_values(
BrandModel::query()
->with('arcadiaBrand')
->get()
->map(fn (BrandModel $model): Brand => $this->toDomain($model))
->all()
);
$cachedList = array_map(fn (Brand $brand): array => $this->toCacheData($brand), $brands);
Cache::put($key, $cachedList, self::CACHE_TTL_SECONDS);
$this->brandCache->putAll(...$brands);
return $brands;
}
@ -132,12 +112,13 @@ final class EloquentBrandRepository implements BrandRepository
BrandModel::query()->updateOrCreate(
['id' => $entity->id()->value],
[
'name' => $entity->name->value,
'arcadia_id' => $entity->arcadiaId,
'uwil_public_id' => $entity->code,
]
);
$this->invalidateCache($entity);
$this->brandCache->invalidate($entity);
}
#[\Override]
@ -151,62 +132,14 @@ final class EloquentBrandRepository implements BrandRepository
throw BrandNotFoundException::forBrand($entity->id());
}
$this->invalidateCache($entity);
}
private function invalidateCache(Brand $entity): void
{
Cache::forget('brand:all');
Cache::forget("brand:id:{$entity->id()->value}");
Cache::forget("brand:code:{$entity->code->value}");
Cache::forget("brand:arcadia:{$entity->arcadiaId->value}");
}
/**
* @return array{id: string, name: string, code: string, arcadiaId: int}
*/
private function toCacheData(Brand $brand): array
{
return [
'id' => (string) $brand->id()->value,
'name' => $brand->name->value,
'code' => $brand->code->value,
'arcadiaId' => $brand->arcadiaId->value,
];
}
private function fromCacheData(mixed $data): ?Brand
{
if (
! is_array($data)
|| ! isset($data['id'], $data['name'], $data['code'], $data['arcadiaId'])
|| ! is_string($data['id'])
|| ! is_string($data['name'])
|| ! is_string($data['code'])
|| ! is_int($data['arcadiaId'])
) {
return null;
}
try {
return Brand::hydrate(
id: new BrandId(Uuid::fromString($data['id'])),
name: new BrandName($data['name']),
code: new BrandCode($data['code']),
arcadiaId: new ArcadiaId($data['arcadiaId']),
);
} catch (\Throwable) {
return null;
}
$this->brandCache->invalidate($entity);
}
private function toDomain(BrandModel $model): Brand
{
$name = $model->arcadiaBrand !== null ? $model->arcadiaBrand->libelle : '';
return Brand::hydrate(
id: new BrandId(Uuid::fromString($model->id)),
name: new BrandName($name),
name: new BrandName($model->name),
code: $model->uwil_public_id,
arcadiaId: $model->arcadia_id,
);

View file

@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace Infra\Cache;
use Domain\Models\Entities\Brand;
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 Psr\SimpleCache\CacheInterface;
use Ramsey\Uuid\Uuid;
final readonly class BrandCache
{
private const int CACHE_TTL_SECONDS = 86400;
private const string KEY_ALL = 'brand:all';
public function __construct(
private CacheInterface $cacheStore,
) {
}
public function getById(BrandId $id): ?Brand
{
return $this->fromCacheData($this->cacheStore->get($this->idKey($id)));
}
public function getByCode(BrandCode $code): ?Brand
{
return $this->fromCacheData($this->cacheStore->get($this->codeKey($code)));
}
public function getByArcadiaId(ArcadiaId $arcadiaId): ?Brand
{
return $this->fromCacheData($this->cacheStore->get($this->arcadiaKey($arcadiaId)));
}
/**
* @return list<Brand>|null
*/
public function getAll(): ?array
{
$data = $this->cacheStore->get(self::KEY_ALL);
if (! is_array($data)) {
return null;
}
$brands = [];
foreach ($data as $item) {
$brand = $this->fromCacheData($item);
if ($brand !== null) {
$brands[] = $brand;
}
}
if (count($brands) !== count($data) || empty($brands)) {
return null;
}
return $brands;
}
public function put(Brand $brand): void
{
$data = $this->toCacheData($brand);
$this->cacheStore->set($this->idKey($brand->id()), $data, self::CACHE_TTL_SECONDS);
$this->cacheStore->set($this->codeKey($brand->code), $data, self::CACHE_TTL_SECONDS);
$this->cacheStore->set($this->arcadiaKey($brand->arcadiaId), $data, self::CACHE_TTL_SECONDS);
}
public function putAll(Brand ...$brands): void
{
$cachedList = array_map(fn (Brand $brand): array => $this->toCacheData($brand), $brands);
$this->cacheStore->set(self::KEY_ALL, $cachedList, self::CACHE_TTL_SECONDS);
foreach ($brands as $brand) {
$this->put($brand);
}
}
public function invalidate(Brand $brand): void
{
$this->cacheStore->delete(self::KEY_ALL);
$this->cacheStore->delete($this->idKey($brand->id()));
$this->cacheStore->delete($this->codeKey($brand->code));
$this->cacheStore->delete($this->arcadiaKey($brand->arcadiaId));
}
public function reset(): void
{
$this->cacheStore->delete(self::KEY_ALL);
$all = $this->getAll();
if ($all !== null) {
foreach ($all as $brand) {
$this->cacheStore->delete($this->idKey($brand->id()));
$this->cacheStore->delete($this->codeKey($brand->code));
$this->cacheStore->delete($this->arcadiaKey($brand->arcadiaId));
}
}
}
private function idKey(BrandId $id): string
{
return "brand:id:{$id->value}";
}
private function codeKey(BrandCode $code): string
{
return "brand:code:{$code->value}";
}
private function arcadiaKey(ArcadiaId $arcadiaId): string
{
return "brand:arcadia:{$arcadiaId->value}";
}
/**
* @return array{id: string, name: string, code: string, arcadiaId: int}
*/
private function toCacheData(Brand $brand): array
{
return [
'id' => (string) $brand->id()->value,
'name' => $brand->name->value,
'code' => $brand->code->value,
'arcadiaId' => $brand->arcadiaId->value,
];
}
private function fromCacheData(mixed $data): ?Brand
{
if (
! is_array($data)
|| ! isset($data['id'], $data['name'], $data['code'], $data['arcadiaId'])
|| ! is_string($data['id'])
|| ! is_string($data['name'])
|| ! is_string($data['code'])
|| ! is_int($data['arcadiaId'])
) {
return null;
}
try {
return Brand::hydrate(
id: new BrandId(Uuid::fromString($data['id'])),
name: new BrandName($data['name']),
code: new BrandCode($data['code']),
arcadiaId: new ArcadiaId($data['arcadiaId']),
);
} catch (\Throwable) {
return null;
}
}
}

View file

@ -8,18 +8,16 @@ use Database\Factories\BrandFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Infra\Casts\ArcadiaIdCast;
use Infra\Casts\BrandCodeCast;
use Infra\Database\Relations\ArcadiaBrandBelongsTo;
/**
* @phpcs:disable Generic.Files.LineLength.TooLong
*
* @property string $id
* @property string $name The human readable brand name
* @property \Domain\Models\ValueObjects\Brand\BrandCode $uwil_public_id A unique public id that is generated spefically for the UWIL eco system
* @property \Domain\Models\ValueObjects\Brand\ArcadiaId $arcadia_id An unique external autoincrement id for the brand, it may be exposed but should not be used
* @property-read \Infra\Database\Models\ArcadiaBrand|null $arcadiaBrand
*
* @method static \Database\Factories\BrandFactory factory($count = null, $state = [])
*
@ -44,6 +42,7 @@ final class Brand extends Model
/** @var list<string> */
protected $fillable = [
'id',
'name',
'arcadia_id',
'uwil_public_id',
];
@ -57,20 +56,4 @@ final class Brand extends Model
'uwil_public_id' => BrandCodeCast::class,
];
}
/**
* @return ArcadiaBrandBelongsTo
*/
public function arcadiaBrand(): BelongsTo
{
$instance = $this->newRelatedInstance(ArcadiaBrand::class);
return new ArcadiaBrandBelongsTo(
$instance->newQuery(),
$this,
'arcadia_id',
'code',
'arcadiaBrand'
);
}
}

View file

@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace Infra\Database\Relations;
use Domain\Models\ValueObjects\Brand\ArcadiaId;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @extends BelongsTo<\Infra\Database\Models\ArcadiaBrand, \Infra\Database\Models\Brand>
*/
final class ArcadiaBrandBelongsTo extends BelongsTo
{
#[\Override]
protected function getForeignKeyFrom(Model $model): mixed
{
$foreignKey = parent::getForeignKeyFrom($model);
if ($foreignKey instanceof ArcadiaId) {
return $foreignKey->value;
}
return $foreignKey;
}
}

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Infra\Listeners;
use Domain\Events\BrandCreated;
use Infra\Cache\BrandCache;
final readonly class InvalidateBrandCache
{
public function __construct(
private BrandCache $brandCache,
) {
}
public function handle(BrandCreated $_): void
{
$this->brandCache->reset();
}
}

View file

@ -10,6 +10,8 @@ use Domain\Ports\Directories\IncoCompositionDirectory;
use Domain\Ports\Directories\IncoSheetDirectory;
use Domain\Ports\Directories\IncoShortSheetDirectory;
use Domain\Ports\Repositories\BrandRepository;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\ServiceProvider;
use Infra\Adapters\Directories\EloquentArcadiaBrandDirectory;
use Infra\Adapters\Directories\EloquentEmployeeDirectory;
@ -18,6 +20,7 @@ use Infra\Adapters\Directories\EloquentIncoSheetDirectory;
use Infra\Adapters\Directories\EloquentIncoShortSheetDirectory;
use Infra\Adapters\Events\LaravelTransactionalDomainEventDispatcher;
use Infra\Adapters\Repositories\EloquentBrandRepository;
use Infra\Cache\BrandCache;
use Infra\Usecase\LaravelExecutor;
use Shared\DDD\Event\DomainEventDispatcher;
use Shared\DDD\Event\TransactionalDomainEventDispatcher;
@ -35,6 +38,14 @@ class DomainServiceProvider extends ServiceProvider
$this->app->bind(ArcadiaBrandDirectory::class, EloquentArcadiaBrandDirectory::class);
$this->app->bind(BrandRepository::class, EloquentBrandRepository::class);
$this->app->bind(BrandCache::class, static function (): BrandCache {
$storeName = Config::string('cache.arcadia_store');
/** @var \Psr\SimpleCache\CacheInterface $cache */
$cache = Cache::store($storeName);
return new BrandCache($cache);
});
$this->app->scoped(LaravelTransactionalDomainEventDispatcher::class);
$this->app->bind(DomainEventDispatcher::class, LaravelTransactionalDomainEventDispatcher::class);
$this->app->bind(TransactionalDomainEventDispatcher::class, LaravelTransactionalDomainEventDispatcher::class);

View file

@ -10,6 +10,7 @@ use Application\Security\Events\ClientStatusToggled;
use Domain\Events\BrandCreated;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Infra\Listeners\InvalidateBrandCache;
use Infra\Listeners\LogBrandCreated;
use Infra\Listeners\LogClientSecretRotated;
use Infra\Listeners\LogClientStatusChanged;
@ -23,6 +24,11 @@ class EventServiceProvider extends ServiceProvider
LogBrandCreated::class,
);
Event::listen(
BrandCreated::class,
InvalidateBrandCache::class,
);
Event::listen(
ClientSecretGenerated::class,
LogClientSecretRotated::class,

View file

@ -29,7 +29,6 @@ describe('BrandSeeder Integration Test', function (): void {
$brand = Brand::query()->where('uwil_public_id', 'LGL01')->first();
expect($brand)->not->toBeNull()
->and($brand?->arcadia_id->value)->toBe(1)
->and($brand?->arcadiaBrand)->not->toBeNull()
->and($brand?->arcadiaBrand?->libelle)->toBe('Le Gaulois');
->and($brand?->name)->toBe('Le Gaulois');
});
});

View file

@ -13,6 +13,7 @@ use Domain\Models\ValueObjects\Brand\BrandName;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Infra\Adapters\Repositories\EloquentBrandRepository;
use Infra\Cache\BrandCache;
use Infra\Database\Models\ArcadiaBrand;
use Infra\Database\Models\Brand as BrandModel;
use Ramsey\Uuid\Uuid;
@ -30,18 +31,13 @@ beforeEach(function (): void {
describe('EloquentBrandRepository Integration Tests', function (): void {
it('persists a new brand into database and invalidates cache', function (): void {
// GIVEN
$repository = new EloquentBrandRepository();
$brandCache = resolve(BrandCache::class);
$repository = new EloquentBrandRepository($brandCache);
$brandId = new BrandId(Uuid::uuid7());
$brandCode = new BrandCode('LGL01');
$arcadiaId = new ArcadiaId(1);
$name = new BrandName('Le Gaulois');
ArcadiaBrand::query()->firstOrCreate([
'code' => 1,
], [
'libelle' => 'Le Gaulois',
]);
$brand = Brand::hydrate(
id: $brandId,
name: $name,
@ -63,12 +59,13 @@ describe('EloquentBrandRepository Integration Tests', function (): void {
it('finds a brand by BrandId and serves subsequent calls from cache', function (): void {
// GIVEN
$repository = new EloquentBrandRepository();
$brandCache = resolve(BrandCache::class);
$repository = new EloquentBrandRepository($brandCache);
$rawId = Uuid::uuid7()->toString();
ArcadiaBrand::query()->firstOrCreate(['code' => 10], ['libelle' => 'Maître CoQ']);
BrandModel::query()->create([
'id' => $rawId,
'name' => 'Maître CoQ',
'arcadia_id' => 10,
'uwil_public_id' => 'MCQ01',
]);
@ -91,17 +88,19 @@ describe('EloquentBrandRepository Integration Tests', function (): void {
expect($firstResult)->not->toBeNull()
->and($secondResult)->not->toBeNull()
->and($secondResult->id()->value)->toBe($brandId->value)
->and($secondResult->code->value)->toBe('MCQ01');
->and($secondResult->code->value)->toBe('MCQ01')
->and($secondResult->name->value)->toBe('Maître CoQ');
});
it('finds a brand by BrandCode and serves subsequent calls from cache', function (): void {
// GIVEN
$repository = new EloquentBrandRepository();
$brandCache = resolve(BrandCache::class);
$repository = new EloquentBrandRepository($brandCache);
$rawId = Uuid::uuid7()->toString();
ArcadiaBrand::query()->firstOrCreate(['code' => 20], ['libelle' => 'Doux']);
BrandModel::query()->create([
'id' => $rawId,
'name' => 'Doux',
'arcadia_id' => 20,
'uwil_public_id' => 'DOX01',
]);
@ -118,17 +117,19 @@ describe('EloquentBrandRepository Integration Tests', function (): void {
// THEN
expect($firstResult)->not->toBeNull()
->and($secondResult)->not->toBeNull()
->and($secondResult->code->value)->toBe('DOX01');
->and($secondResult->code->value)->toBe('DOX01')
->and($secondResult->name->value)->toBe('Doux');
});
it('finds a brand by ArcadiaId and serves subsequent calls from cache', function (): void {
// GIVEN
$repository = new EloquentBrandRepository();
$brandCache = resolve(BrandCache::class);
$repository = new EloquentBrandRepository($brandCache);
$rawId = Uuid::uuid7()->toString();
ArcadiaBrand::query()->firstOrCreate(['code' => 30], ['libelle' => 'Père Dodu']);
BrandModel::query()->create([
'id' => $rawId,
'name' => 'Père Dodu',
'arcadia_id' => 30,
'uwil_public_id' => 'PRD01',
]);
@ -145,23 +146,24 @@ describe('EloquentBrandRepository Integration Tests', function (): void {
// THEN
expect($firstResult)->not->toBeNull()
->and($secondResult)->not->toBeNull()
->and($secondResult->arcadiaId->value)->toBe(30);
->and($secondResult->arcadiaId->value)->toBe(30)
->and($secondResult->name->value)->toBe('Père Dodu');
});
it('finds all brands and caches the list', function (): void {
// GIVEN
$repository = new EloquentBrandRepository();
ArcadiaBrand::query()->firstOrCreate(['code' => 1], ['libelle' => 'Brand 1']);
ArcadiaBrand::query()->firstOrCreate(['code' => 2], ['libelle' => 'Brand 2']);
$brandCache = resolve(BrandCache::class);
$repository = new EloquentBrandRepository($brandCache);
BrandModel::query()->create([
'id' => Uuid::uuid7()->toString(),
'name' => 'Brand 1',
'arcadia_id' => 1,
'uwil_public_id' => 'BRD01',
]);
BrandModel::query()->create([
'id' => Uuid::uuid7()->toString(),
'name' => 'Brand 2',
'arcadia_id' => 2,
'uwil_public_id' => 'BRD02',
]);
@ -176,12 +178,13 @@ describe('EloquentBrandRepository Integration Tests', function (): void {
it('gracefully recovers when cache contains corrupted or non-array data', function (): void {
// GIVEN
$repository = new EloquentBrandRepository();
$brandCache = resolve(BrandCache::class);
$repository = new EloquentBrandRepository($brandCache);
$rawId = Uuid::uuid7()->toString();
ArcadiaBrand::query()->firstOrCreate(['code' => 40], ['libelle' => 'Fermiers d\'Ancenis']);
BrandModel::query()->create([
'id' => $rawId,
'name' => "Fermiers d'Ancenis",
'arcadia_id' => 40,
'uwil_public_id' => 'FDA01',
]);
@ -211,12 +214,13 @@ describe('EloquentBrandRepository Integration Tests', function (): void {
it('deletes an existing brand and invalidates cache', function (): void {
// GIVEN
$repository = new EloquentBrandRepository();
$brandCache = resolve(BrandCache::class);
$repository = new EloquentBrandRepository($brandCache);
$rawId = Uuid::uuid7()->toString();
ArcadiaBrand::query()->firstOrCreate(['code' => 50], ['libelle' => 'To Delete']);
BrandModel::query()->create([
'id' => $rawId,
'name' => 'To Delete',
'arcadia_id' => 50,
'uwil_public_id' => 'DEL01',
]);
@ -244,7 +248,8 @@ describe('EloquentBrandRepository Integration Tests', function (): void {
it('throws BrandNotFoundException when deleting non-existent brand', function (): void {
// GIVEN
$repository = new EloquentBrandRepository();
$brandCache = resolve(BrandCache::class);
$repository = new EloquentBrandRepository($brandCache);
$brand = Brand::hydrate(
id: new BrandId(Uuid::uuid7()),
name: new BrandName('Ghost'),

View file

@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Infra\Cache;
use Domain\Models\Entities\Brand;
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\Cache\BrandCache;
use Mockery;
use Psr\SimpleCache\CacheInterface;
use Ramsey\Uuid\Uuid;
covers(BrandCache::class);
describe('BrandCache Unit Tests', function (): void {
it('returns brand from cache when found by id', function (): void {
// GIVEN
$uuid = Uuid::uuid7()->toString();
$brandId = new BrandId(Uuid::fromString($uuid));
$cacheData = [
'id' => $uuid,
'name' => 'Le Gaulois',
'code' => 'LGL01',
'arcadiaId' => 1,
];
/** @var CacheInterface&Mockery\MockInterface $cacheMock */
$cacheMock = Mockery::mock(CacheInterface::class);
$cacheMock->shouldReceive('get')
->once()
->with("brand:id:{$uuid}")
->andReturn($cacheData);
$brandCache = new BrandCache($cacheMock);
// WHEN
$result = $brandCache->getById($brandId);
// THEN
expect($result)->not->toBeNull()
->and($result->id()->value)->toBe($brandId->value)
->and($result->name->value)->toBe('Le Gaulois')
->and($result->code->value)->toBe('LGL01')
->and($result->arcadiaId->value)->toBe(1);
});
it('returns brand from cache when found by code', function (): void {
// GIVEN
$uuid = Uuid::uuid7()->toString();
$brandCode = new BrandCode('LGL01');
$cacheData = [
'id' => $uuid,
'name' => 'Le Gaulois',
'code' => 'LGL01',
'arcadiaId' => 1,
];
/** @var CacheInterface&Mockery\MockInterface $cacheMock */
$cacheMock = Mockery::mock(CacheInterface::class);
$cacheMock->shouldReceive('get')
->once()
->with('brand:code:LGL01')
->andReturn($cacheData);
$brandCache = new BrandCache($cacheMock);
// WHEN
$result = $brandCache->getByCode($brandCode);
// THEN
expect($result)->not->toBeNull()
->and($result->code->value)->toBe('LGL01')
->and($result->name->value)->toBe('Le Gaulois');
});
it('returns brand from cache when found by arcadia id', function (): void {
// GIVEN
$uuid = Uuid::uuid7()->toString();
$arcadiaId = new ArcadiaId(10);
$cacheData = [
'id' => $uuid,
'name' => 'Maître CoQ',
'code' => 'MCQ01',
'arcadiaId' => 10,
];
/** @var CacheInterface&Mockery\MockInterface $cacheMock */
$cacheMock = Mockery::mock(CacheInterface::class);
$cacheMock->shouldReceive('get')
->once()
->with('brand:arcadia:10')
->andReturn($cacheData);
$brandCache = new BrandCache($cacheMock);
// WHEN
$result = $brandCache->getByArcadiaId($arcadiaId);
// THEN
expect($result)->not->toBeNull()
->and($result->arcadiaId->value)->toBe(10)
->and($result->name->value)->toBe('Maître CoQ');
});
it('returns null and recovers gracefully when cache data is corrupted', function (): void {
// GIVEN
$brandId = new BrandId(Uuid::uuid7());
/** @var CacheInterface&Mockery\MockInterface $cacheMock */
$cacheMock = Mockery::mock(CacheInterface::class);
$cacheMock->shouldReceive('get')
->once()
->with("brand:id:{$brandId->value}")
->andReturn('corrupted_string');
$brandCache = new BrandCache($cacheMock);
// WHEN
$result = $brandCache->getById($brandId);
// THEN
expect($result)->toBeNull();
});
it('stores brand in cache across all key variations', function (): void {
// GIVEN
$uuid = Uuid::uuid7()->toString();
$brand = Brand::hydrate(
id: new BrandId(Uuid::fromString($uuid)),
name: new BrandName('Doux'),
code: new BrandCode('DOX01'),
arcadiaId: new ArcadiaId(20),
);
$expectedData = [
'id' => $uuid,
'name' => 'Doux',
'code' => 'DOX01',
'arcadiaId' => 20,
];
/** @var CacheInterface&Mockery\MockInterface $cacheMock */
$cacheMock = Mockery::mock(CacheInterface::class);
$cacheMock->shouldReceive('set')
->once()
->with("brand:id:{$uuid}", $expectedData, 86400);
$cacheMock->shouldReceive('set')
->once()
->with('brand:code:DOX01', $expectedData, 86400);
$cacheMock->shouldReceive('set')
->once()
->with('brand:arcadia:20', $expectedData, 86400);
$brandCache = new BrandCache($cacheMock);
// WHEN
$brandCache->put($brand);
// THEN
expect(true)->toBeTrue();
});
it('invalidates specific brand keys and collection key', function (): void {
// GIVEN
$uuid = Uuid::uuid7()->toString();
$brand = Brand::hydrate(
id: new BrandId(Uuid::fromString($uuid)),
name: new BrandName('Père Dodu'),
code: new BrandCode('PRD01'),
arcadiaId: new ArcadiaId(30),
);
/** @var CacheInterface&Mockery\MockInterface $cacheMock */
$cacheMock = Mockery::mock(CacheInterface::class);
$cacheMock->shouldReceive('delete')
->once()
->with('brand:all');
$cacheMock->shouldReceive('delete')
->once()
->with("brand:id:{$uuid}");
$cacheMock->shouldReceive('delete')
->once()
->with('brand:code:PRD01');
$cacheMock->shouldReceive('delete')
->once()
->with('brand:arcadia:30');
$brandCache = new BrandCache($cacheMock);
// WHEN
$brandCache->invalidate($brand);
// THEN
expect(true)->toBeTrue();
});
it('resets all brand caches when reset is called', function (): void {
// GIVEN
$uuid = Uuid::uuid7()->toString();
$cacheData = [
[
'id' => $uuid,
'name' => 'Brand 1',
'code' => 'BRD01',
'arcadiaId' => 1,
],
];
/** @var CacheInterface&Mockery\MockInterface $cacheMock */
$cacheMock = Mockery::mock(CacheInterface::class);
$cacheMock->shouldReceive('get')
->once()
->with('brand:all')
->andReturn($cacheData);
$cacheMock->shouldReceive('delete')
->once()
->with('brand:all');
$cacheMock->shouldReceive('delete')
->once()
->with("brand:id:{$uuid}");
$cacheMock->shouldReceive('delete')
->once()
->with('brand:code:BRD01');
$cacheMock->shouldReceive('delete')
->once()
->with('brand:arcadia:1');
$brandCache = new BrandCache($cacheMock);
// WHEN
$brandCache->reset();
// THEN
expect(true)->toBeTrue();
});
});

View file

@ -0,0 +1,43 @@
<?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\Cache\BrandCache;
use Infra\Listeners\InvalidateBrandCache;
use Mockery;
use Ramsey\Uuid\Uuid;
covers(InvalidateBrandCache::class);
describe('InvalidateBrandCache Listener Unit Tests', function (): void {
it('resets brand cache when BrandCreated event is handled', function (): void {
// GIVEN
$event = new BrandCreated(
brandId: new BrandId(Uuid::uuid7()),
brandName: new BrandName('Le Gaulois'),
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();
});
});