diff --git a/database/factories/BrandFactory.php b/database/factories/BrandFactory.php index 3aaff85..46a8b59 100644 --- a/database/factories/BrandFactory.php +++ b/database/factories/BrandFactory.php @@ -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, ]; diff --git a/database/migrations/2026_08_19_085607_create_brands_table.php b/database/migrations/2026_08_19_085607_create_brands_table.php index 3b86f0c..dd9dfb4 100644 --- a/database/migrations/2026_08_19_085607_create_brands_table.php +++ b/database/migrations/2026_08_19_085607_create_brands_table.php @@ -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() diff --git a/database/seeders/BrandSeeder.php b/database/seeders/BrandSeeder.php index 17a70c8..4b4037f 100644 --- a/database/seeders/BrandSeeder.php +++ b/database/seeders/BrandSeeder.php @@ -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'], ], ); diff --git a/src/Infra/Adapters/Repositories/EloquentBrandRepository.php b/src/Infra/Adapters/Repositories/EloquentBrandRepository.php index 3344a43..8c4be6f 100644 --- a/src/Infra/Adapters/Repositories/EloquentBrandRepository.php +++ b/src/Infra/Adapters/Repositories/EloquentBrandRepository.php @@ -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, ); diff --git a/src/Infra/Cache/BrandCache.php b/src/Infra/Cache/BrandCache.php new file mode 100644 index 0000000..0a9553b --- /dev/null +++ b/src/Infra/Cache/BrandCache.php @@ -0,0 +1,160 @@ +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|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; + } + } +} diff --git a/src/Infra/Database/Models/Brand.php b/src/Infra/Database/Models/Brand.php index 6e2c688..b493d8c 100644 --- a/src/Infra/Database/Models/Brand.php +++ b/src/Infra/Database/Models/Brand.php @@ -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 */ 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' - ); - } } diff --git a/src/Infra/Database/Relations/ArcadiaBrandBelongsTo.php b/src/Infra/Database/Relations/ArcadiaBrandBelongsTo.php deleted file mode 100644 index 313c4c5..0000000 --- a/src/Infra/Database/Relations/ArcadiaBrandBelongsTo.php +++ /dev/null @@ -1,27 +0,0 @@ - - */ -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; - } -} diff --git a/src/Infra/Listeners/InvalidateBrandCache.php b/src/Infra/Listeners/InvalidateBrandCache.php new file mode 100644 index 0000000..919fcae --- /dev/null +++ b/src/Infra/Listeners/InvalidateBrandCache.php @@ -0,0 +1,21 @@ +brandCache->reset(); + } +} diff --git a/src/Infra/Providers/DomainServiceProvider.php b/src/Infra/Providers/DomainServiceProvider.php index 302bfe3..36f1fc5 100644 --- a/src/Infra/Providers/DomainServiceProvider.php +++ b/src/Infra/Providers/DomainServiceProvider.php @@ -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); diff --git a/src/Infra/Providers/EventServiceProvider.php b/src/Infra/Providers/EventServiceProvider.php index a3a9382..352834d 100644 --- a/src/Infra/Providers/EventServiceProvider.php +++ b/src/Infra/Providers/EventServiceProvider.php @@ -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, diff --git a/tests/Integration/Database/Seeders/BrandSeederTest.php b/tests/Integration/Database/Seeders/BrandSeederTest.php index c6b3c56..2868356 100644 --- a/tests/Integration/Database/Seeders/BrandSeederTest.php +++ b/tests/Integration/Database/Seeders/BrandSeederTest.php @@ -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'); }); }); diff --git a/tests/Integration/Infra/Adapters/Repositories/EloquentBrandRepositoryTest.php b/tests/Integration/Infra/Adapters/Repositories/EloquentBrandRepositoryTest.php index e758291..31d0685 100644 --- a/tests/Integration/Infra/Adapters/Repositories/EloquentBrandRepositoryTest.php +++ b/tests/Integration/Infra/Adapters/Repositories/EloquentBrandRepositoryTest.php @@ -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'), diff --git a/tests/Unit/Infra/Cache/BrandCacheTest.php b/tests/Unit/Infra/Cache/BrandCacheTest.php new file mode 100644 index 0000000..e960365 --- /dev/null +++ b/tests/Unit/Infra/Cache/BrandCacheTest.php @@ -0,0 +1,244 @@ +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(); + }); +}); diff --git a/tests/Unit/Infra/Listeners/InvalidateBrandCacheTest.php b/tests/Unit/Infra/Listeners/InvalidateBrandCacheTest.php new file mode 100644 index 0000000..0de90f8 --- /dev/null +++ b/tests/Unit/Infra/Listeners/InvalidateBrandCacheTest.php @@ -0,0 +1,43 @@ +shouldReceive('reset') + ->once(); + + $listener = new InvalidateBrandCache($brandCacheMock); + + // WHEN + $listener->handle($event); + + // THEN + expect(true)->toBeTrue(); + }); +});