[ProductVariant][API] Add possibility to POST variant translations

This commit is contained in:
Rafikooo 2023-02-27 15:37:08 +01:00
parent 5264b484c1
commit 8feb851ece
No known key found for this signature in database
GPG key ID: 4A26D8327BC2442B
15 changed files with 443 additions and 14 deletions

View file

@ -11,7 +11,7 @@ Feature: Adding a new product variant
And the store has "Fragile" shipping category
And I am logged in as an administrator
@ui
@api @ui
Scenario: Adding a new product variant
When I want to create a new variant of this product
And I specify its code as "VODKA_WYBOROWA_PREMIUM"
@ -19,9 +19,9 @@ Feature: Adding a new product variant
And I add it
Then I should be notified that it has been successfully created
And the "VODKA_WYBOROWA_PREMIUM" variant of the "Wyborowa Vodka" product should appear in the store
And the variant with code "VODKA_WYBOROWA_PREMIUM" should be priced at $100.00 for channel "United States"
And the variant with code "VODKA_WYBOROWA_PREMIUM" should be priced at "$100.00" for channel "United States"
@ui
@api @ui
Scenario: Adding a new product variant with name
Given the store is also available in "Polish (Poland)"
When I want to create a new variant of this product
@ -32,7 +32,7 @@ Feature: Adding a new product variant
And I add it
Then I should be notified that it has been successfully created
And the "VODKA_WYBOROWA_PREMIUM" variant of the "Wyborowa Vodka" product should appear in the store
And the variant with code "VODKA_WYBOROWA_PREMIUM" should be priced at $100.00 for channel "United States"
And the variant with code "VODKA_WYBOROWA_PREMIUM" should be priced at "$100.00" for channel "United States"
And the variant with code "VODKA_WYBOROWA_PREMIUM" should be named "Vodka Wyborowa Premium" in "English (United States)" locale
And the variant with code "VODKA_WYBOROWA_PREMIUM" should be named "Wódka Wyborowa Premium" in "Polish (Poland)" locale

View file

@ -54,13 +54,17 @@ final class ResponseChecker implements ResponseCheckerInterface
return $translations[$localeCode][$key];
}
public function getError(Response $response): string
public function getError(Response $response): ?string
{
if ($this->hasKey($response, 'message')) {
return $this->getValue($response, 'message');
}
return $this->getResponseContentValue($response, 'hydra:description');
if ($this->hasKey($response, 'hydra:description')) {
return $this->getResponseContentValue($response, 'hydra:description');
}
return $response->getContent();
}
public function isAccepted(Response $response): bool

View file

@ -29,7 +29,7 @@ interface ResponseCheckerInterface
public function getTranslationValue(Response $response, string $key, ?string $localeCode): string;
public function getError(Response $response): string;
public function getError(Response $response): ?string;
public function isAccepted(Response $response): bool;

View file

