From daa8d1e0fa8cb043e4cd97ab82f112f92e5aa7e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Tue, 25 Aug 2026 08:26:58 +0200 Subject: [PATCH] refactor(cache): implement graceful degradation for BrandCache --- src/Infra/Cache/BrandCache.php | 109 ++++++++++++------ src/Infra/Providers/DomainServiceProvider.php | 3 +- tests/Unit/Infra/Cache/BrandCacheTest.php | 83 +++++++++++-- 3 files changed, 151 insertions(+), 44 deletions(-) diff --git a/src/Infra/Cache/BrandCache.php b/src/Infra/Cache/BrandCache.php index e02a1c8..217c6e0 100644 --- a/src/Infra/Cache/BrandCache.php +++ b/src/Infra/Cache/BrandCache.php @@ -20,22 +20,38 @@ final readonly class BrandCache public function __construct( private CacheInterface $cacheStore, + private \Psr\Log\LoggerInterface $logger, ) { } public function getById(BrandId $id): ?Brand { - return $this->fromCacheData($this->cacheStore->get($this->idKey($id))); + try { + return $this->fromCacheData($this->cacheStore->get($this->idKey($id))); + } catch (\Throwable $e) { + $this->logger->warning('BrandCache::getById failed', ['exception' => $e->getMessage()]); + return null; + } } public function getByCode(BrandCode $code): ?Brand { - return $this->fromCacheData($this->cacheStore->get($this->codeKey($code))); + try { + return $this->fromCacheData($this->cacheStore->get($this->codeKey($code))); + } catch (\Throwable $e) { + $this->logger->warning('BrandCache::getByCode failed', ['exception' => $e->getMessage()]); + return null; + } } public function getByArcadiaId(ArcadiaId $arcadiaId): ?Brand { - return $this->fromCacheData($this->cacheStore->get($this->arcadiaKey($arcadiaId))); + try { + return $this->fromCacheData($this->cacheStore->get($this->arcadiaKey($arcadiaId))); + } catch (\Throwable $e) { + $this->logger->warning('BrandCache::getByArcadiaId failed', ['exception' => $e->getMessage()]); + return null; + } } /** @@ -43,65 +59,86 @@ final readonly class BrandCache */ public function getAll(): ?array { - $data = $this->cacheStore->get(self::KEY_ALL); + try { + $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 (! is_array($data)) { + return null; } - } - if (count($brands) !== count($data) || empty($brands)) { + $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; + } catch (\Throwable $e) { + $this->logger->warning('BrandCache::getAll failed', ['exception' => $e->getMessage()]); return null; } - - return $brands; } public function put(Brand $brand): void { - $data = $this->toCacheData($brand); + try { + $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); + $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); + } catch (\Throwable $e) { + $this->logger->warning('BrandCache::put failed', ['exception' => $e->getMessage()]); + } } 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); + try { + $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); + foreach ($brands as $brand) { + $this->put($brand); + } + } catch (\Throwable $e) { + $this->logger->warning('BrandCache::putAll failed', ['exception' => $e->getMessage()]); } } 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)); + try { + $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)); + } catch (\Throwable $e) { + $this->logger->warning('BrandCache::invalidate failed', ['exception' => $e->getMessage()]); + } } public function reset(): void { - $this->cacheStore->delete(self::KEY_ALL); + try { + $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)); + $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)); + } } + } catch (\Throwable $e) { + $this->logger->warning('BrandCache::reset failed', ['exception' => $e->getMessage()]); } } diff --git a/src/Infra/Providers/DomainServiceProvider.php b/src/Infra/Providers/DomainServiceProvider.php index 44eb7e6..daadb7e 100644 --- a/src/Infra/Providers/DomainServiceProvider.php +++ b/src/Infra/Providers/DomainServiceProvider.php @@ -45,8 +45,9 @@ class DomainServiceProvider extends ServiceProvider $this->app->bind(BrandCache::class, static function (Application $app): BrandCache { $storageConfig = $app->make(ArcadiaStorageConfig::class); $cache = Cache::store($storageConfig->cacheStore); + $logger = $app->make(\Psr\Log\LoggerInterface::class); - return new BrandCache($cache); + return new BrandCache($cache, $logger); }); $this->app->scoped(LaravelTransactionalDomainEventDispatcher::class); diff --git a/tests/Unit/Infra/Cache/BrandCacheTest.php b/tests/Unit/Infra/Cache/BrandCacheTest.php index c9e4523..43d2d96 100644 --- a/tests/Unit/Infra/Cache/BrandCacheTest.php +++ b/tests/Unit/Infra/Cache/BrandCacheTest.php @@ -36,12 +36,16 @@ describe('BrandCache Unit Tests', function (): void { ->with("brand:id:{$uuid}") ->andReturn($cacheData); - $brandCache = new BrandCache($cacheMock); + /** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */ + $loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class); + $loggerMock->shouldReceive('warning')->zeroOrMoreTimes(); + $brandCache = new BrandCache($cacheMock, $loggerMock); // WHEN $result = $brandCache->getById($brandId); // THEN + assert($result !== null); expect($result)->not->toBeNull() ->and($result->id()->value)->toBe($brandId->value) ->and($result->name->value)->toBe('Le Gaulois') @@ -68,12 +72,16 @@ describe('BrandCache Unit Tests', function (): void { ->with('brand:code:LGL01') ->andReturn($cacheData); - $brandCache = new BrandCache($cacheMock); + /** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */ + $loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class); + $loggerMock->shouldReceive('warning')->zeroOrMoreTimes(); + $brandCache = new BrandCache($cacheMock, $loggerMock); // WHEN $result = $brandCache->getByCode($brandCode); // THEN + assert($result !== null); expect($result)->not->toBeNull() ->and($result->code->value)->toBe('LGL01') ->and($result->name->value)->toBe('Le Gaulois'); @@ -98,12 +106,16 @@ describe('BrandCache Unit Tests', function (): void { ->with('brand:arcadia:10') ->andReturn($cacheData); - $brandCache = new BrandCache($cacheMock); + /** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */ + $loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class); + $loggerMock->shouldReceive('warning')->zeroOrMoreTimes(); + $brandCache = new BrandCache($cacheMock, $loggerMock); // WHEN $result = $brandCache->getByArcadiaId($arcadiaId); // THEN + assert($result !== null); expect($result)->not->toBeNull() ->and($result->arcadiaId->value)->toBe('10') ->and($result->name->value)->toBe('MaƮtre CoQ'); @@ -120,7 +132,10 @@ describe('BrandCache Unit Tests', function (): void { ->with("brand:id:{$brandId->value}") ->andReturn('corrupted_string'); - $brandCache = new BrandCache($cacheMock); + /** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */ + $loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class); + $loggerMock->shouldReceive('warning')->zeroOrMoreTimes(); + $brandCache = new BrandCache($cacheMock, $loggerMock); // WHEN $result = $brandCache->getById($brandId); @@ -158,7 +173,10 @@ describe('BrandCache Unit Tests', function (): void { ->once() ->with('brand:arcadia:20', $expectedData, 86400); - $brandCache = new BrandCache($cacheMock); + /** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */ + $loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class); + $loggerMock->shouldReceive('warning')->zeroOrMoreTimes(); + $brandCache = new BrandCache($cacheMock, $loggerMock); // WHEN $brandCache->put($brand); @@ -192,7 +210,10 @@ describe('BrandCache Unit Tests', function (): void { ->once() ->with('brand:arcadia:30'); - $brandCache = new BrandCache($cacheMock); + /** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */ + $loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class); + $loggerMock->shouldReceive('warning')->zeroOrMoreTimes(); + $brandCache = new BrandCache($cacheMock, $loggerMock); // WHEN $brandCache->invalidate($brand); @@ -233,7 +254,10 @@ describe('BrandCache Unit Tests', function (): void { ->once() ->with('brand:arcadia:1'); - $brandCache = new BrandCache($cacheMock); + /** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */ + $loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class); + $loggerMock->shouldReceive('warning')->zeroOrMoreTimes(); + $brandCache = new BrandCache($cacheMock, $loggerMock); // WHEN $brandCache->reset(); @@ -241,4 +265,49 @@ describe('BrandCache Unit Tests', function (): void { // THEN expect(true)->toBeTrue(); }); + it('returns null and logs warning when getById throws exception', function (): void { + // GIVEN + $brandId = new BrandId(Uuid::uuid7()); + /** @var CacheInterface&\Mockery\MockInterface $cacheMock */ + $cacheMock = Mockery::mock(CacheInterface::class); + $cacheMock->shouldReceive('get')->andThrow(new \RuntimeException('Redis error')); + + /** @var \Psr\Log\LoggerInterface&\Mockery\MockInterface $loggerMock */ + $loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class); + $loggerMock->shouldReceive('warning')->once(); + + $brandCache = new BrandCache($cacheMock, $loggerMock); + + // WHEN + $result = $brandCache->getById($brandId); + + // THEN + expect($result)->toBeNull(); + }); + + it('logs warning and does not throw when put throws exception', function (): void { + // GIVEN + $brand = Brand::hydrate( + id: new BrandId(Uuid::uuid7()), + name: new BrandName('Ghost'), + code: new BrandCode('GHO01'), + arcadiaId: new ArcadiaId('999'), + ); + /** @var CacheInterface&\Mockery\MockInterface $cacheMock */ + $cacheMock = Mockery::mock(CacheInterface::class); + $cacheMock->shouldReceive('set')->andThrow(new \RuntimeException('Redis error')); + + /** @var \Psr\Log\LoggerInterface&\Mockery\MockInterface $loggerMock */ + $loggerMock = Mockery::mock(\Psr\Log\LoggerInterface::class); + $loggerMock->shouldReceive('warning')->once(); + + $brandCache = new BrandCache($cacheMock, $loggerMock); + + // WHEN + $brandCache->put($brand); + + // THEN + // No exception is thrown + expect(true)->toBeTrue(); + }); });