diff --git a/src/Application/Arcadia/Usecase/Brands/FindAll/FindAllBrands.php b/src/Application/Arcadia/Usecase/Brands/FindAll/FindAllBrands.php index 25822fa..c5af7bc 100644 --- a/src/Application/Arcadia/Usecase/Brands/FindAll/FindAllBrands.php +++ b/src/Application/Arcadia/Usecase/Brands/FindAll/FindAllBrands.php @@ -28,7 +28,7 @@ final readonly class FindAllBrands implements Usecase $brandItems = []; foreach ($paginated->getItems() as $arcadiaBrand) { - $brand = $this->brandResolver->resolve($arcadiaBrand->id); + $brand = $this->brandResolver->resolveFromArcadia($arcadiaBrand); $brandItems[] = BrandItem::fromDomain($brand); } diff --git a/src/Domain/Services/BrandResolver.php b/src/Domain/Services/BrandResolver.php index eaa7df8..13ed561 100644 --- a/src/Domain/Services/BrandResolver.php +++ b/src/Domain/Services/BrandResolver.php @@ -8,6 +8,7 @@ use Domain\Events\BrandCreated; use Domain\Events\BrandRenamed; use Domain\Exceptions\BrandNotFoundException; use Domain\Models\Entities\Brand; +use Domain\Models\ValueObjects\Brand\ArcadiaBrand; use Domain\Models\ValueObjects\Brand\ArcadiaId; use Domain\Ports\Directories\ArcadiaBrandDirectory; use Domain\Ports\Repositories\BrandRepository; @@ -31,10 +32,30 @@ 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)) { + return $existingBrand; + } + + $arcadiaBrand = $this->arcadiaBrandDirectory->find($arcadiaBrandCode); + + if ($arcadiaBrand === null) { + throw BrandNotFoundException::forArcadiaId($arcadiaBrandCode); + } + + return $this->resolveFromArcadia($arcadiaBrand); + } + + /** + * Resolve an existing or new Brand entity from an ArcadiaBrand VO. + * Handles name updates (BrandRenamed) and creation (BrandCreated). + */ + public function resolveFromArcadia(ArcadiaBrand $arcadiaBrand): Brand + { + $existingBrand = $this->brandRepository->findByArcadiaId($arcadiaBrand->id); + + if ($existingBrand !== null) { + if (! $existingBrand->name->equals($arcadiaBrand->name)) { $oldName = $existingBrand->name; $existingBrand = $existingBrand->rename($arcadiaBrand->name); @@ -53,10 +74,6 @@ final readonly class BrandResolver return $existingBrand; } - if ($arcadiaBrand === null) { - throw BrandNotFoundException::forArcadiaId($arcadiaBrandCode); - } - do { $code = $this->brandCodeGenerator->generate($arcadiaBrand->name); } while ($this->brandRepository->findByCode($code) !== null); diff --git a/src/Infra/Adapters/Directories/ArcadiaMarqueBrandDirectory.php b/src/Infra/Adapters/Directories/ArcadiaMarqueBrandDirectory.php index b2fb20b..2ce46fa 100644 --- a/src/Infra/Adapters/Directories/ArcadiaMarqueBrandDirectory.php +++ b/src/Infra/Adapters/Directories/ArcadiaMarqueBrandDirectory.php @@ -7,10 +7,13 @@ namespace Infra\Adapters\Directories; use Database\ArcadiaReplicate\Tables\Refp\MarqueTable; use Domain\Models\ValueObjects\Brand\ArcadiaBrand; use Domain\Models\ValueObjects\Brand\ArcadiaId; +use Domain\Models\ValueObjects\Brand\BrandName; use Domain\Ports\Directories\ArcadiaBrandDirectory as ArcadiaBrandDirectoryPort; use Illuminate\Database\Connection; use Illuminate\Database\Query\Builder; use Infra\Adapters\Mappers\ArcadiaBrandMapper; +use Infra\Cache\BrandCache; +use Infra\Database\Models\Brand as BrandModel; use Shared\Paginated; use Shared\Pagination; @@ -21,40 +24,57 @@ final class ArcadiaMarqueBrandDirectory implements ArcadiaBrandDirectoryPort public function __construct( private readonly Connection $connection, + private readonly ?BrandCache $brandCache = null, ) { } #[\Override] public function findAll(Pagination $pagination): Paginated { - $query = $this->connection - ->table(MarqueTable::table($this->connection)) - ->where('COD_MARQ', '>', 0) - ->where('LIB_MARQ', 'NOT LIKE', 'Z -%') - ->where(static function (Builder $q): void { - $q->whereNull('A_EPURER')->orWhere('A_EPURER', '!=', 1); - }) - ->where(static function (Builder $q): void { - $q->whereNull('TYPE_MARQUE')->orWhere('TYPE_MARQUE', '!=', 99); - }); - - $total = (int) (clone $query)->count(); - - $rows = (clone $query) - ->orderBy('COD_MARQ', 'asc') - ->offset($pagination->from()) - ->limit($pagination->pageSize()) - ->get(); - - $items = []; - - foreach ($rows as $row) { - $domain = ArcadiaBrandMapper::toDomain($row); - $this->cache[$domain->id->value] = $domain; - $items[] = $domain; + if ($this->brandCache !== null) { + $cached = $this->brandCache->getArcadiaPaginated($pagination); + if ($cached !== null) { + return $cached; + } } - return new Paginated($pagination, $items, $total); + try { + $query = $this->connection + ->table(MarqueTable::table($this->connection)) + ->where('COD_MARQ', '>', 0) + ->where('LIB_MARQ', 'NOT LIKE', 'Z -%') + ->where(static function (Builder $q): void { + $q->whereNull('A_EPURER')->orWhere('A_EPURER', '!=', 1); + }) + ->where(static function (Builder $q): void { + $q->whereNull('TYPE_MARQUE')->orWhere('TYPE_MARQUE', '!=', 99); + }); + + $total = (int) (clone $query)->count(); + + $rows = (clone $query) + ->orderBy('COD_MARQ', 'asc') + ->offset($pagination->from()) + ->limit($pagination->pageSize()) + ->get(); + + $items = []; + + foreach ($rows as $row) { + $domain = ArcadiaBrandMapper::toDomain($row); + $this->cache[$domain->id->value] = $domain; + $items[] = $domain; + } + + /** @var list $items */ + $paginated = new Paginated($pagination, $items, $total); + + $this->brandCache?->putArcadiaPaginated($pagination, $paginated); + + return $paginated; + } catch (\Throwable $e) { + return $this->fallbackFindAll($pagination, $e); + } } #[\Override] @@ -64,26 +84,83 @@ final class ArcadiaMarqueBrandDirectory implements ArcadiaBrandDirectoryPort return $this->cache[$id->value]; } - $row = $this->connection - ->table(MarqueTable::table($this->connection)) - ->where('COD_MARQ', $id->value) - ->where('COD_MARQ', '>', 0) - ->where('LIB_MARQ', 'NOT LIKE', 'Z -%') - ->where(static function (Builder $q): void { - $q->whereNull('A_EPURER')->orWhere('A_EPURER', '!=', 1); - }) - ->where(static function (Builder $q): void { - $q->whereNull('TYPE_MARQUE')->orWhere('TYPE_MARQUE', '!=', 99); - }) + try { + $row = $this->connection + ->table(MarqueTable::table($this->connection)) + ->where('COD_MARQ', $id->value) + ->where('COD_MARQ', '>', 0) + ->where('LIB_MARQ', 'NOT LIKE', 'Z -%') + ->where(static function (Builder $q): void { + $q->whereNull('A_EPURER')->orWhere('A_EPURER', '!=', 1); + }) + ->where(static function (Builder $q): void { + $q->whereNull('TYPE_MARQUE')->orWhere('TYPE_MARQUE', '!=', 99); + }) + ->first(); + + if ($row === null) { + $this->cache[$id->value] = null; + + return null; + } + + $domain = ArcadiaBrandMapper::toDomain($row); + $this->cache[$id->value] = $domain; + + return $domain; + } catch (\Throwable) { + return $this->fallbackFind($id); + } + } + + /** + * @return Paginated + */ + private function fallbackFindAll(Pagination $pagination, \Throwable $originalException): Paginated + { + $query = BrandModel::query()->orderBy('name', 'asc'); + $total = $query->count(); + + if ($total === 0) { + throw $originalException; + } + + $models = $query + ->offset($pagination->from()) + ->limit($pagination->pageSize()) + ->get(); + + $items = []; + foreach ($models as $model) { + $arcadiaId = $model->arcadia_id instanceof ArcadiaId + ? $model->arcadia_id + : new ArcadiaId((string) $model->arcadia_id); + + $items[] = new ArcadiaBrand( + $arcadiaId, + new BrandName($model->name) + ); + } + + /** @var list $items */ + return new Paginated($pagination, $items, $total); + } + + private function fallbackFind(ArcadiaId $id): ?ArcadiaBrand + { + $model = BrandModel::query() + ->where('arcadia_id', $id->value) ->first(); - if ($row === null) { - $this->cache[$id->value] = null; - + if ($model === null) { return null; } - $domain = ArcadiaBrandMapper::toDomain($row); + $arcadiaId = $model->arcadia_id instanceof ArcadiaId + ? $model->arcadia_id + : new ArcadiaId((string) $model->arcadia_id); + + $domain = new ArcadiaBrand($arcadiaId, new BrandName($model->name)); $this->cache[$id->value] = $domain; return $domain; diff --git a/src/Infra/Cache/BrandCache.php b/src/Infra/Cache/BrandCache.php index 3a2c811..cb0336c 100644 --- a/src/Infra/Cache/BrandCache.php +++ b/src/Infra/Cache/BrandCache.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Infra\Cache; use Domain\Models\Entities\Brand; +use Domain\Models\ValueObjects\Brand\ArcadiaBrand; use Domain\Models\ValueObjects\Brand\ArcadiaId; use Domain\Models\ValueObjects\Brand\BrandCode; use Domain\Models\ValueObjects\Brand\BrandId; @@ -12,6 +13,8 @@ use Domain\Models\ValueObjects\Brand\BrandName; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use Ramsey\Uuid\Uuid; +use Shared\Paginated; +use Shared\Pagination; final readonly class BrandCache { @@ -90,6 +93,60 @@ final readonly class BrandCache } } + /** + * @return Paginated|null + */ + public function getArcadiaPaginated(Pagination $pagination): ?Paginated + { + try { + $data = $this->cacheStore->get($this->paginationKey($pagination)); + + if (! is_array($data) || ! isset($data['total'], $data['items']) || ! is_int($data['total']) || ! is_array($data['items'])) { + return null; + } + + $items = []; + foreach ($data['items'] as $item) { + if (! is_array($item) || ! isset($item['id'], $item['name']) || ! is_string($item['id']) || ! is_string($item['name'])) { + return null; + } + $items[] = new ArcadiaBrand( + new ArcadiaId($item['id']), + new BrandName($item['name']) + ); + } + + /** @var list $items */ + return new Paginated($pagination, $items, $data['total']); + } catch (\Throwable $e) { + $this->logger->warning('BrandCache::getArcadiaPaginated failed', ['exception' => $e->getMessage()]); + + return null; + } + } + + /** + * @param Paginated $paginated + */ + public function putArcadiaPaginated(Pagination $pagination, Paginated $paginated): void + { + try { + $items = array_map(static fn (ArcadiaBrand $brand): array => [ + 'id' => $brand->id->value, + 'name' => $brand->name->value, + ], $paginated->getItems()); + + $data = [ + 'total' => $paginated->getTotal(), + 'items' => $items, + ]; + + $this->cacheStore->set($this->paginationKey($pagination), $data, self::CACHE_TTL_SECONDS); + } catch (\Throwable $e) { + $this->logger->warning('BrandCache::putArcadiaPaginated failed', ['exception' => $e->getMessage()]); + } + } + public function put(Brand $brand): void { try { @@ -163,6 +220,11 @@ final readonly class BrandCache return "brand:arcadia:{$arcadiaId->value}"; } + private function paginationKey(Pagination $pagination): string + { + return "brand:arcadia:page:{$pagination->pageNumber()}:size:{$pagination->pageSize()}"; + } + /** * @return array{id: string, name: string, code: string, arcadiaId: string} */ diff --git a/src/Infra/Providers/DomainServiceProvider.php b/src/Infra/Providers/DomainServiceProvider.php index 2f6fe5c..d8809f0 100644 --- a/src/Infra/Providers/DomainServiceProvider.php +++ b/src/Infra/Providers/DomainServiceProvider.php @@ -85,7 +85,10 @@ class DomainServiceProvider extends ServiceProvider ); $this->app->bind( ArcadiaBrandDirectory::class, - fn (Application $app) => new ArcadiaMarqueBrandDirectory($this->getArcadiaConnection($app)) + fn (Application $app) => new ArcadiaMarqueBrandDirectory( + $this->getArcadiaConnection($app), + $app->make(BrandCache::class) + ) ); $this->app->bind(BrandRepository::class, EloquentBrandRepository::class); $this->app->bind(ClientAuditLogDirectory::class, EloquentClientAuditLogDirectory::class); diff --git a/tests/Integration/Infra/Adapters/Directories/ArcadiaMarqueBrandDirectoryTest.php b/tests/Integration/Infra/Adapters/Directories/ArcadiaMarqueBrandDirectoryTest.php new file mode 100644 index 0000000..ec1e714 --- /dev/null +++ b/tests/Integration/Infra/Adapters/Directories/ArcadiaMarqueBrandDirectoryTest.php @@ -0,0 +1,119 @@ +delete(); +}); + +describe('ArcadiaMarqueBrandDirectory Integration Tests', function (): void { + it('finds brands and caches the paginated result', function (): void { + // GIVEN + $conn = DB::connection(); + $brandCache = resolve(BrandCache::class); + $directory = new ArcadiaMarqueBrandDirectory($conn, $brandCache); + + MarqueTable::up($conn->getSchemaBuilder()); + + $conn->table(MarqueTable::table($conn))->insert([ + 'COD_MARQ' => 1, + 'LIB_MARQ' => 'Le Gaulois', + 'A_EPURER' => 0, + 'TYPE_MARQUE' => 1, + ]); + $conn->table(MarqueTable::table($conn))->insert([ + 'COD_MARQ' => 2, + 'LIB_MARQ' => 'Maître Coq', + 'A_EPURER' => 0, + 'TYPE_MARQUE' => 1, + ]); + + $pagination = Pagination::ofPage(1, 25); + + // WHEN + $result = $directory->findAll($pagination); + + // THEN + expect($result->getItems())->toHaveCount(2) + ->and($result->getTotal())->toBe(2) + ->and($result->getItems()[0]->name->value)->toBe('Le Gaulois') + ->and($result->getItems()[1]->name->value)->toBe('Maître Coq'); + + // Check it was cached + $cached = $brandCache->getArcadiaPaginated($pagination); + expect($cached)->not->toBeNull() + ->and($cached->getItems())->toHaveCount(2); + }); + + it('falls back to local BrandModel when database connection fails during findAll', function (): void { + // GIVEN + BrandModel::query()->create([ + 'id' => (string) Uuid::uuid7(), + 'name' => 'Local Gaulois', + 'arcadia_id' => new ArcadiaId('1'), + 'uwil_public_id' => new BrandCode('LGL01'), + ]); + + /** @var Connection&Mockery\MockInterface $failingConnection */ + $failingConnection = Mockery::mock(Connection::class); + $failingConnection->shouldReceive('table') + ->andThrow(new \RuntimeException('Oracle is down')); + + $directory = new ArcadiaMarqueBrandDirectory($failingConnection, null); + $pagination = Pagination::ofPage(1, 25); + + // WHEN + $result = $directory->findAll($pagination); + + // THEN + expect($result->getItems())->toHaveCount(1) + ->and($result->getItems()[0]->id->value)->toBe('1') + ->and($result->getItems()[0]->name->value)->toBe('Local Gaulois'); + }); + + it('falls back to local BrandModel when database connection fails during find', function (): void { + // GIVEN + BrandModel::query()->create([ + 'id' => (string) Uuid::uuid7(), + 'name' => 'Fallback Brand', + 'arcadia_id' => new ArcadiaId('42'), + 'uwil_public_id' => new BrandCode('FLB01'), + ]); + + /** @var Connection&Mockery\MockInterface $failingConnection */ + $failingConnection = Mockery::mock(Connection::class); + $failingConnection->shouldReceive('table') + ->andThrow(new \RuntimeException('Oracle is down')); + + $directory = new ArcadiaMarqueBrandDirectory($failingConnection, null); + + // WHEN + $found = $directory->find(new ArcadiaId('42')); + + // THEN + expect($found)->not->toBeNull() + ->and($found->id->value)->toBe('42') + ->and($found->name->value)->toBe('Fallback Brand'); + }); +}); diff --git a/tests/Unit/Domain/Services/BrandResolverTest.php b/tests/Unit/Domain/Services/BrandResolverTest.php index 730e2b4..4ce0797 100644 --- a/tests/Unit/Domain/Services/BrandResolverTest.php +++ b/tests/Unit/Domain/Services/BrandResolverTest.php @@ -5,7 +5,6 @@ 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; @@ -20,6 +19,7 @@ use Infra\Adapters\Repositories\InMemoryBrandRepository; use Ramsey\Uuid\Uuid; use Shared\DDD\Event\DomainEventDispatcher; use Shared\DDD\Event\Event; +use Domain\Events\BrandRenamed; covers(BrandResolver::class); @@ -118,7 +118,7 @@ describe('BrandResolver Unit Tests', function (): void { ->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 { + it('returns existing brand directly without querying Arcadia directory when already in repository', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator): void { // GIVEN $brandId = new BrandId(Uuid::uuid7()); $oldBrand = Brand::hydrate( @@ -129,7 +129,7 @@ describe('BrandResolver Unit Tests', function (): void { ); $brandRepository->add($oldBrand); - // Arcadia has renamed the brand + // Arcadia directory has a different name, but resolve() prioritizes local repository without extra Oracle calls $arcadiaDirectory->add(new ArcadiaBrand(new ArcadiaId('1'), new BrandName('Le Gaulois France'))); $dispatcher = new class () implements DomainEventDispatcher @@ -149,22 +149,9 @@ describe('BrandResolver Unit Tests', function (): void { $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); + expect($resolved)->toBe($oldBrand) + ->and($resolved->name->value)->toBe('Le Gaulois') + ->and($dispatcher->dispatched)->toBeEmpty(); }); it('preserves and returns existing brand when brand was removed from Arcadia directory', function () use ($arcadiaDirectory, $brandRepository, $codeGenerator): void { @@ -202,4 +189,52 @@ describe('BrandResolver Unit Tests', function (): void { ->and($resolved->name->value)->toBe('Historical Brand') ->and($dispatcher->dispatched)->toBeEmpty(); }); + + it('updates brand name, persists and dispatches BrandRenamed when resolving from ArcadiaBrand with changed name', 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); + + $renamedArcadia = new ArcadiaBrand(new ArcadiaId('1'), new BrandName('Le Gaulois France')); + + $dispatcher = new class () implements DomainEventDispatcher + { + /** @var list */ + public array $dispatched = []; + + public function dispatch(Event $event): void + { + $this->dispatched[] = $event; + } + }; + + $resolver = new BrandResolver($arcadiaDirectory, $brandRepository, $codeGenerator, $dispatcher); + + // WHEN + $resolved = $resolver->resolveFromArcadia($renamedArcadia); + + // 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); + }); }); diff --git a/tests/Unit/Infra/Cache/BrandCacheTest.php b/tests/Unit/Infra/Cache/BrandCacheTest.php index 329aac3..6acfba6 100644 --- a/tests/Unit/Infra/Cache/BrandCacheTest.php +++ b/tests/Unit/Infra/Cache/BrandCacheTest.php @@ -14,6 +14,9 @@ use Mockery; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; use Ramsey\Uuid\Uuid; +use Domain\Models\ValueObjects\Brand\ArcadiaBrand; +use Shared\Paginated; +use Shared\Pagination; covers(BrandCache::class); @@ -311,4 +314,49 @@ describe('BrandCache Unit Tests', function (): void { // No exception is thrown expect(true)->toBeTrue(); }); + + it('gets and puts arcadia paginated results to cache', function (): void { + // GIVEN + $pagination = Pagination::ofPage(1, 25); + $brand1 = new ArcadiaBrand(new ArcadiaId('1'), new BrandName('Brand One')); + $brand2 = new ArcadiaBrand(new ArcadiaId('2'), new BrandName('Brand Two')); + $paginated = new Paginated($pagination, [$brand1, $brand2], 2); + + $expectedData = [ + 'total' => 2, + 'items' => [ + ['id' => '1', 'name' => 'Brand One'], + ['id' => '2', 'name' => 'Brand Two'], + ], + ]; + + /** @var CacheInterface&Mockery\MockInterface $cacheMock */ + $cacheMock = Mockery::mock(CacheInterface::class); + $cacheMock->shouldReceive('set') + ->once() + ->with('brand:arcadia:page:1:size:25', $expectedData, 86400); + + $cacheMock->shouldReceive('get') + ->once() + ->with('brand:arcadia:page:1:size:25') + ->andReturn($expectedData); + + /** @var \Psr\Log\LoggerInterface&Mockery\MockInterface $loggerMock */ + $loggerMock = Mockery::mock(LoggerInterface::class); + $loggerMock->shouldReceive('warning')->zeroOrMoreTimes(); + $brandCache = new BrandCache($cacheMock, $loggerMock); + + // WHEN + $brandCache->putArcadiaPaginated($pagination, $paginated); + $result = $brandCache->getArcadiaPaginated($pagination); + + // THEN + expect($result)->not->toBeNull() + ->and($result->getTotal())->toBe(2) + ->and($result->getItems())->toHaveCount(2) + ->and($result->getItems()[0]->id->value)->toBe('1') + ->and($result->getItems()[0]->name->value)->toBe('Brand One') + ->and($result->getItems()[1]->id->value)->toBe('2') + ->and($result->getItems()[1]->name->value)->toBe('Brand Two'); + }); });