@ -26,6 +26,8 @@ use Webmozart\Assert\Assert;
final class ManagingProductVariantsContext implements Context
{
private const FIRST_COLLECTION_ITEM = 0;
public function __construct(
private ApiClientInterface $client,
private ResponseCheckerInterface $responseChecker,
@ -50,6 +52,19 @@ final class ManagingProductVariantsContext implements Context
$this->client->addRequestData('code', $code);
}
/**
* @When I name it :name in :language
*/
public function iNameItIn($name, $language)
{
$this->client->addRequestData('translations', [
$language => [
'locale' => $language,
'name' => $name,
],
]);
}
/**
* @When /^I set its price to ("[^"]+") for ("[^"]+" channel)$/
*/
@ -167,7 +182,10 @@ final class ManagingProductVariantsContext implements Context
{
Assert::true(
$this->responseChecker->isCreationSuccessful($this->client->getLastResponse()),
'Product Variant could not be created',
sprintf(
'Product Variant could not be created: %s',
$this->responseChecker->getError($this->client->getLastResponse()),
),
);
}
@ -181,6 +199,27 @@ final class ManagingProductVariantsContext implements Context
Assert::true($this->responseChecker->hasItemWithValue($response, 'code', $productVariantCode));
}
/**
* @Then /^the (?:variant with code "[^"]+") should be named "([^"]+)" in ("([^"]+)" locale)$/
*/
public function theVariantWithCodeShouldBeNamedIn(string $name, string $language)
{
$response = $this->responseChecker->getCollection($this->client->index(Resources::PRODUCT_VARIANTS));
$expectedTranslation = [
'locale' => $language,
'name' => $name,
];
$translationInLocale = $response[self::FIRST_COLLECTION_ITEM]['translations'][$language];
Assert::allInArray(
$expectedTranslation,
$translationInLocale,
sprintf('Expected translation %s, got %s', $expectedTranslation['name'], $translationInLocale['name']),
);
}
/**
* @Then /^the (variant with code "[^"]+") should be priced at ("[^"]+") for (channel "([^"]+)")$/
*/
@ -188,7 +227,7 @@ final class ManagingProductVariantsContext implements Context
{
$response = $this->responseChecker->getCollection($this->client->index(Resources::PRODUCT_VARIANTS));
Assert::same($response[0]['channelPricings'][$channel->getCode()]['price'], $price);
Assert::same($response[self::FIRST_COLLECTION_ITEM]['channelPricings'][$channel->getCode()]['price'], $price);
}
/**
@ -198,7 +237,7 @@ final class ManagingProductVariantsContext implements Context
{
$response = $this->responseChecker->getCollection($this->client->index(Resources::PRODUCT_VARIANTS));
Assert::same($response[0]['channelPricings'][$channel->getCode()]['minimumPrice'], $minimumPrice);
Assert::same($response[self::FIRST_COLLECTION_ITEM]['channelPricings'][$channel->getCode()]['minimumPrice'], $minimumPrice);
}
private function updateChannelPricingField(

View file

@ -93,7 +93,6 @@
<attribute name="example">
<attribute name="en_US">
<attribute name="name">string</attribute>
<attribute name="slug">string</attribute>
<attribute name="locale">string</attribute>
</attribute>
</attribute>

View file

@ -27,6 +27,7 @@
<group>shop:product_variant:read</group>
</attribute>
<attribute name="translations">
<group>admin:product_variant:create</group>
<group>admin:product_variant:read</group>
</attribute>
<attribute name="optionValues">

View file

@ -25,12 +25,16 @@
<attribute name="name">
<group>shop:order:account:read</group>
<group>admin:order:read</group>
<group>admin:product_variant:create</group>
<group>admin:product_variant:update</group>
<group>admin:product_variant:read</group>
<group>shop:product_variant:read</group>
</attribute>
<attribute name="locale">
<group>shop:order:account:read</group>
<group>admin:order:read</group>
<group>admin:product_variant:create</group>
<group>admin:product_variant:update</group>
<group>admin:product_variant:read</group>
<group>shop:product_variant:read</group>
</attribute>

View file

@ -37,6 +37,10 @@
<service id="sylius.validator.has_all_variant_prices_defined" class="Sylius\Bundle\CoreBundle\Validator\Constraints\HasAllVariantPricesDefinedValidator">
<tag name="validator.constraint_validator" alias="sylius_has_all_variant_prices_defined" />
</service>
<service id="sylius.validator.translation_for_existing_locales" class="Sylius\Bundle\CoreBundle\Validator\Constraints\TranslationForExistingLocalesValidator">
<argument type="service" id="sylius.repository.locale" />
<tag name="validator.constraint_validator" alias="sylius_translation_for_existing_locales" />
</service>
<service id="sylius.validator.unique_reviewer_email" class="Sylius\Bundle\CoreBundle\Validator\Constraints\UniqueReviewerEmailValidator">
<argument type="service" id="sylius.repository.shop_user" />
<argument type="service" id="security.token_storage" />

View file

@ -60,8 +60,14 @@
<constraint name="Sylius\Bundle\CoreBundle\Validator\Constraints\HasAllPricesDefined">
<option name="groups">sylius</option>
</constraint>
<constraint name="Sylius\Bundle\CoreBundle\Validator\Constraints\TranslationForExistingLocales">
<option name="groups">sylius</option>
</constraint>
<property name="channelPricings">
<constraint name="Valid" />
</property>
<property name="translations">
<constraint name="Valid" />
</property>
</class>
</constraint-mapping>

View file

@ -124,6 +124,9 @@ sylius:
file:
max_size: The image is too big - {{ size }}{{ suffix }}. Maximum allowed size is {{ limit }}{{ suffix }}.
upload_ini_size: The image is too big. Maximum allowed size is {{ limit }}{{ suffix }}.
translation:
locale_code:
invalid: 'Please choose one of the available locales: %locales%'
user:
email:
unique: This email is already used.

View file

@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
final class TranslationForExistingLocales extends Constraint
{
public string $message = 'sylius.translation.locale_code.invalid';
public function validatedBy(): string
{
return 'sylius_translation_for_existing_locales';
}
public function getTargets(): string
{
return Constraint::CLASS_CONSTRAINT;
}
}

View file

@ -0,0 +1,57 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace Sylius\Bundle\CoreBundle\Validator\Constraints;
use Sylius\Component\Locale\Model\LocaleInterface;
use Sylius\Component\Resource\Model\TranslatableInterface;
use Sylius\Component\Resource\Model\TranslationInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Webmozart\Assert\Assert;
final class TranslationForExistingLocalesValidator extends ConstraintValidator
{
public function __construct(private RepositoryInterface $localeRepository)
{
}
public function validate($value, Constraint $constraint): void
{
Assert::isInstanceOf($value, TranslatableInterface::class);
Assert::isInstanceOf($constraint, TranslationForExistingLocales::class);
/** @var LocaleInterface[] $locales */
$locales = $this->localeRepository->findAll();
if (empty($locales)) {
return;
}
$localeCodes = array_map(fn (LocaleInterface $locale) => $locale->getCode(), $locales);
$translations = $value->getTranslations();
/** @var TranslationInterface $translation */
foreach ($translations as $key => $translation) {
if (!in_array($translation->getLocale(), $localeCodes, true)) {
$this->context->buildViolation($constraint->message)
->setParameter('%locales%', implode(', ', $localeCodes))
->atPath(sprintf('translations[%s]', $key))
->addViolation()
;
}
}
}
}

