diff --git a/packages/api/src/Domain/Products/JsonApi/Filters/CollectionGroupFilter.php b/packages/api/src/Domain/Products/JsonApi/Filters/CollectionGroupFilter.php new file mode 100644 index 000000000..9b4ba272b --- /dev/null +++ b/packages/api/src/Domain/Products/JsonApi/Filters/CollectionGroupFilter.php @@ -0,0 +1,89 @@ +name = $name; + } + + public static function make(string $name): self + { + return new static($name); + } + + public function key(): string + { + return $this->name; + } + + public function isSingular(): bool + { + return false; + } + + /** + * Apply the filter to the query. + * + * @param Builder $query + * @param mixed $value + * @return Builder + */ + public function apply($query, $value) + { + if (! is_array($value)) { + return $query; + } + + foreach ($value as $groupHandle => $filters) { + if (! is_array($filters) || empty($filters['id'])) { + continue; + } + + $ids = is_string($filters['id']) + ? array_filter(explode(',', $filters['id']), fn ($id) => $id !== '') + : (array) $filters['id']; + + if (empty($ids)) { + continue; + } + + $query->whereHas('collections', function ($q) use ($groupHandle, $ids) { + $q->whereHas('group', function ($q) use ($groupHandle) { + $q->where( + $q->getModel()->qualifyColumn('handle'), + $groupHandle, + ); + }); + + $q->whereIn( + $q->getModel()->qualifyColumn('id'), + $ids, + ); + }); + } + + return $query; + } +} diff --git a/packages/api/src/Domain/Products/JsonApi/V1/ProductSchema.php b/packages/api/src/Domain/Products/JsonApi/V1/ProductSchema.php index 801cb8e5c..54c4d25b4 100644 --- a/packages/api/src/Domain/Products/JsonApi/V1/ProductSchema.php +++ b/packages/api/src/Domain/Products/JsonApi/V1/ProductSchema.php @@ -7,6 +7,7 @@ use Dystore\Api\Domain\JsonApi\Eloquent\Sorts\InDefaultOrder; use Dystore\Api\Domain\JsonApi\Eloquent\Sorts\InRandomOrder; use Dystore\Api\Domain\Products\Builders\ProductBuilder; +use Dystore\Api\Domain\Products\JsonApi\Filters\CollectionGroupFilter; use Dystore\Api\Domain\Products\JsonApi\Filters\InStockFilter; use Dystore\Api\Domain\Products\JsonApi\Filters\ProductFilterCollection; use Dystore\Api\Support\Models\Actions\SchemaType; @@ -317,6 +318,8 @@ public function filters(): array WhereHas::make($this, 'collections'), + CollectionGroupFilter::make('collection_groups'), + WhereHas::make($this, 'tags'), ...(new ProductFilterCollection)->toArray(), diff --git a/tests/api/Feature/Domain/Products/JsonApi/V1/CollectionGroupFilterTest.php b/tests/api/Feature/Domain/Products/JsonApi/V1/CollectionGroupFilterTest.php new file mode 100644 index 000000000..d58e838c4 --- /dev/null +++ b/tests/api/Feature/Domain/Products/JsonApi/V1/CollectionGroupFilterTest.php @@ -0,0 +1,242 @@ +group('products', 'filters'); + +it('can filter products by a single collection group', function () { + /** @var TestCase $this */ + $group = CollectionGroup::factory()->create(['handle' => 'category']); + + $collectionA = Collection::factory()->create(['collection_group_id' => $group->id]); + $collectionB = Collection::factory()->create(['collection_group_id' => $group->id]); + + $matchingProduct = Product::factory() + ->has(ProductVariant::factory()->has(Price::factory()), 'variants') + ->create(); + $matchingProduct->collections()->attach([$collectionA->id]); + + $nonMatchingProduct = Product::factory() + ->has(ProductVariant::factory()->has(Price::factory()), 'variants') + ->create(); + + $response = $this + ->jsonApi() + ->expects('products') + ->get(serverUrl("/products?filter[collection_groups][category][id]={$collectionA->id}")); + + $response + ->assertSuccessful() + ->assertFetchedMany([$matchingProduct]) + ->assertDoesntHaveIncluded(); +}); + +it('can filter products by multiple collection ids within a group using OR logic', function () { + /** @var TestCase $this */ + $group = CollectionGroup::factory()->create(['handle' => 'category']); + + $collectionA = Collection::factory()->create(['collection_group_id' => $group->id]); + $collectionB = Collection::factory()->create(['collection_group_id' => $group->id]); + $collectionC = Collection::factory()->create(['collection_group_id' => $group->id]); + + $productA = Product::factory() + ->has(ProductVariant::factory()->has(Price::factory()), 'variants') + ->create(); + $productA->collections()->attach([$collectionA->id]); + + $productB = Product::factory() + ->has(ProductVariant::factory()->has(Price::factory()), 'variants') + ->create(); + $productB->collections()->attach([$collectionB->id]); + + $productC = Product::factory() + ->has(ProductVariant::factory()->has(Price::factory()), 'variants') + ->create(); + $productC->collections()->attach([$collectionC->id]); + + $ids = implode(',', [$collectionA->id, $collectionB->id]); + + $response = $this + ->jsonApi() + ->expects('products') + ->get(serverUrl("/products?filter[collection_groups][category][id]={$ids}")); + + $response + ->assertSuccessful() + ->assertFetchedMany([$productA, $productB]) + ->assertDoesntHaveIncluded(); +}); + +it('can filter products across multiple collection groups with AND logic', function () { + /** @var TestCase $this */ + $groupCategory = CollectionGroup::factory()->create(['handle' => 'category']); + $groupSeason = CollectionGroup::factory()->create(['handle' => 'season']); + + $categoryA = Collection::factory()->create(['collection_group_id' => $groupCategory->id]); + $seasonWinter = Collection::factory()->create(['collection_group_id' => $groupSeason->id]); + $seasonSummer = Collection::factory()->create(['collection_group_id' => $groupSeason->id]); + + // Product in categoryA AND seasonWinter — should match + $matchingProduct = Product::factory() + ->has(ProductVariant::factory()->has(Price::factory()), 'variants') + ->create(); + $matchingProduct->collections()->attach([$categoryA->id, $seasonWinter->id]); + + // Product in categoryA but NOT in seasonWinter — should NOT match + $categoryOnlyProduct = Product::factory() + ->has(ProductVariant::factory()->has(Price::factory()), 'variants') + ->create(); + $categoryOnlyProduct->collections()->attach([$categoryA->id, $seasonSummer->id]); + + // Product in seasonWinter but NOT in categoryA — should NOT match + $seasonOnlyProduct = Product::factory() + ->has(ProductVariant::factory()->has(Price::factory()), 'variants') + ->create(); + $seasonOnlyProduct->collections()->attach([$seasonWinter->id]); + + $response = $this + ->jsonApi() + ->expects('products') + ->get(serverUrl("/products?filter[collection_groups][category][id]={$categoryA->id}&filter[collection_groups][season][id]={$seasonWinter->id}")); + + $response + ->assertSuccessful() + ->assertFetchedMany([$matchingProduct]) + ->assertDoesntHaveIncluded(); +}); + +it('returns all products when collection_groups filter value is empty', function () { + /** @var TestCase $this */ + $products = Product::factory() + ->has(ProductVariant::factory()->has(Price::factory()), 'variants') + ->count(2) + ->create(); + + $response = $this + ->jsonApi() + ->expects('products') + ->get(serverUrl('/products')); + + $response + ->assertSuccessful() + ->assertFetchedMany($products); +}); + +it('returns no products when filtering by a nonexistent group handle', function () { + /** @var TestCase $this */ + $group = CollectionGroup::factory()->create(['handle' => 'category']); + $collection = Collection::factory()->create(['collection_group_id' => $group->id]); + + $product = Product::factory() + ->has(ProductVariant::factory()->has(Price::factory()), 'variants') + ->create(); + $product->collections()->attach([$collection->id]); + + $response = $this + ->jsonApi() + ->expects('products') + ->get(serverUrl("/products?filter[collection_groups][nonexistent-handle][id]={$collection->id}")); + + $response + ->assertSuccessful() + ->assertFetchedNone(); +}); + +it('ignores groups with empty collection IDs and returns all products', function () { + /** @var TestCase $this */ + $group = CollectionGroup::factory()->create(['handle' => 'category']); + $collection = Collection::factory()->create(['collection_group_id' => $group->id]); + + $products = Product::factory() + ->has(ProductVariant::factory()->has(Price::factory()), 'variants') + ->count(2) + ->create(); + $products->first()->collections()->attach([$collection->id]); + + $response = $this + ->jsonApi() + ->expects('products') + ->get(serverUrl('/products?filter[collection_groups][category][id]=')); + + $response + ->assertSuccessful() + ->assertFetchedMany($products); +}); + +it('returns no products when filtering by nonexistent collection IDs', function () { + /** @var TestCase $this */ + $group = CollectionGroup::factory()->create(['handle' => 'category']); + $collection = Collection::factory()->create(['collection_group_id' => $group->id]); + + $product = Product::factory() + ->has(ProductVariant::factory()->has(Price::factory()), 'variants') + ->create(); + $product->collections()->attach([$collection->id]); + + $response = $this + ->jsonApi() + ->expects('products') + ->get(serverUrl('/products?filter[collection_groups][category][id]=99999')); + + $response + ->assertSuccessful() + ->assertFetchedNone(); +}); + +it('returns only matching products when mixing valid and nonexistent collection IDs', function () { + /** @var TestCase $this */ + $group = CollectionGroup::factory()->create(['handle' => 'category']); + $collectionA = Collection::factory()->create(['collection_group_id' => $group->id]); + + $matchingProduct = Product::factory() + ->has(ProductVariant::factory()->has(Price::factory()), 'variants') + ->create(); + $matchingProduct->collections()->attach([$collectionA->id]); + + $nonMatchingProduct = Product::factory() + ->has(ProductVariant::factory()->has(Price::factory()), 'variants') + ->create(); + + $ids = implode(',', [$collectionA->id, 99999]); + + $response = $this + ->jsonApi() + ->expects('products') + ->get(serverUrl("/products?filter[collection_groups][category][id]={$ids}")); + + $response + ->assertSuccessful() + ->assertFetchedMany([$matchingProduct]) + ->assertDoesntHaveIncluded(); +}); + +it('does not duplicate products belonging to multiple collections within the same group', function () { + /** @var TestCase $this */ + $group = CollectionGroup::factory()->create(['handle' => 'category']); + $collectionA = Collection::factory()->create(['collection_group_id' => $group->id]); + $collectionB = Collection::factory()->create(['collection_group_id' => $group->id]); + + $product = Product::factory() + ->has(ProductVariant::factory()->has(Price::factory()), 'variants') + ->create(); + $product->collections()->attach([$collectionA->id, $collectionB->id]); + + $ids = implode(',', [$collectionA->id, $collectionB->id]); + + $response = $this + ->jsonApi() + ->expects('products') + ->get(serverUrl("/products?filter[collection_groups][category][id]={$ids}")); + + $response + ->assertSuccessful() + ->assertFetchedMany([$product]) + ->assertDoesntHaveIncluded(); +});