diff --git a/UPGRADE.md b/UPGRADE.md index b03106cc1..033ae8692 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -139,12 +139,12 @@ - [BREAKING] [#51](https://github.com/shopware/SwagMigrationAssistant/pull/51) - feat!: add migration validation of converted data - [BREAKING] Added validation check of converted data to `convertData(...)` method in `SwagMigrationAssistant\Migration\Service\MigrationDataConverter` - Added `hasValidMappingByEntityId(...)` to `SwagMigrationAssistant\Migration\Mapping\MappingService` and `SwagMigrationAssistant\Migration\Mapping\MappingServiceInterface` to check if a mapping exists and is valid for a given entity and source id - - Added new service `SwagMigrationAssistant\Migration\Validation\MigrationValidationService` to validate converted data against Shopware's data definitions + - Added new service `SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService` to validate converted data against Shopware's data definitions - Added new events `SwagMigrationAssistant\Migration\Validation\Event\MigrationPreValidationEvent` and `SwagMigrationAssistant\Migration\Validation\Event\MigrationPostValidationEvent` to allow extensions to hook into the validation process - Added new log classes `ValidationInvalidFieldValueLog`, `ValidationInvalidForeignKeyLog`, `ValidationMissingRequiredFieldLog` and `ValidationUnexpectedFieldLog` to log validation errors - Added new context class `SwagMigrationAssistant\Migration\Validation\MigrationValidationContext` to pass validation related data - Added new result class `SwagMigrationAssistant\Migration\Validation\MigrationValidationResult` to collect validation results - - Added new service `SwagMigrationAssistant\Migration\Validation\MigrationValidationService` to validate converted data against Shopware's data definitions in three steps: + - Added new service `SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService` to validate converted data against Shopware's data definitions in three steps: - Entity structure: Check for unexpected fields - Field Validation: Check for missing required fields and invalid field values - Association Validation: Check for invalid foreign keys diff --git a/src/Controller/DataProviderController.php b/src/Controller/DataProviderController.php index 29f1c56f4..4e2ccef17 100644 --- a/src/Controller/DataProviderController.php +++ b/src/Controller/DataProviderController.php @@ -19,7 +19,9 @@ use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\ApiRouteScope; use Shopware\Core\Framework\Routing\RoutingException; +use Shopware\Core\PlatformRequest; use SwagMigrationAssistant\DataProvider\Provider\ProviderRegistryInterface; use SwagMigrationAssistant\DataProvider\Service\EnvironmentServiceInterface; use SwagMigrationAssistant\Exception\MigrationException; @@ -32,7 +34,7 @@ use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\Routing\Attribute\Route; -#[Route(defaults: ['_routeScope' => ['api']])] +#[Route(defaults: [PlatformRequest::ATTRIBUTE_ROUTE_SCOPE => [ApiRouteScope::ID]])] #[Package('fundamentals@after-sales')] class DataProviderController extends AbstractController { @@ -54,7 +56,7 @@ public function __construct( #[Route( path: '/api/_action/data-provider/get-environment', name: 'api.admin.data-provider.get-environment', - defaults: ['_acl' => ['admin']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['admin']], methods: [Request::METHOD_GET] )] public function getEnvironment(Context $context): JsonResponse @@ -67,7 +69,7 @@ public function getEnvironment(Context $context): JsonResponse #[Route( path: '/api/_action/data-provider/get-data', name: 'api.admin.data-provider.get-data', - defaults: ['_acl' => ['admin']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['admin']], methods: [Request::METHOD_GET] )] public function getData(Request $request, Context $context): JsonResponse @@ -89,7 +91,7 @@ public function getData(Request $request, Context $context): JsonResponse #[Route( path: '/api/_action/data-provider/get-total', name: 'api.admin.data-provider.get-total', - defaults: ['_acl' => ['admin']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['admin']], methods: [Request::METHOD_GET] )] public function getTotal(Request $request, Context $context): JsonResponse @@ -107,7 +109,7 @@ public function getTotal(Request $request, Context $context): JsonResponse #[Route( path: '/api/_action/data-provider/get-table', name: 'api.admin.data-provider.get-table', - defaults: ['_acl' => ['admin']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['admin']], methods: [Request::METHOD_GET] )] public function getTable(Request $request, Context $context): JsonResponse @@ -127,7 +129,7 @@ public function getTable(Request $request, Context $context): JsonResponse #[Route( path: '/api/_action/data-provider/generate-document', name: 'api.admin.data-provider.generate-document', - defaults: ['_acl' => ['admin']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['admin']], methods: [Request::METHOD_GET] )] public function generateDocument(Request $request, Context $context): JsonResponse @@ -154,7 +156,7 @@ public function generateDocument(Request $request, Context $context): JsonRespon #[Route( path: '/api/_action/data-provider/download-private-file/{file}', name: 'api.admin.data-provider.download-private-file', - defaults: ['_acl' => ['admin']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['admin']], methods: [Request::METHOD_GET] )] public function downloadPrivateFile(Request $request, Context $context): StreamedResponse|RedirectResponse diff --git a/src/Controller/ErrorResolutionController.php b/src/Controller/ErrorResolutionController.php new file mode 100644 index 000000000..1e5f4335f --- /dev/null +++ b/src/Controller/ErrorResolutionController.php @@ -0,0 +1,150 @@ + + * 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\Routing\ApiRouteScope; +use Shopware\Core\Framework\Validation\WriteConstraintViolationException; +use Shopware\Core\PlatformRequest; +use SwagMigrationAssistant\Exception\MigrationException; +use SwagMigrationAssistant\Migration\ErrorResolution\MigrationFieldExampleGenerator; +use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; +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: [PlatformRequest::ATTRIBUTE_ROUTE_SCOPE => [ApiRouteScope::ID]])] +#[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: [PlatformRequest::ATTRIBUTE_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->validateField( + $entityName, + $fieldName, + $fieldValue, + $context, + ); + } catch (MigrationValidationException $exception) { + $previous = $exception->getPrevious(); + + if ($previous instanceof WriteConstraintViolationException) { + return new JsonResponse([ + 'valid' => false, + 'violations' => $previous->toArray(), + ]); + } + + return new JsonResponse([ + 'valid' => false, + 'violations' => [['message' => $exception->getMessage()]], + ]); + } catch (\Exception $exception) { + return new JsonResponse([ + 'valid' => false, + 'violations' => [['message' => $exception->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: [PlatformRequest::ATTRIBUTE_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 MigrationValidationException::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/Controller/HistoryController.php b/src/Controller/HistoryController.php index 16ac27c42..6598dae86 100644 --- a/src/Controller/HistoryController.php +++ b/src/Controller/HistoryController.php @@ -9,7 +9,9 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\ApiRouteScope; use Shopware\Core\Framework\Routing\RoutingException; +use Shopware\Core\PlatformRequest; use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\History\HistoryServiceInterface; use SwagMigrationAssistant\Migration\History\LogGroupingService; @@ -22,7 +24,7 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -#[Route(defaults: ['_routeScope' => ['api']])] +#[Route(defaults: [PlatformRequest::ATTRIBUTE_ROUTE_SCOPE => [ApiRouteScope::ID]])] #[Package('fundamentals@after-sales')] class HistoryController extends AbstractController { @@ -35,7 +37,12 @@ public function __construct( ) { } - #[Route(path: '/api/_action/migration/get-grouped-logs-of-run', name: 'api.admin.migration.get-grouped-logs-of-run', methods: ['GET'], defaults: ['_acl' => ['swag_migration.viewer']])] + #[Route( + path: '/api/_action/migration/get-grouped-logs-of-run', + name: 'api.admin.migration.get-grouped-logs-of-run', + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], + methods: [Request::METHOD_GET], + )] public function getGroupedLogsOfRun(Request $request, Context $context): JsonResponse { $runUuid = $request->query->getAlnum('runUuid'); @@ -60,7 +67,12 @@ public function getGroupedLogsOfRun(Request $request, Context $context): JsonRes ]); } - #[Route(path: '/api/_action/migration/download-logs-of-run', name: 'api.admin.migration.download-logs-of-run', methods: ['POST'], defaults: ['auth_required' => false, '_acl' => ['swag_migration.viewer']])] + #[Route( + path: '/api/_action/migration/download-logs-of-run', + name: 'api.admin.migration.download-logs-of-run', + defaults: ['auth_required' => false, PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], + methods: [Request::METHOD_POST], + )] public function downloadLogsOfRun(Request $request, Context $context): StreamedResponse { $runUuid = $request->request->getAlnum('runUuid'); @@ -86,7 +98,12 @@ public function downloadLogsOfRun(Request $request, Context $context): StreamedR return $response; } - #[Route(path: '/api/_action/migration/clear-data-of-run', name: 'api.admin.migration.clear-data-of-run', methods: ['POST'], defaults: ['_acl' => ['swag_migration.deleter']])] + #[Route( + path: '/api/_action/migration/clear-data-of-run', + name: 'api.admin.migration.clear-data-of-run', + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.deleter']], + methods: [Request::METHOD_POST], + )] public function clearDataOfRun(Request $request, Context $context): Response { $runUuid = $request->request->getAlnum('runUuid'); @@ -104,7 +121,12 @@ public function clearDataOfRun(Request $request, Context $context): Response return new Response(); } - #[Route(path: '/api/_action/migration/is-media-processing', name: 'api.admin.migration.is-media-processing', methods: ['GET'], defaults: ['_acl' => ['swag_migration_history:read']])] + #[Route( + path: '/api/_action/migration/is-media-processing', + name: 'api.admin.migration.is-media-processing', + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration_history:read']], + methods: [Request::METHOD_GET], + )] public function isMediaProcessing(): JsonResponse { $result = $this->historyService->isMediaProcessing(); @@ -115,8 +137,8 @@ public function isMediaProcessing(): JsonResponse #[Route( path: '/api/_action/migration/get-log-groups', name: 'api.admin.migration.get-log-groups', - methods: ['GET'], - defaults: ['_acl' => ['swag_migration.viewer']] + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], + methods: [Request::METHOD_GET], )] public function getLogGroups(Request $request, Context $context): JsonResponse { @@ -166,8 +188,8 @@ public function getLogGroups(Request $request, Context $context): JsonResponse #[Route( path: '/api/_action/migration/get-all-log-ids', name: 'api.admin.migration.get-all-log-ids', - methods: ['POST'], - defaults: ['_acl' => ['swag_migration.viewer']] + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], + methods: [Request::METHOD_POST], )] public function getAllLogIds(Request $request): JsonResponse { diff --git a/src/Controller/PremappingController.php b/src/Controller/PremappingController.php index 078990a1d..9e1310535 100644 --- a/src/Controller/PremappingController.php +++ b/src/Controller/PremappingController.php @@ -9,7 +9,9 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\ApiRouteScope; use Shopware\Core\Framework\Routing\RoutingException; +use Shopware\Core\PlatformRequest; use SwagMigrationAssistant\Migration\MigrationContextFactoryInterface; use SwagMigrationAssistant\Migration\Service\PremappingServiceInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -18,7 +20,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route(defaults: ['_routeScope' => ['api']])] +#[Route(defaults: [PlatformRequest::ATTRIBUTE_ROUTE_SCOPE => [ApiRouteScope::ID]])] #[Package('fundamentals@after-sales')] class PremappingController extends AbstractController { @@ -31,7 +33,12 @@ public function __construct( ) { } - #[Route(path: '/api/_action/migration/generate-premapping', name: 'api.admin.migration.generate-premapping', methods: ['POST'], defaults: ['_acl' => ['swag_migration.editor']])] + #[Route( + path: '/api/_action/migration/generate-premapping', + name: 'api.admin.migration.generate-premapping', + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.editor']], + methods: [Request::METHOD_POST], + )] public function generatePremapping(Request $request, Context $context): JsonResponse { $dataSelectionIds = $request->request->all('dataSelectionIds'); @@ -44,7 +51,12 @@ public function generatePremapping(Request $request, Context $context): JsonResp return new JsonResponse($this->premappingService->generatePremapping($context, $migrationContext, $dataSelectionIds)); } - #[Route(path: '/api/_action/migration/write-premapping', name: 'api.admin.migration.write-premapping', methods: ['POST'], defaults: ['_acl' => ['swag_migration.editor']])] + #[Route( + path: '/api/_action/migration/write-premapping', + name: 'api.admin.migration.write-premapping', + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.editor']], + methods: [Request::METHOD_POST], + )] public function writePremapping(Request $request, Context $context): Response { $premapping = $request->request->all('premapping'); diff --git a/src/Controller/StatusController.php b/src/Controller/StatusController.php index 5f6395be0..616f88ebe 100644 --- a/src/Controller/StatusController.php +++ b/src/Controller/StatusController.php @@ -11,7 +11,9 @@ use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\ApiRouteScope; use Shopware\Core\Framework\Routing\RoutingException; +use Shopware\Core\PlatformRequest; use SwagMigrationAssistant\Exception\MigrationException; use SwagMigrationAssistant\Migration\Connection\Fingerprint\MigrationFingerprintServiceInterface; use SwagMigrationAssistant\Migration\Connection\SwagMigrationConnectionCollection; @@ -30,7 +32,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; -#[Route(defaults: ['_routeScope' => ['api']])] +#[Route(defaults: [PlatformRequest::ATTRIBUTE_ROUTE_SCOPE => [ApiRouteScope::ID]])] #[Package('fundamentals@after-sales')] class StatusController extends AbstractController { @@ -56,7 +58,7 @@ public function __construct( #[Route( path: '/api/_action/migration/get-profile-information', name: 'api.admin.migration.get-profile-information', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_GET] )] public function getProfileInformation(Request $request): Response @@ -126,7 +128,7 @@ public function getProfileInformation(Request $request): Response #[Route( path: '/api/_action/migration/get-profiles', name: 'api.admin.migration.get-profiles', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_GET] )] public function getProfiles(): JsonResponse @@ -149,7 +151,7 @@ public function getProfiles(): JsonResponse #[Route( path: '/api/_action/migration/get-gateways', name: 'api.admin.migration.get-gateways', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_GET] )] public function getGateways(Request $request): JsonResponse @@ -180,7 +182,7 @@ public function getGateways(Request $request): JsonResponse #[Route( path: '/api/_action/migration/update-connection-credentials', name: 'api.admin.migration.update-connection-credentials', - defaults: ['_acl' => ['swag_migration.editor']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.editor']], methods: [Request::METHOD_POST] )] public function updateConnectionCredentials(Request $request, Context $context): Response @@ -207,7 +209,7 @@ public function updateConnectionCredentials(Request $request, Context $context): #[Route( path: '/api/_action/migration/data-selection', name: 'api.admin.migration.data-selection', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_GET] )] public function getDataSelection(Request $request, Context $context): JsonResponse @@ -234,7 +236,7 @@ public function getDataSelection(Request $request, Context $context): JsonRespon #[Route( path: '/api/_action/migration/check-connection', name: 'api.admin.migration.check-connection', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_POST] )] public function checkConnection(Request $request, Context $context): JsonResponse @@ -287,7 +289,7 @@ public function checkConnection(Request $request, Context $context): JsonRespons #[Route( path: '/api/_action/migration/start-migration', name: 'api.admin.migration.start-migration', - defaults: ['_acl' => ['swag_migration.creator']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.creator']], methods: [Request::METHOD_POST] )] public function startMigration(Request $request, Context $context): Response @@ -320,7 +322,7 @@ public function startMigration(Request $request, Context $context): Response #[Route( path: '/api/_action/migration/get-state', name: 'api.admin.migration.get-state', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_GET] )] public function getState(Context $context): JsonResponse @@ -331,7 +333,7 @@ public function getState(Context $context): JsonResponse #[Route( path: '/api/_action/migration/approve-finished', name: 'api.admin.migration.approveFinished', - defaults: ['_acl' => ['swag_migration.editor']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.editor']], methods: [Request::METHOD_POST] )] public function approveFinishedMigration(Context $context): Response @@ -352,7 +354,7 @@ public function approveFinishedMigration(Context $context): Response #[Route( path: '/api/_action/migration/abort-migration', name: 'api.admin.migration.abort-migration', - defaults: ['_acl' => ['swag_migration.editor']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.editor']], methods: [Request::METHOD_POST] )] public function abortMigration(Context $context): Response @@ -365,7 +367,7 @@ public function abortMigration(Context $context): Response #[Route( path: '/api/_action/migration/reset-checksums', name: 'api.admin.migration.reset-checksums', - defaults: ['_acl' => ['swag_migration.deleter']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.deleter']], methods: [Request::METHOD_POST] )] public function resetChecksums(Request $request, Context $context): Response @@ -384,7 +386,7 @@ public function resetChecksums(Request $request, Context $context): Response #[Route( path: '/api/_action/migration/cleanup-migration-data', name: 'api.admin.migration.cleanup-migration-data', - defaults: ['_acl' => ['swag_migration.deleter']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.deleter']], methods: [Request::METHOD_POST] )] public function cleanupMigrationData(Context $context): Response @@ -397,7 +399,7 @@ public function cleanupMigrationData(Context $context): Response #[Route( path: '/api/_action/migration/is-truncating-migration-data', name: 'api.admin.migration.get-reset-status', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_GET] )] public function isTruncatingMigrationData(Context $context): JsonResponse @@ -414,7 +416,7 @@ public function isTruncatingMigrationData(Context $context): JsonResponse #[Route( path: '/api/_action/migration/is-resetting-checksums', name: 'api.admin.migration.is-resetting-checksums', - defaults: ['_acl' => ['swag_migration.viewer']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['swag_migration.viewer']], methods: [Request::METHOD_GET] )] public function isResettingChecksums(Context $context): JsonResponse @@ -436,7 +438,7 @@ public function isResettingChecksums(Context $context): JsonResponse #[Route( path: '/api/_action/migration/resume-after-fixes', name: 'api.admin.migration.resume-after-fixes', - defaults: ['_acl' => ['admin']], + defaults: [PlatformRequest::ATTRIBUTE_ACL => ['admin']], methods: [Request::METHOD_POST] )] public function resumeAfterFixes(Context $context): Response diff --git a/src/DependencyInjection/migration.xml b/src/DependencyInjection/migration.xml index c375ecf6a..40fae385e 100644 --- a/src/DependencyInjection/migration.xml +++ b/src/DependencyInjection/migration.xml @@ -225,7 +225,7 @@ - + @@ -295,6 +295,15 @@ + + + + + + + + + @@ -421,13 +430,18 @@ id="SwagMigrationAssistant\Core\Content\Product\Stock\StockStorageDecorator.inner"/> - + + + + + + diff --git a/src/Exception/MigrationException.php b/src/Exception/MigrationException.php index 4b5cffb2f..38e8b682d 100644 --- a/src/Exception/MigrationException.php +++ b/src/Exception/MigrationException.php @@ -93,6 +93,8 @@ class MigrationException extends HttpException final public const DUPLICATE_SOURCE_CONNECTION = 'SWAG_MIGRATION__DUPLICATE_SOURCE_CONNECTION'; + final public const MISSING_REQUEST_PARAMETER = 'SWAG_MIGRATION__MISSING_REQUEST_PARAMETER'; + public static function associationEntityRequiredMissing(string $entity, string $missingEntity): self { return new self( @@ -476,4 +478,14 @@ 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] + ); + } } 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/Service/MigrationDataConverter.php b/src/Migration/Service/MigrationDataConverter.php index 8f3a39687..2127d03c9 100644 --- a/src/Migration/Service/MigrationDataConverter.php +++ b/src/Migration/Service/MigrationDataConverter.php @@ -25,7 +25,7 @@ use SwagMigrationAssistant\Migration\Mapping\MappingServiceInterface; use SwagMigrationAssistant\Migration\Media\MediaFileServiceInterface; use SwagMigrationAssistant\Migration\MigrationContextInterface; -use SwagMigrationAssistant\Migration\Validation\MigrationValidationService; +use SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService; #[Package('fundamentals@after-sales')] class MigrationDataConverter implements MigrationDataConverterInterface @@ -37,7 +37,7 @@ public function __construct( private readonly LoggingServiceInterface $loggingService, private readonly EntityDefinition $dataDefinition, private readonly MappingServiceInterface $mappingService, - private readonly MigrationValidationService $validationService, + private readonly MigrationEntityValidationService $validationService, ) { } diff --git a/src/Migration/Validation/Exception/MigrationValidationException.php b/src/Migration/Validation/Exception/MigrationValidationException.php index 6e557120a..8fa6217e7 100644 --- a/src/Migration/Validation/Exception/MigrationValidationException.php +++ b/src/Migration/Validation/Exception/MigrationValidationException.php @@ -29,6 +29,8 @@ class MigrationValidationException extends MigrationException final public const VALIDATION_INVALID_ASSOCIATION = 'SWAG_MIGRATION_VALIDATION__INVALID_ASSOCIATION'; + final public const VALIDATION_ENTITY_FIELD_NOT_FOUND = 'SWAG_MIGRATION_VALIDATION__ENTITY_FIELD_NOT_FOUND'; + public static function unexpectedNullValue(string $fieldName): self { return new self( @@ -49,33 +51,36 @@ public static function invalidId(string $entityId, string $entityName): self ); } - public static function invalidRequiredFieldValue(string $entityName, string $fieldName, string $message): self + public static function invalidRequiredFieldValue(string $entityName, string $fieldName, ?\Throwable $previous = null): self { return new self( Response::HTTP_BAD_REQUEST, self::VALIDATION_INVALID_REQUIRED_FIELD_VALUE, 'Invalid value for required field "{{ fieldName }}" in entity "{{ entityName }}": {{ message }}', - ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $message] + ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $previous?->getMessage() ?? ''], + $previous ); } - public static function invalidOptionalFieldValue(string $entityName, string $fieldName, string $message): self + public static function invalidOptionalFieldValue(string $entityName, string $fieldName, ?\Throwable $previous = null): self { return new self( Response::HTTP_BAD_REQUEST, self::VALIDATION_INVALID_OPTIONAL_FIELD_VALUE, 'Invalid value for optional field "{{ fieldName }}" in entity "{{ entityName }}": {{ message }}', - ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $message] + ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $previous?->getMessage() ?? ''], + $previous ); } - public static function invalidTranslation(string $entityName, string $fieldName, string $message): self + public static function invalidTranslation(string $entityName, string $fieldName, ?\Throwable $previous = null): self { return new self( Response::HTTP_BAD_REQUEST, self::VALIDATION_INVALID_TRANSLATION, 'Invalid translation for field "{{ fieldName }}" in entity "{{ entityName }}": {{ message }}', - ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $message] + ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $previous?->getMessage() ?? ''], + $previous ); } @@ -88,4 +93,14 @@ public static function invalidAssociation(string $entityName, string $fieldName, ['fieldName' => $fieldName, 'entityName' => $entityName, 'message' => $message] ); } + + public static function entityFieldNotFound(string $entityName, string $fieldName): self + { + return new self( + Response::HTTP_NOT_FOUND, + self::VALIDATION_ENTITY_FIELD_NOT_FOUND, + 'Field "{{ fieldName }}" not found in entity "{{ entityName }}".', + ['fieldName' => $fieldName, 'entityName' => $entityName] + ); + } } diff --git a/src/Migration/Validation/MigrationValidationService.php b/src/Migration/Validation/MigrationEntityValidationService.php similarity index 62% rename from src/Migration/Validation/MigrationValidationService.php rename to src/Migration/Validation/MigrationEntityValidationService.php index 79b3ba14c..474157dd4 100644 --- a/src/Migration/Validation/MigrationValidationService.php +++ b/src/Migration/Validation/MigrationEntityValidationService.php @@ -12,24 +12,14 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\CompiledFieldCollection; use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; -use Shopware\Core\Framework\DataAbstractionLayer\Field\AssociationField; use Shopware\Core\Framework\DataAbstractionLayer\Field\CreatedAtField; use Shopware\Core\Framework\DataAbstractionLayer\Field\Field; use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required; -use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField; -use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField; -use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToManyAssociationField; -use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField; use Shopware\Core\Framework\DataAbstractionLayer\Field\ReferenceVersionField; use Shopware\Core\Framework\DataAbstractionLayer\Field\StorageAware; use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslationsAssociationField; use Shopware\Core\Framework\DataAbstractionLayer\Field\UpdatedAtField; use Shopware\Core\Framework\DataAbstractionLayer\Field\VersionField; -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\Migration\Logging\Log\Builder\MigrationLogBuilder; @@ -51,7 +41,7 @@ * @internal */ #[Package('fundamentals@after-sales')] -class MigrationValidationService implements ResetInterface +class MigrationEntityValidationService implements ResetInterface { /** * @var list> @@ -82,6 +72,7 @@ public function __construct( private readonly DefinitionInstanceRegistry $definitionRegistry, private readonly EventDispatcherInterface $eventDispatcher, private readonly LoggingServiceInterface $loggingService, + private readonly MigrationFieldValidationService $fieldValidationService, private readonly Connection $connection, ) { } @@ -95,7 +86,7 @@ public function reset(): void * @param array|null $convertedEntity * @param array $sourceData * - * @throws \Exception|Exception + * @throws \Exception */ public function validate( MigrationContextInterface $migrationContext, @@ -126,8 +117,12 @@ public function validate( new MigrationPreValidationEvent($validationContext), ); - $this->validateEntityStructure($validationContext); - $this->validateFieldValues($validationContext); + try { + $this->validateEntityStructure($validationContext); + $this->validateFieldValues($validationContext); + } catch (\Throwable $exception) { + $this->addExceptionLog($validationContext, $exception); + } $this->eventDispatcher->dispatch( new MigrationPostValidationEvent($validationContext), @@ -178,29 +173,24 @@ private function validateFieldValues(MigrationValidationContext $validationConte $entityDefinition = $validationContext->getEntityDefinition(); $entityName = $entityDefinition->getEntityName(); - $fields = $entityDefinition->getFields(); - - $entityExistence = EntityExistence::createForEntity($entityName, ['id' => $id]); - $parameters = new WriteParameterBag( - $entityDefinition, - WriteContext::createFromContext($validationContext->getContext()), - '', - new WriteCommandQueue(), - ); + $fields = $entityDefinition->getFields(); $requiredFields = $this->getRequiredFields($fields, $entityName); foreach ($convertedData as $fieldName => $value) { - $this->validateField( - $validationContext, - $fields, - $fieldName, - $value, - $id, - $entityExistence, - $parameters, - isset($requiredFields[$fieldName]) - ); + try { + $this->fieldValidationService->validateField( + $entityName, + $fieldName, + $value, + $validationContext->getContext(), + isset($requiredFields[$fieldName]) + ); + } catch (MigrationValidationException $exception) { + $this->addValidationExceptionLog($validationContext, $exception, $fieldName, $value, (string) $id); + } catch (\Throwable $exception) { + $this->addExceptionLog($validationContext, $exception); + } } } @@ -227,166 +217,6 @@ private function validateId(MigrationValidationContext $validationContext, mixed return true; } - private function validateField( - MigrationValidationContext $validationContext, - CompiledFieldCollection $fields, - string $fieldName, - mixed $value, - string $id, - EntityExistence $existence, - WriteParameterBag $parameters, - bool $isRequired, - ): void { - if (!$fields->has($fieldName)) { - return; - } - - $field = clone $fields->get($fieldName); - - try { - if ($field instanceof TranslationsAssociationField) { - $this->validateFieldByFieldSerializer($field, $value, $existence, $parameters, $isRequired); - - return; - } - - if ($field instanceof ManyToManyAssociationField || $field instanceof OneToManyAssociationField) { - $this->validateToManyAssociationStructure($validationContext, $fieldName, $value); - - return; - } - - if ($field instanceof ManyToOneAssociationField || $field instanceof OneToOneAssociationField) { - $this->validateToOneAssociationStructure($validationContext, $fieldName, $value); - - return; - } - - if ($field instanceof AssociationField) { - return; - } - - $this->validateFieldByFieldSerializer($field, $value, $existence, $parameters, $isRequired); - } catch (MigrationValidationException $exception) { - $this->addValidationExceptionLog($validationContext, $exception, $fieldName, $value, $id); - } catch (\Throwable $exception) { - $this->addExceptionLog($validationContext, $exception); - } - } - - /** - * @throws MigrationValidationException|\Exception - */ - private function validateFieldByFieldSerializer( - Field $field, - mixed $value, - EntityExistence $entityExistence, - WriteParameterBag $parameters, - bool $isRequired, - ): void { - /** - * Replace all flags with Required to force the serializer to validate this field. - * AbstractFieldSerializer::requiresValidation() skips validation for fields without Required flag. - * The field is cloned before this method is called to avoid mutating the original definition. - */ - $field->setFlags(new Required()); - - $keyValue = new KeyValuePair( - $field->getPropertyName(), - $value, - true - ); - - try { - $serializer = $field->getSerializer(); - - // Consume the generator to trigger validation. Keys are not needed - \iterator_to_array($serializer->encode( - $field, - $entityExistence, - $keyValue, - $parameters - ), false); - } catch (\Throwable $e) { - $entityName = $parameters->getDefinition()->getEntityName(); - $propertyName = $field->getPropertyName(); - - if ($field instanceof TranslationsAssociationField) { - throw MigrationValidationException::invalidTranslation($entityName, $propertyName, $e->getMessage()); - } - - if ($isRequired) { - throw MigrationValidationException::invalidRequiredFieldValue($entityName, $propertyName, $e->getMessage()); - } - - throw MigrationValidationException::invalidOptionalFieldValue($entityName, $propertyName, $e->getMessage()); - } - } - - /** - * @throws MigrationValidationException - */ - private function validateToManyAssociationStructure( - MigrationValidationContext $validationContext, - string $fieldName, - mixed $value, - ): void { - $entityName = $validationContext->getEntityDefinition()->getEntityName(); - - if (!\is_array($value)) { - throw MigrationValidationException::invalidAssociation( - $entityName, - $fieldName, - \sprintf('must be an array, got %s', \get_debug_type($value)) - ); - } - - foreach ($value as $index => $entry) { - if (!\is_array($entry)) { - throw MigrationValidationException::invalidAssociation( - $entityName, - $fieldName . '/' . $index, - \sprintf('entry at index %s must be an array, got %s', $index, \get_debug_type($entry)) - ); - } - - if (isset($entry['id']) && !Uuid::isValid($entry['id'])) { - throw MigrationValidationException::invalidAssociation( - $entityName, - $fieldName . '/' . $index . '/id', - \sprintf('invalid UUID "%s" at index %s', $entry['id'], $index) - ); - } - } - } - - /** - * @throws MigrationValidationException - */ - private function validateToOneAssociationStructure( - MigrationValidationContext $validationContext, - string $fieldName, - mixed $value, - ): void { - $entityName = $validationContext->getEntityDefinition()->getEntityName(); - - if (!\is_array($value)) { - throw MigrationValidationException::invalidAssociation( - $entityName, - $fieldName, - \sprintf('must be an array, got %s', \get_debug_type($value)) - ); - } - - if (isset($value['id']) && !Uuid::isValid($value['id'])) { - throw MigrationValidationException::invalidAssociation( - $entityName, - $fieldName . '/id', - \sprintf('invalid UUID "%s"', $value['id']) - ); - } - } - /** * @throws Exception * diff --git a/src/Migration/Validation/MigrationFieldValidationService.php b/src/Migration/Validation/MigrationFieldValidationService.php new file mode 100644 index 000000000..9ce96363b --- /dev/null +++ b/src/Migration/Validation/MigrationFieldValidationService.php @@ -0,0 +1,243 @@ + + * 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\AssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\Field; +use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToManyAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\ManyToOneAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToManyAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\OneToOneAssociationField; +use Shopware\Core\Framework\DataAbstractionLayer\Field\TranslationsAssociationField; +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; +use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; + +/** + * @internal + */ +#[Package('fundamentals@after-sales')] +readonly class MigrationFieldValidationService +{ + public function __construct( + private DefinitionInstanceRegistry $definitionRegistry, + ) { + } + + /** + * @throws \Exception|MigrationException + */ + public function validateField( + string $entityName, + string $fieldName, + mixed $value, + Context $context, + bool $isRequired = true, + ): void { + if (!$this->definitionRegistry->has($entityName)) { + throw MigrationException::entityNotExists($entityName, $fieldName); + } + + $entityDefinition = $this->definitionRegistry->getByEntityName($entityName); + $fields = $entityDefinition->getFields(); + + if (!$fields->has($fieldName)) { + throw MigrationValidationException::entityFieldNotFound($entityName, $fieldName); + } + + $field = clone $fields->get($fieldName); + + $existence = EntityExistence::createForEntity( + $entityDefinition->getEntityName(), + ['id' => Uuid::randomHex()], + ); + + $parameters = new WriteParameterBag( + $entityDefinition, + WriteContext::createFromContext($context), + '', + new WriteCommandQueue(), + ); + + if ($field instanceof TranslationsAssociationField) { + $this->validateTranslationAssociationStructure($field, $value, $entityName); + + return; + } + + if ($field instanceof ManyToManyAssociationField || $field instanceof OneToManyAssociationField) { + $this->validateToManyAssociationStructure($field, $value, $entityName); + + return; + } + + if ($field instanceof ManyToOneAssociationField || $field instanceof OneToOneAssociationField) { + $this->validateToOneAssociationStructure($field, $value, $entityName); + + return; + } + + if ($field instanceof AssociationField) { + return; + } + + $this->validateFieldByFieldSerializer( + $field, + $value, + $isRequired, + $existence, + $parameters, + ); + } + + private function validateFieldByFieldSerializer( + Field $field, + mixed $value, + bool $isRequired, + EntityExistence $existence, + WriteParameterBag $parameters, + ): void { + /** + * Replace all flags with Required to force the serializer to validate this field. + * AbstractFieldSerializer::requiresValidation() skips validation for fields without Required flag. + * The field is cloned before this method is called to avoid mutating the original definition. + */ + $field->setFlags(new Required()); + + $keyValue = new KeyValuePair( + $field->getPropertyName(), + $value, + true + ); + + $serializer = $field->getSerializer(); + + try { + // Consume the generator to trigger validation. Keys are not needed + \iterator_to_array($serializer->encode( + $field, + $existence, + $keyValue, + $parameters + ), false); + } catch (\Throwable $e) { + $entityName = $parameters->getDefinition()->getEntityName(); + $propertyName = $field->getPropertyName(); + + if ($field instanceof TranslationsAssociationField) { + throw MigrationValidationException::invalidTranslation( + $entityName, + $propertyName, + $e, + ); + } + + if ($isRequired) { + throw MigrationValidationException::invalidRequiredFieldValue( + $entityName, + $propertyName, + $e + ); + } + + throw MigrationValidationException::invalidOptionalFieldValue( + $entityName, + $propertyName, + $e + ); + } + } + + private function validateToManyAssociationStructure(Field $field, mixed $value, string $entityName): void + { + if (!\is_array($value)) { + throw MigrationValidationException::invalidAssociation( + $entityName, + $field->getPropertyName(), + \sprintf('must be an array, got %s', \get_debug_type($value)) + ); + } + + foreach ($value as $index => $entry) { + if (!\is_array($entry)) { + throw MigrationValidationException::invalidAssociation( + $entityName, + $field->getPropertyName() . '/' . $index, + \sprintf('entry at index %s must be an array, got %s', $index, \get_debug_type($entry)) + ); + } + + if (isset($entry['id']) && !Uuid::isValid($entry['id'])) { + throw MigrationValidationException::invalidAssociation( + $entityName, + $field->getPropertyName() . '/' . $index . '/id', + \sprintf('invalid UUID "%s" at index %s', $entry['id'], $index) + ); + } + } + } + + private function validateToOneAssociationStructure(Field $field, mixed $value, string $entityName): void + { + if (!\is_array($value)) { + throw MigrationValidationException::invalidAssociation( + $entityName, + $field->getPropertyName(), + \sprintf('must be an array, got %s', \get_debug_type($value)) + ); + } + + if (isset($value['id']) && !Uuid::isValid($value['id'])) { + throw MigrationValidationException::invalidAssociation( + $entityName, + $field->getPropertyName() . '/id', + \sprintf('invalid UUID "%s"', $value['id']) + ); + } + } + + private function validateTranslationAssociationStructure(TranslationsAssociationField $field, mixed $value, string $entityName): void + { + if (!\is_array($value)) { + throw MigrationValidationException::invalidTranslation( + $entityName, + $field->getPropertyName(), + new \InvalidArgumentException(\sprintf('must be an array, got %s', \get_debug_type($value))) + ); + } + + foreach ($value as $languageId => $translation) { + // Language key must be a string (UUID or locale code like 'en-GB') + if (!\is_string($languageId) || $languageId === '') { + throw MigrationValidationException::invalidTranslation( + $entityName, + $field->getPropertyName(), + new \InvalidArgumentException(\sprintf('language key must be a non-empty string, got %s', \get_debug_type($languageId))) + ); + } + + // Each translation entry must be an array + if (!\is_array($translation)) { + throw MigrationValidationException::invalidTranslation( + $entityName, + $field->getPropertyName() . '/' . $languageId, + new \InvalidArgumentException(\sprintf('translation entry must be an array, got %s', \get_debug_type($translation))) + ); + } + } + } +} 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..c09c8cea5 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: { @@ -34,11 +36,21 @@ export default Shopware.Component.wrapComponentConfig({ 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 +62,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 1a703364a..efe0662f8 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,60 @@ 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 null; + }); + + if (!serializationError) { + return false; + } + + if (serializationError.valid === true) { + return true; + } + + const message = serializationError.violations?.at(0)?.message; + + if (!message) { + return false; + } + + this.fieldError = { detail: 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 7174880c9..452f0d99e 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 @@ -99,6 +99,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 194bb245d..a8bcbec17 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 @@ -650,6 +650,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.", + "fetchExampleFailed": "Beispieldaten konnten nicht abgerufen werden.", + "validationFailed": "Validierung fehlgeschlagen.", "invalidJsonInput": "Diese Eingabe ist kein gültiges JSON.", "resetResolutionFailed": "Fehler beim Zurücksetzen der Fehlerbehebung." }, 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 d3259cf71..c48cb85fd 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 @@ -502,7 +502,9 @@ "fetchFilterDataFailed": "Failed fetching filter data.", "fetchExistingFixesFailed": "Failed fetching existing fixes.", "invalidJsonInput": "This input is not valid JSON.", - "resetResolutionFailed": "Failed resetting error resolution." + "resetResolutionFailed": "Failed resetting error resolution.", + "fetchExampleFailed": "Failed fetching example data.", + "validationFailed": "Validation failed." }, "step": { "continue-modal": { diff --git a/tests/Jest/src/core/service/api/swag-migration.api.service.spec.js b/tests/Jest/src/core/service/api/swag-migration.api.service.spec.js index 1b7b4d090..86efcfe6b 100644 --- a/tests/Jest/src/core/service/api/swag-migration.api.service.spec.js +++ b/tests/Jest/src/core/service/api/swag-migration.api.service.spec.js @@ -376,4 +376,39 @@ describe('src/core/service/api/swag-migration.api.service', () => { expect(clientMock.history.post[0].data).toBe(JSON.stringify(data)); expect(clientMock.history.post[0].headers['test-header']).toBe('test-value'); }); + + it('should validate resolution', async () => { + const { migrationApiService, clientMock } = createMigrationApiService(); + + const data = { + entityName: 'product', + fieldName: 'name', + fieldValue: 'New Product Name', + }; + + await migrationApiService.validateResolution(data.entityName, data.fieldName, data.fieldValue, { + 'test-header': 'test-value', + }); + + expect(clientMock.history.post[0].url).toBe('_action/migration/error-resolution/validate'); + expect(clientMock.history.post[0].data).toBe(JSON.stringify(data)); + expect(clientMock.history.post[0].headers['test-header']).toBe('test-value'); + }); + + it('should get example field structure', async () => { + const { migrationApiService, clientMock } = createMigrationApiService(); + + const data = { + entityName: 'product', + fieldName: 'name', + }; + + await migrationApiService.getExampleFieldStructure(data.entityName, data.fieldName, { + 'test-header': 'test-value', + }); + + expect(clientMock.history.post[0].url).toBe('_action/migration/error-resolution/example-field-structure'); + expect(clientMock.history.post[0].data).toBe(JSON.stringify(data)); + expect(clientMock.history.post[0].headers['test-header']).toBe('test-value'); + }); }); diff --git a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar.spec.js b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar.spec.js index 39e8f093d..432697e31 100644 --- a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar.spec.js +++ b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-scalar.spec.js @@ -10,6 +10,7 @@ const updateFieldValueMock = jest.fn(); const defaultProps = { componentType: 'text', + entityName: 'customer', entityField: { entity: 'customer', type: 'string', @@ -175,4 +176,42 @@ describe('src/module/swag-migration/component/swag-migration-error-resolution/sw await wrapper.setProps({ disabled: true }); expect(wrapper.find('.sw-migration-error-resolution-field__datepicker input').attributes('disabled')).toBeDefined(); }); + + it('should init bool field value for switch component', async () => { + const props = { + ...defaultProps, + componentType: 'switch', + entityField: { + entity: 'customer', + type: 'bool', + }, + fieldName: 'active', + }; + + const wrapper = await createWrapper(props); + + expect(wrapper.vm.fieldValue).toBe(false); + expect(wrapper.find('.sw-migration-error-resolution-field__switch input').element.checked).toBe(false); + }); + + it('should init example value when set', async () => { + const props = { + ...defaultProps, + componentType: 'editor', + entityField: { + entity: 'product', + type: 'string', + }, + fieldName: 'price', + }; + + const wrapper = await createWrapper(props); + expect(wrapper.find('.sw-migration-error-resolution-field__editor').exists()).toBe(true); + + expect(wrapper.vm.fieldValue).toBeNull(); + + const example = 'Sample example value'; + await wrapper.setProps({ exampleValue: example }); + expect(wrapper.vm.fieldValue).toBe(example); + }); }); diff --git a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled.spec.js b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled.spec.js index 6ad3e1182..57ca8c333 100644 --- a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled.spec.js +++ b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field-unhandled.spec.js @@ -23,9 +23,9 @@ async function createWrapper(props = defaultProps) { stubs: { 'sw-code-editor': await wrapTestComponent('sw-code-editor'), 'sw-base-field': await wrapTestComponent('sw-base-field'), + 'sw-field-error': await wrapTestComponent('sw-field-error'), 'sw-inheritance-switch': true, 'sw-ai-copilot-badge': true, - 'sw-field-error': true, 'sw-circle-icon': true, 'sw-help-text': true, 'mt-banner': true, @@ -71,113 +71,38 @@ describe('src/module/swag-migration/component/swag-migration-error-resolution/sw const wrapper = await createWrapper(); await flushPromises(); - expect(wrapper.find('.sw-code-editor__editor').attributes('content')).toBe(''); - expect(updateFieldValueMock).toHaveBeenCalledWith(''); - }); - - it.each([ - { name: 'string', value: '"test string"', expected: 'test string' }, - { name: 'number', value: '123', expected: 123 }, - { name: 'boolean', value: 'true', expected: true }, - { name: 'null', value: 'null', expected: null }, - { name: 'object', value: '{"key": "value"}', expected: { key: 'value' } }, - { - name: 'array', - value: '[1, 2, 3]', - expected: [ - 1, - 2, - 3, - ], - }, - ])('should parse and publish JSON values: $name', async ({ value, expected }) => { - const wrapper = await createWrapper(); - await flushPromises(); - jest.clearAllMocks(); - - await wrapper.setData({ fieldValue: value }); - await flushPromises(); - - expect(updateFieldValueMock).toHaveBeenCalledWith(expected); - }); - - it.each([ - { - name: 'trailing comma in object', - value: '{"key": "value",}', - }, - { - name: 'trailing comma in array', - value: '[1, 2, 3,]', - }, - { - name: 'nested trailing commas', - value: '{"outer": {"inner": "value",},}', - }, - ])('should return null and set error for trailing commas: $name', async ({ value }) => { - const wrapper = await createWrapper(); - await flushPromises(); - jest.clearAllMocks(); - - await wrapper.setData({ fieldValue: value }); - await flushPromises(); - + expect(wrapper.find('.sw-code-editor__editor').attributes('content')).toBeUndefined(); expect(updateFieldValueMock).toHaveBeenCalledWith(null); - expect(wrapper.vm.error).not.toBeNull(); - expect(wrapper.vm.error.detail).toBeDefined(); }); - it.each([ - { - name: 'invalid JSON', - value: 'not json', - }, - { - name: 'incomplete object', - value: '{"key": ', - }, - { - name: 'single quotes', - value: "{'key': 'value'}", - }, - ])('should return null and set error for invalid JSON: $name', async ({ value }) => { + it('should display error message when passed', async () => { const wrapper = await createWrapper(); await flushPromises(); - jest.clearAllMocks(); - - await wrapper.setData({ fieldValue: value }); - await flushPromises(); - expect(updateFieldValueMock).toHaveBeenCalledWith(null); - expect(wrapper.vm.error).not.toBeNull(); - expect(wrapper.vm.error.detail).toBeDefined(); - }); + expect(wrapper.find('.sw-field__error').exists()).toBe(false); - it('should trim whitespace from JSON string', async () => { - const wrapper = await createWrapper(); - await flushPromises(); - jest.clearAllMocks(); - - await wrapper.setData({ fieldValue: ' {"key": "value"} ' }); - await flushPromises(); + const message = 'This is an error message'; + await wrapper.setProps({ + error: { + detail: message, + }, + }); - expect(updateFieldValueMock).toHaveBeenCalledWith({ key: 'value' }); - expect(wrapper.vm.error).toBeNull(); + expect(wrapper.find('.sw-field__error').exists()).toBe(true); + expect(wrapper.find('.sw-field__error').text()).toBe(message); }); - it('should clear error when valid JSON is entered after invalid JSON', async () => { + it('should init with example value when set', async () => { const wrapper = await createWrapper(); await flushPromises(); - await wrapper.setData({ fieldValue: 'invalid json' }); - await flushPromises(); - expect(wrapper.vm.error).not.toBeNull(); + expect(wrapper.find('.sw-code-editor__editor').attributes('content')).toBeUndefined(); - jest.clearAllMocks(); - await wrapper.setData({ fieldValue: '{"valid": "json"}' }); - await flushPromises(); + const exampleValue = 'example content'; + await wrapper.setProps({ + exampleValue: exampleValue, + }); - expect(wrapper.vm.error).toBeNull(); - expect(updateFieldValueMock).toHaveBeenCalledWith({ valid: 'json' }); + expect(wrapper.find('.sw-code-editor__editor').attributes('content')).toBe(exampleValue); }); }); diff --git a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field.spec.js b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field.spec.js index 6c18ecc0d..02e0e9c82 100644 --- a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field.spec.js +++ b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field.spec.js @@ -7,6 +7,14 @@ import SwagMigrationErrorResolutionService from 'SwagMigrationAssistant/module/s Shopware.Component.register('swag-migration-error-resolution-field', () => SwagMigrationErrorResolutionField); +const migrationApiServiceMock = { + getExampleFieldStructure: jest.fn().mockResolvedValue( + Promise.resolve({ + example: 'Example Value', + }), + ), +}; + const defaultProps = { log: { count: 10, @@ -30,12 +38,17 @@ async function createWrapper(props = defaultProps) { }, provide: { swagMigrationErrorResolutionService: new SwagMigrationErrorResolutionService(), + migrationApiService: migrationApiServiceMock, }, }, }); } describe('src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-field/swag-migration-error-resolution-field', () => { + beforeEach(() => { + Shopware.Store.get('notification').$reset(); + }); + it('should display unhandled component & pass data', async () => { const props = { ...defaultProps, @@ -48,12 +61,16 @@ describe('src/module/swag-migration/component/swag-migration-error-resolution/sw }; const wrapper = await createWrapper(props); + await flushPromises(); + + expect(migrationApiServiceMock.getExampleFieldStructure).toHaveBeenCalled(); const field = wrapper.find('swag-migration-error-resolution-field-unhandled-stub'); expect(field.exists()).toBe(true); expect(field.attributes('disabled')).toBe(String(props.disabled)); expect(field.attributes('field-name')).toBe(props.log.fieldName); + expect(field.attributes('example-value')).toBe('Example Value'); }); it('should display scalar component & pass data', async () => { @@ -68,6 +85,9 @@ describe('src/module/swag-migration/component/swag-migration-error-resolution/sw }; const wrapper = await createWrapper(props); + await flushPromises(); + + expect(migrationApiServiceMock.getExampleFieldStructure).not.toHaveBeenCalled(); const field = wrapper.find('swag-migration-error-resolution-field-scalar-stub'); @@ -89,6 +109,9 @@ describe('src/module/swag-migration/component/swag-migration-error-resolution/sw }; const wrapper = await createWrapper(props); + await flushPromises(); + + expect(migrationApiServiceMock.getExampleFieldStructure).not.toHaveBeenCalled(); const field = wrapper.find('swag-migration-error-resolution-field-relation-stub'); @@ -96,4 +119,50 @@ describe('src/module/swag-migration/component/swag-migration-error-resolution/sw expect(field.attributes('disabled')).toBe(String(props.disabled)); expect(field.attributes('field-name')).toBe(props.log.fieldName); }); + + it('should fetch example field value', async () => { + const wrapper = await createWrapper({ + ...defaultProps, + log: { + ...defaultProps.log, + entityName: 'product', + fieldName: 'price', + }, + }); + await flushPromises(); + + expect(migrationApiServiceMock.getExampleFieldStructure).toHaveBeenCalled(); + + expect(wrapper.find('swag-migration-error-resolution-field-scalar-stub').exists()).toBe(true); + expect(wrapper.find('swag-migration-error-resolution-field-scalar-stub').attributes('example-value')).toBe( + 'Example Value', + ); + }); + + it('should display error notification on fetch failure', async () => { + migrationApiServiceMock.getExampleFieldStructure.mockRejectedValueOnce(new Error('Fetch failed')); + + await createWrapper({ + ...defaultProps, + log: { + ...defaultProps.log, + entityName: 'product', + fieldName: 'price', + }, + }); + await flushPromises(); + + expect(migrationApiServiceMock.getExampleFieldStructure).toHaveBeenCalled(); + + const notifications = Object.values(Shopware.Store.get('notification').notifications); + + expect(notifications).toHaveLength(1); + expect(notifications).toStrictEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: 'swag-migration.index.error-resolution.errors.fetchExampleFailed', + }), + ]), + ); + }); }); diff --git a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js index a59bd915e..9a65aa112 100644 --- a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js +++ b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-modal.spec.js @@ -60,6 +60,7 @@ const defaultProps = { }; const migrationApiServiceMock = { + validateResolution: jest.fn(() => Promise.resolve({ valid: true })), getAllLogIds: jest.fn(() => Promise.resolve({ ids: logMocks.map((log) => log.id), @@ -545,6 +546,122 @@ describe('module/swag-migration/component/swag-migration-error-resolution/swag-m }); describe('create resolution fix', () => { + it('should not call backend validation when resolving to-many association fields', async () => { + const wrapper = await createWrapper({ + ...defaultProps, + selectedLog: { + ...fixtureLogGroups.at(1), + entityName: 'product', + fieldName: 'categories', + }, + }); + await flushPromises(); + + await wrapper.find('.sw-data-grid__row--1 .mt-field--checkbox input').setChecked(true); + await flushPromises(); + + const relationField = wrapper.findComponent({ name: 'swag-migration-error-resolution-field-relation' }); + await relationField.setData({ + fieldValue: [ + 'category-id-1', + 'category-id-2', + ], + }); + await flushPromises(); + + await wrapper.find('.swag-migration-error-resolution-modal__right-content-button').trigger('click'); + await flushPromises(); + + expect(migrationApiServiceMock.validateResolution).not.toHaveBeenCalled(); + }); + + it('should save fix when backend validation passes', async () => { + migrationApiServiceMock.validateResolution.mockResolvedValueOnce({ valid: true, violations: [] }); + + const wrapper = await createWrapper({ + ...defaultProps, + selectedLog: { + ...fixtureLogGroups.at(1), + entityName: 'media', + fieldName: 'title', + }, + }); + await flushPromises(); + + await wrapper.find('.sw-data-grid__row--1 .mt-field--checkbox input').setChecked(true); + await flushPromises(); + + const inputField = wrapper.find('.swag-migration-error-resolution-field-scalar input'); + await inputField.setValue('Valid Title'); + await flushPromises(); + + await wrapper.find('.swag-migration-error-resolution-modal__right-content-button').trigger('click'); + await flushPromises(); + + expect(migrationApiServiceMock.validateResolution).toHaveBeenCalledWith('media', 'title', 'Valid Title'); + expect(wrapper.vm.fieldError).toBeNull(); + expect(migrationFixRepositoryMock.saveAll).toHaveBeenCalled(); + }); + + it('should not save fix when backend validation fails without message', async () => { + migrationApiServiceMock.validateResolution.mockResolvedValueOnce({ valid: false, violations: [] }); + + const wrapper = await createWrapper({ + ...defaultProps, + selectedLog: { + ...fixtureLogGroups.at(1), + entityName: 'media', + fieldName: 'title', + }, + }); + await flushPromises(); + + await wrapper.find('.sw-data-grid__row--1 .mt-field--checkbox input').setChecked(true); + await flushPromises(); + + const inputField = wrapper.find('.swag-migration-error-resolution-field-scalar input'); + await inputField.setValue('Invalid Value'); + await flushPromises(); + + await wrapper.find('.swag-migration-error-resolution-modal__right-content-button').trigger('click'); + await flushPromises(); + + expect(migrationApiServiceMock.validateResolution).toHaveBeenCalledWith('media', 'title', 'Invalid Value'); + expect(wrapper.vm.fieldError).toBeNull(); + expect(migrationFixRepositoryMock.saveAll).not.toHaveBeenCalled(); + }); + + it('should display field error when backend validation fails with message', async () => { + migrationApiServiceMock.validateResolution.mockResolvedValueOnce({ + valid: false, + violations: [{ message: 'This value is invalid.' }], + }); + + const wrapper = await createWrapper({ + ...defaultProps, + selectedLog: { + ...fixtureLogGroups.at(1), + entityName: 'media', + fieldName: 'title', + }, + }); + await flushPromises(); + + await wrapper.find('.sw-data-grid__row--1 .mt-field--checkbox input').setChecked(true); + await flushPromises(); + + const inputField = wrapper.find('.swag-migration-error-resolution-field-scalar input'); + await inputField.setValue('Invalid Value'); + await flushPromises(); + + await wrapper.find('.swag-migration-error-resolution-modal__right-content-button').trigger('click'); + await flushPromises(); + + expect(migrationApiServiceMock.validateResolution).toHaveBeenCalledWith('media', 'title', 'Invalid Value'); + expect(wrapper.vm.fieldError).toStrictEqual({ detail: 'This value is invalid.' }); + expect(migrationFixRepositoryMock.saveAll).not.toHaveBeenCalled(); + }); + it.each(Object.keys(ERROR_CODE_COMPONENT_MAPPING).map((code) => ({ code })))( 'should render default resolve component for defined codes: $code', async ({ code }) => { diff --git a/tests/Migration/Services/MigrationDataConverterTest.php b/tests/Migration/Services/MigrationDataConverterTest.php index a5632703a..92c6e3b3d 100644 --- a/tests/Migration/Services/MigrationDataConverterTest.php +++ b/tests/Migration/Services/MigrationDataConverterTest.php @@ -19,7 +19,7 @@ use SwagMigrationAssistant\Migration\Media\MediaFileServiceInterface; use SwagMigrationAssistant\Migration\MigrationContextInterface; use SwagMigrationAssistant\Migration\Service\MigrationDataConverter; -use SwagMigrationAssistant\Migration\Validation\MigrationValidationService; +use SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService; use SwagMigrationAssistant\Test\Mock\DataSet\DataSetMock; use SwagMigrationAssistant\Test\Mock\Migration\Logging\DummyLoggingService; @@ -60,7 +60,7 @@ private function createMigrationDataConverter( ?LoggingServiceInterface $loggingService = null, ?EntityDefinition $dataDefinition = null, ?MappingServiceInterface $mappingService = null, - ?MigrationValidationService $validationService = null, + ?MigrationEntityValidationService $validationService = null, ): MigrationDataConverter { if ($entityWriter === null) { $entityWriter = $this->createMock(EntityWriter::class); @@ -87,7 +87,7 @@ private function createMigrationDataConverter( } if ($validationService === null) { - $validationService = $this->createMock(MigrationValidationService::class); + $validationService = $this->createMock(MigrationEntityValidationService::class); } return new MigrationDataConverter( diff --git a/tests/MigrationServicesTrait.php b/tests/MigrationServicesTrait.php index 92d228953..25cfc6f03 100644 --- a/tests/MigrationServicesTrait.php +++ b/tests/MigrationServicesTrait.php @@ -54,7 +54,8 @@ use SwagMigrationAssistant\Migration\Service\MigrationDataConverterInterface; use SwagMigrationAssistant\Migration\Service\MigrationDataFetcher; use SwagMigrationAssistant\Migration\Service\MigrationDataFetcherInterface; -use SwagMigrationAssistant\Migration\Validation\MigrationValidationService; +use SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService; +use SwagMigrationAssistant\Migration\Validation\MigrationFieldValidationService; use SwagMigrationAssistant\Profile\Shopware\Gateway\Api\Reader\EnvironmentReader; use SwagMigrationAssistant\Profile\Shopware\Gateway\Api\Reader\TableCountReader; use SwagMigrationAssistant\Profile\Shopware\Gateway\Api\Reader\TableReader; @@ -208,10 +209,11 @@ protected function getMigrationDataConverter( ) ); - $validationService = new MigrationValidationService( + $validationService = new MigrationEntityValidationService( $this->getContainer()->get(DefinitionInstanceRegistry::class), $this->getContainer()->get('event_dispatcher'), $loggingService, + $this->getContainer()->get(MigrationFieldValidationService::class), $this->getContainer()->get(Connection::class), ); diff --git a/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php b/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php new file mode 100644 index 000000000..49198265b --- /dev/null +++ b/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php @@ -0,0 +1,327 @@ + + * 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 SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; +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(MigrationValidationException::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/MigrationValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php similarity index 98% rename from tests/integration/Migration/Validation/MigrationValidationServiceTest.php rename to tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php index 3ea51f83b..d590dcf29 100644 --- a/tests/integration/Migration/Validation/MigrationValidationServiceTest.php +++ b/tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php @@ -34,8 +34,8 @@ use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredFieldValueLog; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredTranslation; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationMissingRequiredFieldLog; +use SwagMigrationAssistant\Migration\Validation\MigrationEntityValidationService; use SwagMigrationAssistant\Migration\Validation\MigrationValidationResult; -use SwagMigrationAssistant\Migration\Validation\MigrationValidationService; use SwagMigrationAssistant\Profile\Shopware54\Shopware54Profile; use SwagMigrationAssistant\Test\Mock\Gateway\Dummy\Local\DummyLocalGateway; @@ -43,8 +43,8 @@ * @internal */ #[Package('fundamentals@after-sales')] -#[CoversClass(MigrationValidationService::class)] -class MigrationValidationServiceTest extends TestCase +#[CoversClass(MigrationEntityValidationService::class)] +class MigrationEntityValidationServiceTest extends TestCase { use IntegrationTestBehaviour; @@ -52,7 +52,7 @@ class MigrationValidationServiceTest extends TestCase private MigrationContext $migrationContext; - private MigrationValidationService $validationService; + private MigrationEntityValidationService $validationService; /** * @var EntityRepository @@ -75,7 +75,7 @@ class MigrationValidationServiceTest extends TestCase protected function setUp(): void { - $this->validationService = static::getContainer()->get(MigrationValidationService::class); + $this->validationService = static::getContainer()->get(MigrationEntityValidationService::class); $this->loggingRepo = static::getContainer()->get('swag_migration_logging.repository'); $this->runRepo = static::getContainer()->get('swag_migration_run.repository'); $this->mappingRepo = static::getContainer()->get(SwagMigrationMappingDefinition::ENTITY_NAME . '.repository'); diff --git a/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php new file mode 100644 index 000000000..d4a331ca5 --- /dev/null +++ b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php @@ -0,0 +1,135 @@ + + * 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\Log\Package; +use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; +use SwagMigrationAssistant\Exception\MigrationException; +use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; +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(MigrationException::entityNotExists('test', 'field')); + + $this->migrationFieldValidationService->validateField( + 'test', + 'field', + 'value', + Context::createDefaultContext(), + ); + } + + public function testNotExistingField(): void + { + static::expectExceptionObject(MigrationValidationException::entityFieldNotFound('product', 'nonExistingField')); + + $this->migrationFieldValidationService->validateField( + 'product', + 'nonExistingField', + 'value', + Context::createDefaultContext(), + ); + } + + public function testValidPriceField(): void + { + static::expectNotToPerformAssertions(); + + $this->migrationFieldValidationService->validateField( + 'product', + 'price', + [ + [ + 'currencyId' => Defaults::CURRENCY, + 'gross' => 100.0, + 'net' => 84.03, + 'linked' => true, + ], + ], + Context::createDefaultContext(), + ); + } + + public function testInvalidPriceFieldGrossType(): void + { + static::expectException(MigrationValidationException::class); + + $this->migrationFieldValidationService->validateField( + 'product', + 'price', + [ + [ + 'currencyId' => Defaults::CURRENCY, + 'gross' => 'invalid', // should be numeric + 'net' => 84.03, + 'linked' => true, + ], + ], + Context::createDefaultContext(), + ); + } + + public function testInvalidPriceFieldMissingNet(): void + { + static::expectException(MigrationValidationException::class); + + $this->migrationFieldValidationService->validateField( + 'product', + 'price', + [ + [ + 'currencyId' => Defaults::CURRENCY, + 'gross' => 100.0, + // 'net' is missing + 'linked' => true, + ], + ], + Context::createDefaultContext(), + ); + } + + public function testInvalidPriceFieldCurrencyId(): void + { + static::expectException(MigrationValidationException::class); + + $this->migrationFieldValidationService->validateField( + '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'), + ]; + } +}