View file

@ -0,0 +1,148 @@
<?php
/*
* This file is part of the Sylius package.
*
* (c) Paweł Jędrzejewski
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace spec\Sylius\Bundle\CoreBundle\Validator\Constraints;
use Doctrine\Common\Collections\ArrayCollection;
use PhpSpec\ObjectBehavior;
use Prophecy\Argument;
use Sylius\Bundle\CoreBundle\Validator\Constraints\TranslationForExistingLocales;
use Sylius\Component\Locale\Model\LocaleInterface;
use Sylius\Component\Resource\Model\TranslatableInterface;
use Sylius\Component\Resource\Model\TranslationInterface;
use Sylius\Component\Resource\Repository\RepositoryInterface;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
use Symfony\Component\Validator\Violation\ConstraintViolationBuilderInterface;
final class TranslationForExistingLocalesValidatorSpec extends ObjectBehavior
{
function let(RepositoryInterface $localeRepository, ExecutionContextInterface $context): void
{
$this->beConstructedWith($localeRepository);
$this->initialize($context);
}
function it_is_a_constraint_validator(): void
{
$this->shouldImplement(ConstraintValidatorInterface::class);
}
function it_throws_an_exception_if_value_is_not_translatable(
RepositoryInterface $localeRepository,
ExecutionContextInterface $context,
TranslatableInterface $value,
TranslationInterface $translation,
): void {
$localeRepository->findAll()->shouldNotBeCalled();
$value->getTranslations()->shouldNotBeCalled();
$translation->getLocale()->shouldNotBeCalled();
$context->buildViolation((new TranslationForExistingLocales())->message)->shouldNotBeCalled();
$this
->shouldThrow(\InvalidArgumentException::class)
->during('validate', [new \stdClass(), new TranslationForExistingLocales()])
;
}
function it_throws_an_exception_if_constraint_is_not_translation_for_existing_locales_constraint(
RepositoryInterface $localeRepository,
ExecutionContextInterface $context,
Constraint $constraint,
TranslatableInterface $value,
TranslationInterface $translation,
): void {
$localeRepository->findAll()->shouldNotBeCalled();
$value->getTranslations()->shouldNotBeCalled();
$translation->getLocale()->shouldNotBeCalled();
$context->buildViolation((new TranslationForExistingLocales())->message)->shouldNotBeCalled();
$this
->shouldThrow(\InvalidArgumentException::class)
->during('validate', [$value, $constraint])
;
}
function it_does_nothing_if_there_is_no_locales(
RepositoryInterface $localeRepository,
ExecutionContextInterface $context,
TranslatableInterface $value,
TranslationInterface $translation,
): void {
$localeRepository->findAll()->willReturn([]);
$value->getTranslations()->shouldNotBeCalled();
$translation->getLocale()->shouldNotBeCalled();
$context->buildViolation((new TranslationForExistingLocales())->message)->shouldNotBeCalled();
$this->validate($value, new TranslationForExistingLocales());
}
function it_adds_a_violation_if_any_translations_locale_in_the_translatable_object_is_not_included_in_the_available_locales(
RepositoryInterface $localeRepository,
ExecutionContextInterface $context,
ConstraintViolationBuilderInterface $constraintViolationBuilder,
TranslatableInterface $value,
TranslationInterface $firstTranslation,
TranslationInterface $secondTranslation,
LocaleInterface $availableLocale,
): void {
$availableLocale->getCode()->willReturn('en_US');
$localeRepository->findAll()->willReturn([$availableLocale]);
$value->getTranslations()->willReturn(new ArrayCollection([$firstTranslation, $secondTranslation]));
$firstTranslation->getLocale()->willReturn('en_US');
$secondTranslation->getLocale()->willReturn('NON_EXISTING_LOCALE');
$constraintViolationBuilder->addViolation()->shouldBeCalled();
$constraintViolationBuilder->atPath(Argument::any())->willReturn($constraintViolationBuilder);
$constraintViolationBuilder->setParameter(Argument::cetera())->willReturn($constraintViolationBuilder);
$context->buildViolation((new TranslationForExistingLocales())->message)->willReturn($constraintViolationBuilder);
$this->validate($value, new TranslationForExistingLocales());
}
function it_does_not_add_violation_if_the_translations_in_the_translatable_object_are_included_in_the_available_locales(
RepositoryInterface $localeRepository,
ExecutionContextInterface $context,
ConstraintViolationBuilderInterface $constraintViolationBuilder,
TranslatableInterface $value,
TranslationInterface $firstTranslation,
TranslationInterface $secondTranslation,
LocaleInterface $firstAvailableLocale,
LocaleInterface $secondAvailableLocale,
): void {
$firstAvailableLocale->getCode()->willReturn('en_US');
$secondAvailableLocale->getCode()->willReturn('pl_PL');
$localeRepository->findAll()->willReturn([$firstAvailableLocale, $secondAvailableLocale]);
$value
->getTranslations()
->willReturn(new ArrayCollection([$firstTranslation->getWrappedObject(), $secondTranslation->getWrappedObject()]))
;
$firstTranslation->getLocale()->willReturn('en_US');
$secondTranslation->getLocale()->willReturn('pl_PL');
$context->buildViolation((new TranslationForExistingLocales())->message)->shouldNotBeCalled();
$this->validate($value, new TranslationForExistingLocales());
}
}

View file

@ -71,7 +71,7 @@ final class ProductVariantsTest extends JsonApiTestCase
}
/** @test */
public function it_updates_channel_pricing_of_product_variant(): void
public function it_updates_channel_pricing_and_translation_of_a_product_variant(): void
{
$fixtures = $this->loadFixturesFromFiles(['channel.yaml', 'product/product_variant.yaml', 'authentication/api_administrator.yaml']);
$header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
@ -90,7 +90,19 @@ final class ProductVariantsTest extends JsonApiTestCase
'@id' => sprintf('/api/v2/admin/channel-pricings/%s', $productVariant->getChannelPricingForChannel($channel)->getId()),
'price' => 3000,
'originalPrice' => 4000,
]]
'minimumPrice' => 210,
]],
'translations' => [
'pl_PL' => [
'@id' => sprintf('/api/v2/admin/product-variant-translations/%s', $productVariant->getTranslation('pl_PL')->getId()),
'locale' => 'pl_PL',
'name' => 'Pomarańczowy kubek',
],
'de_DE' => [
'locale' => 'de_DE',
'name' => 'Orange Tasse',
],
]
], JSON_THROW_ON_ERROR),
);
@ -124,6 +136,12 @@ final class ProductVariantsTest extends JsonApiTestCase
'originalPrice' => 5000,
'minimumPrice' => 2000,
]],
'translations' => [
'en_US' => [
'locale' => 'en_US',
'name' => 'Yellow mug',
],
],
], JSON_THROW_ON_ERROR),
);
@ -167,4 +185,111 @@ final class ProductVariantsTest extends JsonApiTestCase
Response::HTTP_CREATED,
);
}
/** @test */
public function it_does_not_allow_to_create_product_variant_with_invalid_channel_code(): void
{
$fixtures = $this->loadFixturesFromFiles(['channel.yaml', 'product/product_variant.yaml', 'authentication/api_administrator.yaml']);
$header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
/** @var ProductInterface $product */
$product = $fixtures['product'];
$this->client->request(
method: 'POST',
uri: '/api/v2/admin/product-variants',
server: $header,
content: json_encode([
'code' => 'CUP',
'product' => sprintf('/api/v2/admin/products/%s', $product->getCode()),
'channelPricings' => ['NON-EXISTING-CHANNEL' => [
'channelCode' => 'NON-EXISTING-CHANNEL',
'price' => 4000,
]],
], JSON_THROW_ON_ERROR),
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_UNPROCESSABLE_ENTITY);
}
/** @test */
public function it_does_not_allow_to_create_product_variant_without_channel_code(): void
{
$fixtures = $this->loadFixturesFromFiles(['channel.yaml', 'product/product_variant.yaml', 'authentication/api_administrator.yaml']);
$header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
/** @var ProductInterface $product */
$product = $fixtures['product'];
$this->client->request(
method: 'POST',
uri: '/api/v2/admin/product-variants',
server: $header,
content: json_encode([
'code' => 'CUP',
'product' => sprintf('/api/v2/admin/products/%s', $product->getCode()),
'channelPricings' => [
'NON-EXISTING-CHANNEL' => ['price' => 4000]
],
], JSON_THROW_ON_ERROR),
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_UNPROCESSABLE_ENTITY);
}
/** @test */
public function it_does_not_allow_to_create_product_variant_without_product(): void
{
$this->loadFixturesFromFiles(['channel.yaml', 'product/product_variant.yaml', 'authentication/api_administrator.yaml']);
$header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
$this->client->request(
method: 'POST',
uri: '/api/v2/admin/product-variants',
server: $header,
content: json_encode([
'code' => 'CUP',
'channelPricings' => ['WEB' => [
'channelCode' => 'WEB',
'price' => 4000,
]],
], JSON_THROW_ON_ERROR),
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_UNPROCESSABLE_ENTITY);
}
/** @test */
public function it_does_not_allow_to_create_product_variant_with_invalid_locale_code(): void
{
$fixtures = $this->loadFixturesFromFiles(['channel.yaml', 'product/product_variant.yaml', 'authentication/api_administrator.yaml']);
$header = array_merge($this->logInAdminUser('api@example.com'), self::CONTENT_TYPE_HEADER);
/** @var ProductInterface $product */
$product = $fixtures['product'];
$this->client->request(
method: 'POST',
uri: '/api/v2/admin/product-variants',
server: $header,
content: json_encode([
'code' => 'CUP',
'product' => sprintf('/api/v2/admin/products/%s', $product->getCode()),
'channelPricings' => ['WEB' => [
'channelCode' => 'WEB',
'price' => 4000,
'originalPrice' => 5000,
'minimumPrice' => 2000,
]],
'translations' => [
'NON-EXISTING-LOCALE-CODE' => [
'locale' => 'NON-EXISTING-LOCALE-CODE',
'name' => 'Yellow mug',
],
],
], JSON_THROW_ON_ERROR),
);
$this->assertResponseCode($this->client->getResponse(), Response::HTTP_UNPROCESSABLE_ENTITY);
}
}

View file

@ -17,5 +17,13 @@
},
"enabled": true,
"optionValues": [],
"translations": []
"translations": {
"en_US": {
"@id":"\/api\/v2\/admin\/product-variant-translation\/@integer@",
"@type":"ProductVariantTranslation",
"id":"@integer@",
"name":"Yellow mug",
"locale":"en_US"
}
}
}