[ApiBundle] Fix 404 on GET /shop/products/{code} when all associated products are disabled (#19018)
Some checks failed
Continuous Integration (Minimal) / Static checks (push) Has been cancelled
Continuous Integration (Minimal) / Javascript Tests (MySQL) (push) Has been cancelled
Continuous Integration (Minimal) / Tests (PostgreSQL) (push) Has been cancelled
Continuous Integration (Minimal) / Frontend (push) Has been cancelled
Continuous Integration (Minimal) / Packages (push) Has been cancelled
Continuous Integration (Minimal) / Tests (MariaDB) (push) Has been cancelled
Continuous Integration (Minimal) / Tests (MySQL) (push) Has been cancelled

| Q               | A
|-----------------|-----
| Branch?         | 2.2
| Bug fix?        | yes
| New feature?    | no
| BC breaks?      | no
| Deprecations?   | no
| Related tickets | https://github.com/Sylius/Sylius/issues/19013
| License         | MIT

  ### Summary

`GET /api/v2/shop/products/{code}` returned **404** for an `enabled`
product whenever
*all* of its associated products (across all its associations) were
disabled.

  Root cause: `EnabledWithinProductAssociationExtension` applied
`andWhere('product.associations IS EMPTY OR associatedProduct.id IS NOT
NULL')`
both in `applyToCollection` and `applyToItem`. For an item query, the OR
collapsed
to `false` whenever the product had associations but none of their
associated
  products were enabled — excluding the product itself from the result.

  ### Fix

Removed the `andWhere` from the extension. Eager-loading of enabled
associated
products (`LEFT JOIN ... WITH associatedProduct.enabled = true`) stays
in place
and mirrors the convention used by the neighbouring
`EnabledVariantsExtension`.
This commit is contained in:
Karol Wojdyła 2026-05-21 14:14:32 +02:00 committed by GitHub
commit d57823973a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 80 additions and 6 deletions

View file

@ -86,7 +86,6 @@ final readonly class EnabledWithinProductAssociationExtension implements QueryCo
$queryBuilder->expr()->eq(sprintf('%s.owner', $associationAliasName), $rootAlias),
),
)
->andWhere(sprintf('%s.associations IS EMPTY OR %s.id IS NOT NULL', $rootAlias, $productAssociationAliasName))
;
}
}

View file

@ -82,7 +82,7 @@ final class EnabledWithinProductAssociationExtensionTest extends TestCase
);
}
public function test_it_filters_products_by_available_associations(): void
public function test_it_eager_loads_only_enabled_associated_products(): void
{
$this->sectionProvider->method('getSection')->willReturn(new ShopApiSection());
@ -147,10 +147,8 @@ final class EnabledWithinProductAssociationExtensionTest extends TestCase
});
$this->queryBuilder
->expects(self::once())
->method('andWhere')
->with('o.associations IS EMPTY OR associatedProduct.id IS NOT NULL')
->willReturnSelf();
->expects(self::never())
->method('andWhere');
$this->extension->applyToCollection(
$this->queryBuilder,

View file

@ -0,0 +1,63 @@
Sylius\Component\Core\Model\Channel:
channel_web:
code: 'WEB'
name: 'Web Channel'
hostname: 'localhost'
description: 'Lorem ipsum'
baseCurrency: '@currency_usd'
defaultLocale: '@locale_en'
locales: ['@locale_en']
color: 'black'
enabled: true
taxCalculationStrategy: 'order_items_based'
Sylius\Component\Currency\Model\Currency:
currency_usd:
code: 'USD'
Sylius\Component\Locale\Model\Locale:
locale_en:
code: 'en_US'
Sylius\Component\Core\Model\Product:
product_kettle:
code: 'KETTLE'
channels: ['@channel_web']
currentLocale: 'en_US'
product_disabled_hat:
code: 'DISABLED_HAT'
channels: ['@channel_web']
currentLocale: 'en_US'
enabled: false
Sylius\Component\Core\Model\ProductTranslation:
product_translation_kettle_en_US:
slug: 'kettle'
locale: 'en_US'
name: 'Kettle'
description: 'A nice kettle'
shortDescription: 'Kettle'
translatable: '@product_kettle'
product_translation_disabled_hat_en_US:
slug: 'disabled-hat'
locale: 'en_US'
name: 'Disabled Hat'
description: 'A hat that is not enabled'
shortDescription: 'Disabled Hat'
translatable: '@product_disabled_hat'
Sylius\Component\Product\Model\ProductAssociationType:
similar_products_type:
code: 'similar_products'
Sylius\Component\Product\Model\ProductAssociationTypeTranslation:
similar_products_type_translation_en_US:
name: 'Similar products'
locale: 'en_US'
translatable: '@similar_products_type'
Sylius\Component\Product\Model\ProductAssociation:
kettle_dead_association:
type: '@similar_products_type'
owner: '@product_kettle'
associatedProducts: ['@product_disabled_hat']

View file

@ -154,6 +154,20 @@ final class ProductsTest extends JsonApiTestCase
);
}
#[Test]
public function it_returns_enabled_product_item_when_all_its_associated_products_are_disabled(): void
{
$this->loadFixturesFromFile('product/product_with_only_disabled_associated_products.yaml');
$this->client->request(
method: 'GET',
uri: '/api/v2/shop/products/KETTLE',
server: self::CONTENT_TYPE_HEADER,
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_OK);
}
#[Test]
public function it_returns_associated_products_collection_by_association_type(): void
{