diff --git a/src/Controller/ErrorResolutionController.php b/src/Controller/ErrorResolutionController.php new file mode 100644 index 000000000..b8884b0be --- /dev/null +++ b/src/Controller/ErrorResolutionController.php @@ -0,0 +1,138 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Controller; + +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Validation\WriteConstraintViolationException; +use SwagMigrationAssistant\Exception\MigrationException; +use SwagMigrationAssistant\Migration\ErrorResolution\MigrationFieldExampleGenerator; +use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Attribute\Route; + +#[Route(defaults: ['_routeScope' => ['api']])] +#[Package('fundamentals@after-sales')] +class ErrorResolutionController extends AbstractController +{ + /** + * @internal + */ + public function __construct( + private readonly DefinitionInstanceRegistry $definitionRegistry, + private readonly MigrationFieldValidationService $fieldValidationService, + ) { + } + + #[Route( + path: '/api/_action/migration/error-resolution/validate', + name: 'api.admin.migration.error-resolution.validate', + defaults: ['_acl' => ['swag_migration.viewer']], + methods: [Request::METHOD_POST] + )] + public function validateResolution(Request $request, Context $context): JsonResponse + { + $entityName = (string) $request->request->get('entityName'); + $fieldName = (string) $request->request->get('fieldName'); + + if ($entityName === '') { + throw MigrationException::missingRequestParameter('entityName'); + } + + if ($fieldName === '') { + throw MigrationException::missingRequestParameter('fieldName'); + } + + $fieldValue = $this->decodeFieldValue($request->request->all()['fieldValue'] ?? null); + + if ($fieldValue === null) { + throw MigrationException::missingRequestParameter('fieldValue'); + } + + try { + $this->fieldValidationService->validateFieldValue( + $entityName, + $fieldName, + $fieldValue, + $context, + ); + } catch (WriteConstraintViolationException $e) { + return new JsonResponse([ + 'valid' => false, + 'violations' => $e->toArray(), + ]); + } catch (\Exception $e) { + return new JsonResponse([ + 'valid' => false, + 'violations' => [['message' => $e->getMessage()]], + ]); + } + + return new JsonResponse([ + 'valid' => true, + 'violations' => [], + ]); + } + + #[Route( + path: '/api/_action/migration/error-resolution/example-field-structure', + name: 'api.admin.migration.error-resolution.example-field-structure', + defaults: ['_acl' => ['swag_migration.viewer']], + methods: [Request::METHOD_POST] + )] + public function getExampleFieldStructure(Request $request): JsonResponse + { + $entityName = (string) $request->request->get('entityName'); + $fieldName = (string) $request->request->get('fieldName'); + + if ($entityName === '') { + throw MigrationException::missingRequestParameter('entityName'); + } + + if ($fieldName === '') { + throw MigrationException::missingRequestParameter('fieldName'); + } + + $entityDefinition = $this->definitionRegistry->getByEntityName($entityName); + $fields = $entityDefinition->getFields(); + + if (!$fields->has($fieldName)) { + throw MigrationException::entityFieldNotFound($entityName, $fieldName); + } + + $field = $fields->get($fieldName); + + $response = [ + 'fieldType' => MigrationFieldExampleGenerator::getFieldType($field), + 'example' => MigrationFieldExampleGenerator::generateExample($field), + ]; + + return new JsonResponse($response); + } + + /** + * @return array|bool|float|int|string|null + */ + private function decodeFieldValue(mixed $value): array|bool|float|int|string|null + { + if ($value === null || $value === '' || $value === []) { + return null; + } + + if (!\is_string($value)) { + return $value; + } + + $decoded = \json_decode($value, true); + + return \json_last_error() === \JSON_ERROR_NONE ? $decoded : $value; + } +} diff --git a/src/DependencyInjection/migration.xml b/src/DependencyInjection/migration.xml index b008b1b85..5a8f842a5 100644 --- a/src/DependencyInjection/migration.xml +++ b/src/DependencyInjection/migration.xml @@ -295,6 +295,15 @@ + + + + + + + + + @@ -426,6 +435,11 @@ + + + + + diff --git a/src/Exception/MigrationException.php b/src/Exception/MigrationException.php index ff1c28442..c361412a3 100644 --- a/src/Exception/MigrationException.php +++ b/src/Exception/MigrationException.php @@ -97,6 +97,10 @@ class MigrationException extends HttpException public const DUPLICATE_SOURCE_CONNECTION = 'SWAG_MIGRATION__DUPLICATE_SOURCE_CONNECTION'; + final public const MISSING_REQUEST_PARAMETER = 'SWAG_MIGRATION__MISSING_REQUEST_PARAMETER'; + + final public const ENTITY_FIELD_NOT_FOUND = 'SWAG_MIGRATION__ENTITY_FIELD_NOT_FOUND'; + public static function associationEntityRequiredMissing(string $entity, string $missingEntity): self { return new self( @@ -500,4 +504,24 @@ public static function duplicateSourceConnection(): self 'A connection to this source system already exists.', ); } + + public static function missingRequestParameter(string $parameterName): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::MISSING_REQUEST_PARAMETER, + 'Required request parameter "{{ parameterName }}" is missing.', + ['parameterName' => $parameterName] + ); + } + + public static function entityFieldNotFound(string $entityName, string $fieldName): self + { + return new self( + Response::HTTP_NOT_FOUND, + self::ENTITY_FIELD_NOT_FOUND, + 'Field "{{ fieldName }}" not found in entity "{{ entityName }}".', + ['fieldName' => $fieldName, 'entityName' => $entityName] + ); + } } diff --git a/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php b/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php new file mode 100644 index 000000000..891fe9fcc --- /dev/null +++ b/src/Migration/ErrorResolution/MigrationFieldExampleGenerator.php @@ -0,0 +1,211 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Migration\ErrorResolution; + +use Shopware\Core\Defaults; +use Shopware\Core\Framework\DataAbstractionLayer\Field\BoolField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CalculatedPriceField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CartPriceField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CashRoundingConfigField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CustomFields; +use Shopware\Core\Framework\DataAbstractionLayer\Field\DateField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\DateTimeField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\Field; +use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\FloatField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\IntField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\JsonField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ListField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ObjectField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\PriceDefinitionField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\PriceField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\TaxFreeConfigField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslatedField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\VariantListingConfigField; +use Shopware\Core\Framework\Log\Package; + +/** + * @internal + */ +#[Package('fundamentals@after-sales')] +readonly class MigrationFieldExampleGenerator +{ + public static function generateExample(Field $field): ?string + { + $example = self::buildExample($field); + + if ($example === null) { + return null; + } + + $encoded = \json_encode($example, \JSON_PRETTY_PRINT); + + if ($encoded === false) { + return null; + } + + return $encoded; + } + + public static function getFieldType(Field $field): string + { + return (new \ReflectionClass($field))->getShortName(); + } + + private static function buildExample(Field $field): mixed + { + $specialExample = self::getSpecialFieldExample($field); + + if ($specialExample !== null) { + return $specialExample; + } + + if ($field instanceof CustomFields || $field instanceof ObjectField) { + return null; + } + + if ($field instanceof ListField) { + $fieldType = $field->getFieldType(); + + if ($fieldType === null) { + return []; + } + + /** @var Field $elementField */ + $elementField = new $fieldType('example', 'example'); + $elementExample = self::buildExample($elementField); + + return $elementExample !== null ? [$elementExample] : []; + } + + if ($field instanceof JsonField) { + if (empty($field->getPropertyMapping())) { + return []; + } + + return self::buildFromPropertyMapping($field->getPropertyMapping()); + } + + return self::getScalarDefault($field); + } + + /** + * @param list $fields + * + * @return array + */ + private static function buildFromPropertyMapping(array $fields): array + { + $result = []; + + foreach ($fields as $nestedField) { + $result[$nestedField->getPropertyName()] = self::buildExample($nestedField); + } + + return $result; + } + + private static function getScalarDefault(Field $field): mixed + { + return match (true) { + $field instanceof IntField => 0, + $field instanceof FloatField => 0.1, + $field instanceof BoolField => false, + $field instanceof StringField, $field instanceof TranslatedField => '[string]', + $field instanceof IdField, $field instanceof FkField => '[uuid]', + $field instanceof DateField => \sprintf('[date (%s)]', Defaults::STORAGE_DATE_FORMAT), + $field instanceof DateTimeField => \sprintf('[datetime (%s)]', Defaults::STORAGE_DATE_TIME_FORMAT), + default => null, + }; + } + + /** + * @return array|list>|null + */ + private static function getSpecialFieldExample(Field $field): ?array + { + return match (true) { + $field instanceof PriceField => [ + [ + 'currencyId' => '[uuid]', + 'gross' => 0.1, + 'net' => 0.1, + 'linked' => false, + ], + ], + $field instanceof VariantListingConfigField => [ + 'displayParent' => false, + 'mainVariantId' => '[uuid]', + 'configuratorGroupConfig' => [], + ], + $field instanceof PriceDefinitionField => [ + 'type' => 'quantity', + 'price' => 0.1, + 'quantity' => 1, + 'isCalculated' => false, + 'taxRules' => [ + [ + 'taxRate' => 0.1, + 'percentage' => 0.1, + ], + ], + ], + $field instanceof CartPriceField => [ + 'netPrice' => 0.1, + 'totalPrice' => 0.1, + 'positionPrice' => 0.1, + 'rawTotal' => 0.1, + 'taxStatus' => 'gross', + 'calculatedTaxes' => [ + [ + 'tax' => 0.1, + 'taxRate' => 0.1, + 'price' => 0.1, + ], + ], + 'taxRules' => [ + [ + 'taxRate' => 0.1, + 'percentage' => 0.1, + ], + ], + ], + $field instanceof CalculatedPriceField => [ + 'unitPrice' => 0.1, + 'totalPrice' => 0.1, + 'quantity' => 1, + 'calculatedTaxes' => [ + [ + 'tax' => 0.1, + 'taxRate' => 0.1, + 'price' => 0.1, + ], + ], + 'taxRules' => [ + [ + 'taxRate' => 0.1, + 'percentage' => 0.1, + ], + ], + ], + $field instanceof CashRoundingConfigField => [ + 'decimals' => 2, + 'interval' => 0.01, + 'roundForNet' => true, + ], + $field instanceof TaxFreeConfigField => [ + 'enabled' => false, + 'currencyId' => '[uuid]', + 'amount' => 0.1, + ], + default => null, + }; + } +} diff --git a/src/Migration/Validation/MigrationFieldValidationService.php b/src/Migration/Validation/MigrationFieldValidationService.php new file mode 100644 index 000000000..df3079dac --- /dev/null +++ b/src/Migration/Validation/MigrationFieldValidationService.php @@ -0,0 +1,79 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Migration\Validation; + +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; +use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required; +use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\WriteCommandQueue; +use Shopware\Core\Framework\DataAbstractionLayer\Write\DataStack\KeyValuePair; +use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityExistence; +use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteContext; +use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteParameterBag; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use Shopware\Core\Framework\Validation\WriteConstraintViolationException; +use SwagMigrationAssistant\Exception\MigrationException; + +/** + * @internal + */ +#[Package('fundamentals@after-sales')] +readonly class MigrationFieldValidationService +{ + public function __construct( + private DefinitionInstanceRegistry $definitionRegistry, + ) { + } + + /** + * Validates a field value using the DAL field serializer. + * + * @throws WriteConstraintViolationException|MigrationException|\Exception if the value is not valid + */ + public function validateFieldValue( + string $entityName, + string $fieldName, + mixed $value, + Context $context, + ?string $entityId = null, + ): void { + $entityDefinition = $this->definitionRegistry->getByEntityName($entityName); + $fields = $entityDefinition->getFields(); + + if (!$fields->has($fieldName)) { + throw MigrationException::entityFieldNotFound($entityName, $fieldName); + } + + $field = $fields->get($fieldName); + + $entityExistence = EntityExistence::createForEntity( + $entityDefinition->getEntityName(), + ['id' => $entityId ?? Uuid::randomHex()], + ); + + $parameters = new WriteParameterBag( + $entityDefinition, + WriteContext::createFromContext($context), + '', + new WriteCommandQueue(), + ); + + $field = clone $field; + $field->setFlags(new Required()); + + $keyValue = new KeyValuePair( + $field->getPropertyName(), + $value, + true, + ); + + $serializer = $field->getSerializer(); + \iterator_to_array($serializer->encode($field, $entityExistence, $keyValue, $parameters), false); + } +} diff --git a/src/Migration/Validation/MigrationValidationService.php b/src/Migration/Validation/MigrationValidationService.php index e1d939c2a..bb268a51b 100644 --- a/src/Migration/Validation/MigrationValidationService.php +++ b/src/Migration/Validation/MigrationValidationService.php @@ -12,11 +12,6 @@ use Shopware\Core\Framework\DataAbstractionLayer\Field\Field; use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField; use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required; -use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\WriteCommandQueue; -use Shopware\Core\Framework\DataAbstractionLayer\Write\DataStack\KeyValuePair; -use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityExistence; -use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteContext; -use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteParameterBag; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; use SwagMigrationAssistant\Exception\MigrationException; @@ -44,6 +39,7 @@ public function __construct( private EventDispatcherInterface $eventDispatcher, private LoggingServiceInterface $loggingService, private MappingServiceInterface $mappingService, + private MigrationFieldValidationService $fieldValidationService, ) { } @@ -155,35 +151,19 @@ private function validateFields(MigrationValidationContext $validationContext): throw MigrationException::invalidId($validationContext->getConvertedData()['id'], $validationContext->getEntityDefinition()->getEntityName()); } - $entityExistence = EntityExistence::createForEntity( - $validationContext->getEntityDefinition()->getEntityName(), - ['id' => $validationContext->getConvertedData()['id']], - ); - - $parameters = new WriteParameterBag( - $validationContext->getEntityDefinition(), - WriteContext::createFromContext($validationContext->getContext()), - '', - new WriteCommandQueue(), - ); - foreach ($validationContext->getConvertedData() as $fieldName => $value) { - if (!$fields->has($fieldName)) { + if ($fields->has($fieldName) === false) { continue; } - $field = clone $fields->get($fieldName); - $field->setFlags(new Required()); - - $keyValue = new KeyValuePair( - $field->getPropertyName(), - $value, - true - ); - try { - $serializer = $field->getSerializer(); - \iterator_to_array($serializer->encode($field, $entityExistence, $keyValue, $parameters), false); + $this->fieldValidationService->validateFieldValue( + $validationContext->getEntityDefinition()->getEntityName(), + $fieldName, + $value, + $validationContext->getContext(), + $validationContext->getConvertedData()['id'], + ); } catch (\Throwable $e) { $validationContext->getValidationResult()->addLog( MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) diff --git a/src/Resources/app/administration/src/core/service/api/swag-migration.api.service.ts b/src/Resources/app/administration/src/core/service/api/swag-migration.api.service.ts index 749c5c24e..b2a8aff00 100644 --- a/src/Resources/app/administration/src/core/service/api/swag-migration.api.service.ts +++ b/src/Resources/app/administration/src/core/service/api/swag-migration.api.service.ts @@ -602,4 +602,60 @@ export default class MigrationApiService extends ApiService { return ApiService.handleResponse(response); }); } + + async validateResolution( + entityName: string, + fieldName: string, + fieldValue: unknown, + additionalHeaders: AdditionalHeaders = {}, + ): Promise<{ isValid: boolean; violations: Array<{ message: string; propertyPath?: string }> }> { + // @ts-ignore + const headers = this.getBasicHeaders(additionalHeaders); + + // @ts-ignore + return this.httpClient + .post( + // @ts-ignore + `_action/${this.getApiBasePath()}/error-resolution/validate`, + { + entityName, + fieldName, + fieldValue, + }, + { + ...this.basicConfig, + headers, + }, + ) + .then((response: AxiosResponse) => { + return ApiService.handleResponse(response); + }); + } + + async getExampleFieldStructure( + entityName: string, + fieldName: string, + additionalHeaders: AdditionalHeaders = {}, + ): Promise<{ fieldType: string; example: string | null }> { + // @ts-ignore + const headers = this.getBasicHeaders(additionalHeaders); + + // @ts-ignore + return this.httpClient + .post( + // @ts-ignore + `_action/${this.getApiBasePath()}/error-resolution/example-field-structure`, + { + entityName, + fieldName, + }, + { + ...this.basicConfig, + headers, + }, + ) + .then((response: AxiosResponse) => { + return ApiService.handleResponse(response); + }); + } } diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/index.ts b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/index.ts index 6552dd74b..7171f9d8a 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/index.ts +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/index.ts @@ -16,7 +16,9 @@ export interface SwagMigrationErrorResolutionFieldScalarData { export default Shopware.Component.wrapComponentConfig({ template, - inject: ['updateFieldValue'], + inject: [ + 'updateFieldValue', + ], props: { componentType: { @@ -30,15 +32,29 @@ export default Shopware.Component.wrapComponentConfig({ type: Object as PropType, required: true, }, + entityName: { + type: String, + required: true, + }, fieldName: { type: String, required: true, }, + error: { + type: Object as PropType<{ detail: string }>, + required: false, + default: null, + }, disabled: { type: Boolean, required: false, default: false, }, + exampleValue: { + type: String as PropType, + required: false, + default: null, + }, }, data(): SwagMigrationErrorResolutionFieldScalarData { @@ -50,12 +66,24 @@ export default Shopware.Component.wrapComponentConfig({ watch: { fieldValue: { handler() { + if (this.componentType === 'switch' && this.fieldValue === null) { + this.fieldValue = false; + } + if (this.updateFieldValue) { this.updateFieldValue(this.fieldValue); } }, immediate: true, }, + exampleValue: { + handler(newValue: string | null) { + if (this.componentType === 'editor' && newValue !== null && this.fieldValue === null) { + this.fieldValue = newValue; + } + }, + immediate: true, + }, }, computed: { diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/swag-migration-error-resolution-field-scalar.html.twig b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/swag-migration-error-resolution-field-scalar.html.twig index c0e636bbb..484d8df95 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/swag-migration-error-resolution-field-scalar.html.twig +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar/swag-migration-error-resolution-field-scalar.html.twig @@ -8,6 +8,7 @@ name="migration-resolution--number" :label="fieldName" :number-type="numberFieldType" + :error="error" :disabled="disabled" /> {% endblock %} @@ -19,6 +20,7 @@ class="sw-migration-error-resolution-field__textarea" name="migration-resolution--textarea" :label="fieldName" + :error="error" :disabled="disabled" /> {% endblock %} @@ -30,6 +32,7 @@ class="sw-migration-error-resolution-field__text" name="migration-resolution--text" :label="fieldName" + :error="error" :disabled="disabled" /> {% endblock %} @@ -41,6 +44,7 @@ class="sw-migration-error-resolution-field__switch" name="migration-resolution--switch" :label="fieldName" + :error="error" :disabled="disabled" /> {% endblock %} @@ -53,6 +57,7 @@ name="migration-resolution--datepicker" date-type="datetime" :label="fieldName" + :error="error" :disabled="disabled" /> {% endblock %} @@ -63,6 +68,7 @@ v-model:value="fieldValue" class="sw-migration-error-resolution-field__editor" name="migration-resolution--code-editor" + :error="error" :label="fieldName" /> {% endblock %} diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/index.ts b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/index.ts index ba8ae9cac..de14f2c04 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/index.ts +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/index.ts @@ -4,8 +4,7 @@ import template from './swag-migration-error-resolution-field-unhandled.html.twi * @private */ export interface SwagMigrationErrorResolutionFieldUnhandledData { - fieldValue: string; - error: { detail: string } | null; + fieldValue: string | null; } /** @@ -27,12 +26,21 @@ export default Shopware.Component.wrapComponentConfig({ required: false, default: false, }, + error: { + type: Object as PropType<{ detail: string }>, + required: false, + default: null, + }, + exampleValue: { + type: String as PropType, + required: false, + default: null, + }, }, data(): SwagMigrationErrorResolutionFieldUnhandledData { return { - fieldValue: '', - error: null, + fieldValue: null, }; }, @@ -40,35 +48,18 @@ export default Shopware.Component.wrapComponentConfig({ fieldValue: { handler() { if (this.updateFieldValue) { - const parsedValue = this.parseJsonFieldValue(); - - this.updateFieldValue(parsedValue); + this.updateFieldValue(this.fieldValue); } }, immediate: true, }, - }, - - methods: { - parseJsonFieldValue(): string | number | boolean | null | object | unknown[] { - if (!this.fieldValue || typeof this.fieldValue !== 'string') { - this.error = null; - - return this.fieldValue; - } - - try { - const value = JSON.parse(this.fieldValue); - this.error = null; - - return value; - } catch { - this.error = { - detail: this.$tc('swag-migration.index.error-resolution.errors.invalidJsonInput'), - }; - - return null; - } + exampleValue: { + handler(newValue: string | null) { + if (newValue !== null && this.fieldValue === null) { + this.fieldValue = newValue; + } + }, + immediate: true, }, }, }); diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/swag-migration-error-resolution-field-unhandled.html.twig b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/swag-migration-error-resolution-field-unhandled.html.twig index dd295a502..11415e0f1 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/swag-migration-error-resolution-field-unhandled.html.twig +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled/swag-migration-error-resolution-field-unhandled.html.twig @@ -11,9 +11,6 @@ diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/index.ts b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/index.ts index bd870a33e..1a9e775e8 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/index.ts +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/index.ts @@ -3,6 +3,14 @@ import template from './swag-migration-error-resolution-field.html.twig'; import './swag-migration-error-resolution-field.scss'; import type { ErrorResolutionTableData } from '../../swag-migration-error-resolution-step'; import { MIGRATION_ERROR_RESOLUTION_SERVICE } from '../../../../service/swag-migration-error-resolution.service'; +import { MIGRATION_API_SERVICE } from '../../../../../../core/service/api/swag-migration.api.service'; + +/** + * @private + */ +export interface SwagMigrationErrorResolutionFieldData { + exampleValue: string | null; +} /** * @private @@ -13,6 +21,11 @@ export default Shopware.Component.wrapComponentConfig({ inject: [ MIGRATION_ERROR_RESOLUTION_SERVICE, + MIGRATION_API_SERVICE, + ], + + mixins: [ + Shopware.Mixin.getByName('notification'), ], props: { @@ -20,6 +33,11 @@ export default Shopware.Component.wrapComponentConfig({ type: Object as PropType, required: true, }, + error: { + type: Object as PropType<{ detail: string }>, + required: false, + default: null, + }, disabled: { type: Boolean, required: false, @@ -27,6 +45,16 @@ export default Shopware.Component.wrapComponentConfig({ }, }, + data(): SwagMigrationErrorResolutionFieldData { + return { + exampleValue: null, + }; + }, + + async created() { + await this.fetchExampleValue(); + }, + computed: { isUnhandledField(): boolean { return this.swagMigrationErrorResolutionService.isUnhandledField(this.log.entityName, this.log.fieldName); @@ -47,5 +75,30 @@ export default Shopware.Component.wrapComponentConfig({ fieldType(): string | null { return this.swagMigrationErrorResolutionService.getFieldType(this.log.entityName, this.log.fieldName); }, + + shouldFetchExample(): boolean { + return this.isUnhandledField || this.fieldType === 'editor'; + }, + }, + + methods: { + async fetchExampleValue(): Promise { + if (!this.shouldFetchExample) { + return; + } + + try { + const response = await this.migrationApiService.getExampleFieldStructure( + this.log.entityName, + this.log.fieldName, + ); + + this.exampleValue = response?.example ?? null; + } catch { + this.createNotificationError({ + message: this.$tc('swag-migration.index.error-resolution.errors.fetchExampleFailed'), + }); + } + }, }, }); diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/swag-migration-error-resolution-field.html.twig b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/swag-migration-error-resolution-field.html.twig index 3543caccb..f09bd3f7a 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/swag-migration-error-resolution-field.html.twig +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field/swag-migration-error-resolution-field.html.twig @@ -5,6 +5,8 @@ v-if="isUnhandledField" :field-name="log?.fieldName" :disabled="disabled" + :error="error" + :example-value="exampleValue" /> {% endblock %} @@ -12,9 +14,12 @@ {% endblock %} diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts index e5d8cc7f1..34583ea34 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/index.ts @@ -46,6 +46,7 @@ export interface SwagMigrationErrorResolutionModalData { loading: boolean; submitLoading: boolean; fieldValue: string[] | string | boolean | number | null; + fieldError: { detail: string } | null; migrationStore: MigrationStore; } @@ -89,6 +90,7 @@ export default Shopware.Component.wrapComponentConfig({ loading: false, submitLoading: false, fieldValue: null, + fieldError: null, migrationStore: Shopware.Store.get(MIGRATION_STORE_ID), }; }, @@ -179,22 +181,14 @@ export default Shopware.Component.wrapComponentConfig({ }, async onSubmitResolution() { - const validationError = this.swagMigrationErrorResolutionService.validateFieldValue( - this.selectedLog.entityName, - this.selectedLog.fieldName, - this.fieldValue, - ); - - if (validationError) { - this.createNotificationError({ - message: this.$tc(`swag-migration.index.error-resolution.errors.${validationError}`), - }); + this.submitLoading = true; + this.fieldError = null; + if (!(await this.validateResolution())) { + this.submitLoading = false; return; } - this.submitLoading = true; - try { const entityIds = await this.collectEntityIdsForSubmission(); @@ -223,6 +217,57 @@ export default Shopware.Component.wrapComponentConfig({ } }, + async validateResolution(): Promise { + const validationError = this.swagMigrationErrorResolutionService.validateFieldValue( + this.selectedLog.entityName, + this.selectedLog.fieldName, + this.fieldValue, + ); + + if (validationError) { + this.createNotificationError({ + message: this.$tc(`swag-migration.index.error-resolution.errors.${validationError}`), + }); + + return false; + } + + // skip backend validation for to many associations as they use id arrays + // which are not compatible with the dal serializer format + if ( + this.swagMigrationErrorResolutionService.isToManyAssociationField( + this.selectedLog.entityName, + this.selectedLog.fieldName, + ) + ) { + return true; + } + + const serializationError = await this.migrationApiService + .validateResolution(this.selectedLog.entityName, this.selectedLog.fieldName, this.fieldValue) + .catch(() => { + this.createNotificationError({ + message: this.$tc('swag-migration.index.error-resolution.errors.validationFailed'), + }); + + return false; + }); + + if (serializationError?.valid === true) { + return true; + } + + if (!serializationError?.violations?.length) { + return false; + } + + this.fieldError = { + detail: serializationError.violations.at(0)?.message, + }; + + return false; + }, + async collectEntityIdsForSubmission(): Promise { const entityIdsFromTableData = this.extractEntityIdsFromTableData(); const missingLogIds = this.getMissingLogIds(); diff --git a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/swag-migration-error-resolution-modal.html.twig b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/swag-migration-error-resolution-modal.html.twig index 130c68546..f08b3b113 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/swag-migration-error-resolution-modal.html.twig +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal/swag-migration-error-resolution-modal.html.twig @@ -89,6 +89,7 @@
diff --git a/src/Resources/app/administration/src/module/swag-migration/snippet/de.json b/src/Resources/app/administration/src/module/swag-migration/snippet/de.json index 0c803f695..c95f56278 100644 --- a/src/Resources/app/administration/src/module/swag-migration/snippet/de.json +++ b/src/Resources/app/administration/src/module/swag-migration/snippet/de.json @@ -649,7 +649,8 @@ "noEntityIdsFound": "Keine gültigen Entitäts-IDs in den ausgewählten Log-Einträgen gefunden.", "fetchFilterDataFailed": "Das Abrufen der Filterdaten ist fehlgeschlagen.", "fetchExistingFixesFailed": "Das Abrufen vorhandener Korrekturen ist fehlgeschlagen.", - "invalidJsonInput": "Diese Eingabe ist kein gültiges JSON." + "fetchExampleFailed": "Beispieldaten konnten nicht abgerufen werden.", + "validationFailed": "Validierung fehlgeschlagen." }, "step": { "continue-modal": { diff --git a/src/Resources/app/administration/src/module/swag-migration/snippet/en.json b/src/Resources/app/administration/src/module/swag-migration/snippet/en.json index 5d7ed6b56..69361d228 100644 --- a/src/Resources/app/administration/src/module/swag-migration/snippet/en.json +++ b/src/Resources/app/administration/src/module/swag-migration/snippet/en.json @@ -500,7 +500,8 @@ "noEntityIdsFound": "No valid entity IDs found in the selected log entries.", "fetchFilterDataFailed": "Failed fetching filter data.", "fetchExistingFixesFailed": "Failed fetching existing fixes.", - "invalidJsonInput": "This input is not valid JSON." + "fetchExampleFailed": "Failed fetching example data.", + "validationFailed": "Validation failed." }, "step": { "continue-modal": { diff --git a/tests/MigrationServicesTrait.php b/tests/MigrationServicesTrait.php index 9135d0557..e43ff1202 100644 --- a/tests/MigrationServicesTrait.php +++ b/tests/MigrationServicesTrait.php @@ -53,6 +53,7 @@ use SwagMigrationAssistant\Migration\Service\MigrationDataConverterInterface; use SwagMigrationAssistant\Migration\Service\MigrationDataFetcher; use SwagMigrationAssistant\Migration\Service\MigrationDataFetcherInterface; +use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService; use SwagMigrationAssistant\Migration\Validation\MigrationValidationService; use SwagMigrationAssistant\Profile\Shopware\Gateway\Api\Reader\EnvironmentReader; use SwagMigrationAssistant\Profile\Shopware\Gateway\Api\Reader\TableCountReader; @@ -212,6 +213,7 @@ protected function getMigrationDataConverter( $this->getContainer()->get('event_dispatcher'), $loggingService, $mappingService, + new MigrationFieldValidationService($this->getContainer()->get(DefinitionInstanceRegistry::class)), ); return new MigrationDataConverter( diff --git a/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php b/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php new file mode 100644 index 000000000..79e458e1a --- /dev/null +++ b/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php @@ -0,0 +1,326 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace integration\Migration\Controller; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Defaults; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; +use SwagMigrationAssistant\Controller\ErrorResolutionController; +use SwagMigrationAssistant\Exception\MigrationException; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +/** + * @internal + */ +#[Package('fundamentals@after-sales')] +#[CoversClass(ErrorResolutionController::class)] +class ErrorResolutionControllerTest extends TestCase +{ + use IntegrationTestBehaviour; + + private ErrorResolutionController $errorResolutionController; + + protected function setUp(): void + { + parent::setUp(); + + $this->errorResolutionController = static::getContainer()->get(ErrorResolutionController::class); + } + + public function testGetFieldStructureUnsetEntityName(): void + { + static::expectExceptionObject(MigrationException::missingRequestParameter('entityName')); + + $request = new Request([], [ + 'fieldName' => 'name', + ]); + + $this->errorResolutionController->getExampleFieldStructure($request); + } + + public function testGetFieldStructureUnsetFieldName(): void + { + static::expectExceptionObject(MigrationException::missingRequestParameter('fieldName')); + + $request = new Request([], [ + 'entityName' => 'product', + ]); + + $this->errorResolutionController->getExampleFieldStructure($request); + } + + public function testGetFieldStructureUnknownField(): void + { + static::expectExceptionObject(MigrationException::entityFieldNotFound('product', 'unknownField')); + + $request = new Request([], [ + 'entityName' => 'product', + 'fieldName' => 'unknownField', + ]); + + $this->errorResolutionController->getExampleFieldStructure($request); + } + + /** + * @param array $expected + */ + #[DataProvider('fieldStructureProvider')] + public function testGetFieldStructureProduct(string $entityName, string $fieldName, array $expected): void + { + $request = new Request([], [ + 'entityName' => $entityName, + 'fieldName' => $fieldName, + ]); + + $response = $this->errorResolutionController->getExampleFieldStructure($request); + $responseData = $this->jsonResponseToArray($response); + + static::assertArrayHasKey('fieldType', $responseData); + static::assertArrayHasKey('example', $responseData); + + static::assertSame($expected['fieldType'], $responseData['fieldType']); + static::assertSame($expected['example'], $responseData['example']); + } + + public static function fieldStructureProvider(): \Generator + { + yield 'product name field' => [ + 'entityName' => 'product', + 'fieldName' => 'name', + 'expected' => [ + 'fieldType' => 'TranslatedField', + 'example' => '"[string]"', + ], + ]; + + yield 'product availableStock field' => [ + 'entityName' => 'product', + 'fieldName' => 'availableStock', + 'expected' => [ + 'fieldType' => 'IntField', + 'example' => '0', + ], + ]; + + yield 'product price field' => [ + 'entityName' => 'product', + 'fieldName' => 'price', + 'expected' => [ + 'fieldType' => 'PriceField', + 'example' => \json_encode([ + [ + 'currencyId' => '[uuid]', + 'gross' => 0.1, + 'net' => 0.1, + 'linked' => false, + ], + ], \JSON_PRETTY_PRINT), + ], + ]; + + yield 'product variant listing config' => [ + 'entityName' => 'product', + 'fieldName' => 'variantListingConfig', + 'expected' => [ + 'fieldType' => 'VariantListingConfigField', + 'example' => \json_encode([ + 'displayParent' => false, + 'mainVariantId' => '[uuid]', + 'configuratorGroupConfig' => [], + ], \JSON_PRETTY_PRINT), + ], + ]; + } + + public function testValidateResolutionUnsetEntityName(): void + { + static::expectExceptionObject(MigrationException::missingRequestParameter('entityName')); + + $request = new Request([], [ + 'fieldName' => 'name', + ]); + + $this->errorResolutionController->validateResolution( + $request, + Context::createDefaultContext() + ); + } + + public function testValidateResolutionUnsetFieldName(): void + { + static::expectExceptionObject(MigrationException::missingRequestParameter('fieldName')); + + $request = new Request([], [ + 'entityName' => 'product', + ]); + + $this->errorResolutionController->validateResolution( + $request, + Context::createDefaultContext() + ); + } + + /** + * @param string|list|null $fieldValue + */ + #[DataProvider('invalidResolutionProvider')] + public function testValidateResolutionInvalidRequest(string|array|null $fieldValue): void + { + static::expectExceptionObject(MigrationException::missingRequestParameter('fieldValue')); + + $request = new Request([], [ + 'entityName' => 'product', + 'fieldName' => 'name', + 'fieldValue' => $fieldValue, + ]); + + $this->errorResolutionController->validateResolution( + $request, + Context::createDefaultContext() + ); + } + + public static function invalidResolutionProvider(): \Generator + { + yield 'null' => ['fieldValue' => null]; + yield 'empty string' => ['fieldValue' => '']; + yield 'empty array' => ['fieldValue' => []]; + } + + /** + * @param array $expected + */ + #[DataProvider('validateResolutionProvider')] + public function testValidateResolution(string $entityName, string $fieldName, mixed $fieldValue, array $expected): void + { + $request = new Request([], [ + 'entityName' => $entityName, + 'fieldName' => $fieldName, + 'fieldValue' => $fieldValue, + ]); + + $response = $this->errorResolutionController->validateResolution( + $request, + Context::createDefaultContext() + ); + $data = $this->jsonResponseToArray($response); + + static::assertArrayHasKey('valid', $data); + static::assertArrayHasKey('violations', $data); + + $violationMessages = array_map(static fn (array $violation) => $violation['message'], $data['violations']); + + static::assertSame($expected['valid'], $data['valid']); + static::assertSame($expected['violations'], $violationMessages); + } + + public static function validateResolutionProvider(): \Generator + { + yield 'valid product name' => [ + 'entityName' => 'product', + 'fieldName' => 'name', + 'fieldValue' => 'Valid Product Name', + 'expected' => [ + 'valid' => true, + 'violations' => [], + ], + ]; + + yield 'invalid product stock' => [ + 'entityName' => 'product', + 'fieldName' => 'stock', + 'fieldValue' => 'jhdwhawbdh', + 'expected' => [ + 'valid' => false, + 'violations' => [ + 'This value should be of type int.', + ], + ], + ]; + + yield 'valid product active' => [ + 'entityName' => 'product', + 'fieldName' => 'active', + 'fieldValue' => true, + 'expected' => [ + 'valid' => true, + 'violations' => [], + ], + ]; + + yield 'invalid product taxId' => [ + 'entityName' => 'product', + 'fieldName' => 'taxId', + 'fieldValue' => 'invalid-uuid', + 'expected' => [ + 'valid' => false, + 'violations' => [ + 'The string "invalid-uuid" is not a valid uuid.', + ], + ], + ]; + + yield 'invalid product variant config' => [ + 'entityName' => 'product', + 'fieldName' => 'variantListingConfig', + 'fieldValue' => [ + 'displayParent' => 'not-a-boolean', + 'mainVariantId' => 'also-not-a-uuid', + 'configuratorGroupConfig' => [], + ], + 'expected' => [ + 'valid' => false, + 'violations' => [ + 'This value should be of type boolean.', + 'The string "also-not-a-uuid" is not a valid uuid.', + ], + ], + ]; + + yield 'valid product price' => [ + 'entityName' => 'product', + 'fieldName' => 'price', + 'fieldValue' => [ + [ + 'currencyId' => Defaults::CURRENCY, + 'gross' => 19.99, + 'net' => 16.81, + 'linked' => false, + ], + ], + 'expected' => [ + 'valid' => true, + 'violations' => [], + ], + ]; + } + + /** + * @return array|list> + */ + private function jsonResponseToArray(?Response $response): array + { + static::assertNotNull($response); + static::assertInstanceOf(JsonResponse::class, $response); + + $content = $response->getContent(); + static::assertIsNotBool($content); + static::assertJson($content); + + $array = \json_decode($content, true); + static::assertIsArray($array); + + return $array; + } +} diff --git a/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php new file mode 100644 index 000000000..fd582ce4a --- /dev/null +++ b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php @@ -0,0 +1,136 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Test\integration\Migration\Validation; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Defaults; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\DataAbstractionLayerException; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; +use Shopware\Core\Framework\Validation\WriteConstraintViolationException; +use SwagMigrationAssistant\Exception\MigrationException; +use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService; + +/** + * @internal + */ +#[Package('fundamentals@after-sales')] +#[CoversClass(MigrationFieldValidationService::class)] +class MigrationFieldValidationServiceTest extends TestCase +{ + use IntegrationTestBehaviour; + + private MigrationFieldValidationService $migrationFieldValidationService; + + protected function setUp(): void + { + $this->migrationFieldValidationService = static::getContainer()->get(MigrationFieldValidationService::class); + } + + public function testNotExistingEntityDefinition(): void + { + static::expectExceptionObject(DataAbstractionLayerException::definitionNotFound('test')); + + $this->migrationFieldValidationService->validateFieldValue( + 'test', + 'field', + 'value', + Context::createDefaultContext(), + ); + } + + public function testNotExistingField(): void + { + static::expectExceptionObject(MigrationException::entityFieldNotFound('product', 'nonExistingField')); + + $this->migrationFieldValidationService->validateFieldValue( + 'product', + 'nonExistingField', + 'value', + Context::createDefaultContext(), + ); + } + + public function testValidPriceField(): void + { + static::expectNotToPerformAssertions(); + + $this->migrationFieldValidationService->validateFieldValue( + 'product', + 'price', + [ + [ + 'currencyId' => Defaults::CURRENCY, + 'gross' => 100.0, + 'net' => 84.03, + 'linked' => true, + ], + ], + Context::createDefaultContext(), + ); + } + + public function testInvalidPriceFieldGrossType(): void + { + static::expectException(WriteConstraintViolationException::class); + + $this->migrationFieldValidationService->validateFieldValue( + 'product', + 'price', + [ + [ + 'currencyId' => Defaults::CURRENCY, + 'gross' => 'invalid', // should be numeric + 'net' => 84.03, + 'linked' => true, + ], + ], + Context::createDefaultContext(), + ); + } + + public function testInvalidPriceFieldMissingNet(): void + { + static::expectException(WriteConstraintViolationException::class); + + $this->migrationFieldValidationService->validateFieldValue( + 'product', + 'price', + [ + [ + 'currencyId' => Defaults::CURRENCY, + 'gross' => 100.0, + // 'net' is missing + 'linked' => true, + ], + ], + Context::createDefaultContext(), + ); + } + + public function testInvalidPriceFieldCurrencyId(): void + { + static::expectException(WriteConstraintViolationException::class); + + $this->migrationFieldValidationService->validateFieldValue( + 'product', + 'price', + [ + [ + 'currencyId' => 'not-a-valid-uuid', + 'gross' => 100.0, + 'net' => 84.03, + 'linked' => true, + ], + ], + Context::createDefaultContext(), + ); + } +} diff --git a/tests/unit/Migration/ErrorResolution/MigrationFieldExampleGeneratorTest.php b/tests/unit/Migration/ErrorResolution/MigrationFieldExampleGeneratorTest.php new file mode 100644 index 000000000..35fc8b24c --- /dev/null +++ b/tests/unit/Migration/ErrorResolution/MigrationFieldExampleGeneratorTest.php @@ -0,0 +1,168 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Test\unit\Migration\ErrorResolution; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CalculatedPriceField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CartPriceField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CashRoundingConfigField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\CustomFields; +use Shopware\Core\Framework\DataAbstractionLayer\Field\DateField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\DateTimeField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\Field; +use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\FloatField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\IdField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\IntField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\JsonField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ListField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ObjectField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\PriceDefinitionField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\PriceField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\StringField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\TaxFreeConfigField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslatedField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\VariantListingConfigField; +use Shopware\Core\Framework\Log\Package; +use SwagMigrationAssistant\Migration\ErrorResolution\MigrationFieldExampleGenerator; + +/** + * @internal + */ +#[Package('fundamentals@after-sales')] +#[CoversClass(MigrationFieldExampleGenerator::class)] +class MigrationFieldExampleGeneratorTest extends TestCase +{ + public function testGetFieldType(): void + { + static::assertSame(MigrationFieldExampleGenerator::getFieldType(new StringField('test', 'test')), 'StringField'); + static::assertSame(MigrationFieldExampleGenerator::getFieldType(new IntField('test', 'test')), 'IntField'); + } + + #[DataProvider('exampleFieldProvider')] + public function testGenerateExample(Field $field, ?string $expected): void + { + $example = MigrationFieldExampleGenerator::generateExample($field); + static::assertSame($expected, $example); + } + + public static function exampleFieldProvider(): \Generator + { + yield 'IntField' => [ + 'field' => new IntField('test', 'test'), + 'expected' => '0', + ]; + + yield 'FloatField' => [ + 'field' => new FloatField('test', 'test'), + 'expected' => '0.1', + ]; + + yield 'StringField' => [ + 'field' => new StringField('test', 'test'), + 'expected' => '"[string]"', + ]; + + yield 'TranslatedField' => [ + 'field' => new TranslatedField('test'), + 'expected' => '"[string]"', + ]; + + yield 'IdField' => [ + 'field' => new IdField('test', 'test'), + 'expected' => '"[uuid]"', + ]; + + yield 'FkField' => [ + 'field' => new FkField('test', 'test', 'test'), + 'expected' => '"[uuid]"', + ]; + + yield 'DateField' => [ + 'field' => new DateField('test', 'test'), + 'expected' => '"[date (Y-m-d)]"', + ]; + + yield 'DateTimeField' => [ + 'field' => new DateTimeField('test', 'test'), + 'expected' => '"[datetime (Y-m-d H:i:s.v)]"', + ]; + + yield 'CustomFields' => [ + 'field' => new CustomFields('test', 'test'), + 'expected' => null, + ]; + + yield 'ObjectField' => [ + 'field' => new ObjectField('test', 'test'), + 'expected' => null, + ]; + + yield 'JsonField without property mapping' => [ + 'field' => new JsonField('test', 'test'), + 'expected' => '[]', + ]; + + yield 'JsonField with property mapping' => [ + 'field' => new JsonField('test', 'test', [new StringField('innerString', 'innerString')]), + 'expected' => \json_encode(['innerString' => '[string]'], \JSON_PRETTY_PRINT), + ]; + + yield 'ListField without field type' => [ + 'field' => new ListField('test', 'test'), + 'expected' => '[]', + ]; + + yield 'ListField with field type' => [ + 'field' => new ListField('test', 'test', StringField::class), + 'expected' => \json_encode(['[string]'], \JSON_PRETTY_PRINT), + ]; + } + + #[DataProvider('specialFieldProvider')] + public function testGenerateExampleSpecialFields(Field $field): void + { + $example = MigrationFieldExampleGenerator::generateExample($field); + + // not null means the special field was handled + static::assertNotNull($example); + } + + public static function specialFieldProvider(): \Generator + { + yield 'CalculatedPriceField' => [ + 'field' => new CalculatedPriceField('test', 'test'), + ]; + + yield 'CartPriceField' => [ + 'field' => new CartPriceField('test', 'test'), + ]; + + yield 'PriceDefinitionField' => [ + 'field' => new PriceDefinitionField('test', 'test'), + ]; + + yield 'PriceField' => [ + 'field' => new PriceField('test', 'test'), + ]; + + yield 'VariantListingConfigField' => [ + 'field' => new VariantListingConfigField('test', 'test'), + ]; + + yield 'CashRoundingConfigField' => [ + 'field' => new CashRoundingConfigField('test', 'test'), + ]; + + yield 'TaxFreeConfigField' => [ + 'field' => new TaxFreeConfigField('test', 'test'), + ]; + } +}