diff --git a/UPGRADE.md b/UPGRADE.md index a6e72c1d3..583bca486 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -141,13 +141,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..ec77b7364 --- /dev/null +++ b/src/Controller/ErrorResolutionController.php @@ -0,0 +1,147 @@ + + * 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\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 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 (\Throwable $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'); + } + + $resolved = $this->fieldValidationService->resolveFieldPath($entityName, $fieldName); + + if ($resolved === null) { + throw MigrationValidationException::entityFieldNotFound($entityName, $fieldName); + } + + [, $field] = $resolved; + + $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 651e37911..bf9b2fc0a 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 5ae806f3c..1cdffdde2 100644 --- a/src/DependencyInjection/migration.xml +++ b/src/DependencyInjection/migration.xml @@ -225,7 +225,7 @@ - + @@ -288,6 +288,14 @@ + + + + + + + + @@ -409,11 +417,18 @@ id="SwagMigrationAssistant\Core\Content\Product\Stock\StockStorageDecorator.inner"/> - + - + + + + + + + + diff --git a/src/Exception/MigrationException.php b/src/Exception/MigrationException.php index ff1c28442..38e8b682d 100644 --- a/src/Exception/MigrationException.php +++ b/src/Exception/MigrationException.php @@ -87,15 +87,13 @@ class MigrationException extends HttpException final public const API_CONNECTION_ERROR = 'SWAG_MIGRATION__API_CONNECTION_ERROR'; - final public const UNEXPECTED_NULL_VALUE = 'SWAG_MIGRATION__UNEXPECTED_NULL_VALUE'; - final public const COULD_NOT_CONVERT_FIX = 'SWAG_MIGRATION__COULD_NOT_CONVERT_FIX'; final public const MIGRATION_NOT_IN_STEP = 'SWAG_MIGRATION__MIGRATION_NOT_IN_STEP'; - final public const INVALID_ID = 'SWAG_MIGRATION__INVALID_ID'; + final public const DUPLICATE_SOURCE_CONNECTION = 'SWAG_MIGRATION__DUPLICATE_SOURCE_CONNECTION'; - 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 { @@ -452,16 +450,6 @@ public static function invalidWriteContext(Context $invalidContext): self ); } - public static function unexpectedNullValue(string $fieldName): self - { - return new self( - Response::HTTP_INTERNAL_SERVER_ERROR, - self::UNEXPECTED_NULL_VALUE, - 'Unexpected null value for field "{{ fieldName }}".', - ['fieldName' => $fieldName] - ); - } - public static function couldNotConvertFix(string $missingKey): self { return new self( @@ -482,22 +470,22 @@ public static function migrationNotInStep(string $runUuid, string $step): self ); } - public static function invalidId(string $entityId, string $entityName): self + public static function duplicateSourceConnection(): self { return new self( - Response::HTTP_INTERNAL_SERVER_ERROR, - self::INVALID_ID, - 'The id "{{ entityId }}" for entity "{{ entityName }}" is not a valid Uuid', - ['entityId' => $entityId, 'entityName' => $entityName] + Response::HTTP_CONFLICT, + self::DUPLICATE_SOURCE_CONNECTION, + 'A connection to this source system already exists.', ); } - public static function duplicateSourceConnection(): self + public static function missingRequestParameter(string $parameterName): self { return new self( - Response::HTTP_CONFLICT, - self::DUPLICATE_SOURCE_CONNECTION, - 'A connection to this source system already exists.', + Response::HTTP_BAD_REQUEST, + self::MISSING_REQUEST_PARAMETER, + 'Required request parameter "{{ parameterName }}" is missing.', + ['parameterName' => $parameterName] ); } } diff --git a/src/Migration/ErrorResolution/MigrationErrorResolutionService.php b/src/Migration/ErrorResolution/MigrationErrorResolutionService.php index 206ac330a..b864a5f67 100644 --- a/src/Migration/ErrorResolution/MigrationErrorResolutionService.php +++ b/src/Migration/ErrorResolution/MigrationErrorResolutionService.php @@ -44,6 +44,10 @@ public function applyFixes(array &$data, string $connectionId, string $runId, Co $this->loadFixes($errorResolutionContext); + if (empty($errorResolutionContext->getFixes())) { + return; + } + $this->eventDispatcher->dispatch( new MigrationPreErrorResolutionEvent($errorResolutionContext), ); 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/ErrorResolution/MigrationFix.php b/src/Migration/ErrorResolution/MigrationFix.php index 80530c08f..341c2f6e9 100644 --- a/src/Migration/ErrorResolution/MigrationFix.php +++ b/src/Migration/ErrorResolution/MigrationFix.php @@ -28,10 +28,13 @@ public function __construct( /** * @param array $data + * + * @throws MigrationException */ public static function fromDatabaseQuery(array $data): self { $expectedArrayKeys = ['id', 'value', 'path']; + foreach ($expectedArrayKeys as $expectedKey) { if (!\array_key_exists($expectedKey, $data)) { throw MigrationException::couldNotConvertFix($expectedKey); @@ -50,38 +53,57 @@ public static function fromDatabaseQuery(array $data): self */ public function apply(array &$item): void { - /* + /** * Explode the path to an array * Path example: 'category.language.name' * Results in an array like: ['category', 'language', 'name'] */ $pathArray = explode(self::PATH_SEPARATOR, $this->path); + $decodedValue = \json_decode($this->value, true, 512, \JSON_THROW_ON_ERROR); - /* - * Set current item as pointer - * Item structure for example has no valid value for name and looks like: - * [ - * 'someOtherKeys', - * ... - * category => [ - * ... - * 'language' => [ - * ... - * 'name' => null, - * ] - * ] - * ] - */ - $nestedPointer = &$item; + $this->applyToPath($item, $pathArray, $decodedValue); + } + + /** + * Recursively applies the fix value to the specified path. + * When encountering a list (numerically-indexed array), applies the fix to all items. + * + * @param array $data + * @param array $path + */ + private function applyToPath(array &$data, array $path, mixed $value): void + { + if (empty($path)) { + return; + } + + $nextSegment = \array_shift($path); + + // last segment of the path, "normal" set operation + if (empty($path)) { + $data[$nextSegment] = $value; + + return; + } + + $nextSegmentIsList = isset($data[$nextSegment]) + && \is_array($data[$nextSegment]) + && \array_is_list($data[$nextSegment]); + + if ($nextSegmentIsList) { + foreach ($data[$nextSegment] as &$arrayItem) { + if (\is_array($arrayItem)) { + $this->applyToPath($arrayItem, $path, $value); + } + } + + return; + } - // Iterating over the path to follow them and set the nested pointer to the last key in pathArray - // In this example the result pointer is: $item['category']['language']['name'] - foreach ($pathArray as $key) { - $nestedPointer = &$nestedPointer[$key]; + if (!isset($data[$nextSegment]) || !\is_array($data[$nextSegment])) { + $data[$nextSegment] = []; } - // Now set the value to the pointer like: $item['category']['language']['name'] = 'new Value' - $nestedPointer = \json_decode($this->value, true, 512, \JSON_THROW_ON_ERROR); - unset($nestedPointer); + $this->applyToPath($data[$nextSegment], $path, $value); } } diff --git a/src/Migration/Mapping/MappingService.php b/src/Migration/Mapping/MappingService.php index d84ab7b04..54c8f05ab 100644 --- a/src/Migration/Mapping/MappingService.php +++ b/src/Migration/Mapping/MappingService.php @@ -17,8 +17,6 @@ use Shopware\Core\Framework\DataAbstractionLayer\Search\EntitySearchResult; use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsAnyFilter; use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter; -use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\MultiFilter; -use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\NotFilter; use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityWriterInterface; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Uuid\Uuid; @@ -214,22 +212,6 @@ public function getMappings(string $connectionId, string $entityName, array $ids return $this->migrationMappingRepo->search($criteria, $context); } - public function hasValidMappingByEntityId(string $connectionId, string $entityName, string $entityId, Context $context): bool - { - $criteria = new Criteria(); - $criteria->addFilter( - new EqualsFilter('connectionId', $connectionId), - new EqualsFilter('entity', $entityName), - new EqualsFilter('entityId', $entityId), - new NotFilter(MultiFilter::CONNECTION_AND, [ - new EqualsFilter('oldIdentifier', null), - ]), - ); - $criteria->setLimit(1); - - return $this->migrationMappingRepo->searchIds($criteria, $context)->getTotal() > 0; - } - public function preloadMappings(array $mappingIds, Context $context): void { if (empty($mappingIds)) { diff --git a/src/Migration/Mapping/MappingServiceInterface.php b/src/Migration/Mapping/MappingServiceInterface.php index 20dd09660..a2e9467d4 100644 --- a/src/Migration/Mapping/MappingServiceInterface.php +++ b/src/Migration/Mapping/MappingServiceInterface.php @@ -84,7 +84,5 @@ public function writeMapping(): void; */ public function getMappings(string $connectionId, string $entityName, array $ids, Context $context): EntitySearchResult; - public function hasValidMappingByEntityId(string $connectionId, string $entityName, string $entityId, Context $context): bool; - public function preloadMappings(array $mappingIds, Context $context): void; } 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/Service/MigrationDataWriter.php b/src/Migration/Service/MigrationDataWriter.php index c62982dd0..40c9e5cf4 100644 --- a/src/Migration/Service/MigrationDataWriter.php +++ b/src/Migration/Service/MigrationDataWriter.php @@ -73,6 +73,7 @@ public function writeData(MigrationContextInterface $migrationContext, Context $ $criteria->addFilter(new EqualsFilter('entity', $dataSet::getEntity())); $criteria->addFilter(new EqualsFilter('runId', $migrationContext->getRunUuid())); $criteria->addFilter(new EqualsFilter('convertFailure', false)); + $criteria->addFilter(new EqualsFilter('written', false)); $criteria->setOffset($migrationContext->getOffset()); $criteria->setLimit($migrationContext->getLimit()); $criteria->addSorting(new FieldSorting('autoIncrement', FieldSorting::ASCENDING)); @@ -133,7 +134,7 @@ public function writeData(MigrationContextInterface $migrationContext, Context $ } unset($data); - return $migrationData->getTotal(); + return $migrationData->count(); } catch (WriteException $exception) { $this->handleWriteException( $exception, @@ -165,7 +166,7 @@ public function writeData(MigrationContextInterface $migrationContext, Context $ $context ); - return $migrationData->getTotal(); + return $migrationData->count(); } /** diff --git a/src/Migration/Validation/Exception/MigrationValidationException.php b/src/Migration/Validation/Exception/MigrationValidationException.php new file mode 100644 index 000000000..201611f34 --- /dev/null +++ b/src/Migration/Validation/Exception/MigrationValidationException.php @@ -0,0 +1,106 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Migration\Validation\Exception; + +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Migration\MigrationException; +use Symfony\Component\HttpFoundation\Response; + +/** + * @codeCoverageIgnore + */ +#[Package('fundamentals@after-sales')] +class MigrationValidationException extends MigrationException +{ + final public const VALIDATION_UNEXPECTED_NULL_VALUE = 'SWAG_MIGRATION_VALIDATION__UNEXPECTED_NULL_VALUE'; + + final public const VALIDATION_INVALID_ID = 'SWAG_MIGRATION_VALIDATION__INVALID_ID'; + + final public const VALIDATION_INVALID_REQUIRED_FIELD_VALUE = 'SWAG_MIGRATION_VALIDATION__INVALID_REQUIRED_FIELD_VALUE'; + + final public const VALIDATION_INVALID_OPTIONAL_FIELD_VALUE = 'SWAG_MIGRATION_VALIDATION__INVALID_OPTIONAL_FIELD_VALUE'; + + final public const VALIDATION_INVALID_TRANSLATION = 'SWAG_MIGRATION_VALIDATION__INVALID_TRANSLATION'; + + 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( + Response::HTTP_INTERNAL_SERVER_ERROR, + self::VALIDATION_UNEXPECTED_NULL_VALUE, + 'Unexpected null value for field "{{ fieldName }}".', + ['fieldName' => $fieldName] + ); + } + + public static function invalidId(string $entityId, string $entityName): self + { + return new self( + Response::HTTP_INTERNAL_SERVER_ERROR, + self::VALIDATION_INVALID_ID, + 'The id "{{ entityId }}" for entity "{{ entityName }}" is not a valid UUID', + ['entityId' => $entityId, 'entityName' => $entityName] + ); + } + + 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' => $previous?->getMessage() ?? ''], + $previous + ); + } + + 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' => $previous?->getMessage() ?? ''], + $previous + ); + } + + 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' => $previous?->getMessage() ?? ''], + $previous + ); + } + + public static function invalidAssociation(string $entityName, string $fieldName, string $message): self + { + return new self( + Response::HTTP_BAD_REQUEST, + self::VALIDATION_INVALID_ASSOCIATION, + 'Invalid association "{{ fieldName }}" in entity "{{ entityName }}": {{ message }}', + ['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/Log/MigrationValidationInvalidFieldValueLog.php b/src/Migration/Validation/Log/MigrationValidationInvalidAssociationLog.php similarity index 80% rename from src/Migration/Validation/Log/MigrationValidationInvalidFieldValueLog.php rename to src/Migration/Validation/Log/MigrationValidationInvalidAssociationLog.php index 5b5340165..ce1ffd980 100644 --- a/src/Migration/Validation/Log/MigrationValidationInvalidFieldValueLog.php +++ b/src/Migration/Validation/Log/MigrationValidationInvalidAssociationLog.php @@ -11,7 +11,7 @@ use SwagMigrationAssistant\Migration\Logging\Log\Builder\AbstractMigrationLogEntry; #[Package('fundamentals@after-sales')] -readonly class MigrationValidationInvalidFieldValueLog extends AbstractMigrationLogEntry +readonly class MigrationValidationInvalidAssociationLog extends AbstractMigrationLogEntry { public function isUserFixable(): bool { @@ -25,6 +25,6 @@ public function getLevel(): string public function getCode(): string { - return 'SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE'; + return 'SWAG_MIGRATION_VALIDATION_INVALID_ASSOCIATION'; } } diff --git a/src/Migration/Validation/Log/MigrationValidationInvalidForeignKeyLog.php b/src/Migration/Validation/Log/MigrationValidationInvalidOptionalFieldValueLog.php similarity index 79% rename from src/Migration/Validation/Log/MigrationValidationInvalidForeignKeyLog.php rename to src/Migration/Validation/Log/MigrationValidationInvalidOptionalFieldValueLog.php index bdd2a0ae3..71c295243 100644 --- a/src/Migration/Validation/Log/MigrationValidationInvalidForeignKeyLog.php +++ b/src/Migration/Validation/Log/MigrationValidationInvalidOptionalFieldValueLog.php @@ -11,7 +11,7 @@ use SwagMigrationAssistant\Migration\Logging\Log\Builder\AbstractMigrationLogEntry; #[Package('fundamentals@after-sales')] -readonly class MigrationValidationInvalidForeignKeyLog extends AbstractMigrationLogEntry +readonly class MigrationValidationInvalidOptionalFieldValueLog extends AbstractMigrationLogEntry { public function isUserFixable(): bool { @@ -25,6 +25,6 @@ public function getLevel(): string public function getCode(): string { - return 'SWAG_MIGRATION_VALIDATION_INVALID_FOREIGN_KEY'; + return 'SWAG_MIGRATION_VALIDATION_INVALID_OPTIONAL_FIELD_VALUE'; } } diff --git a/src/Migration/Validation/Log/MigrationValidationUnexpectedFieldLog.php b/src/Migration/Validation/Log/MigrationValidationInvalidRequiredFieldValueLog.php similarity index 74% rename from src/Migration/Validation/Log/MigrationValidationUnexpectedFieldLog.php rename to src/Migration/Validation/Log/MigrationValidationInvalidRequiredFieldValueLog.php index d363bf846..9a4cc1b38 100644 --- a/src/Migration/Validation/Log/MigrationValidationUnexpectedFieldLog.php +++ b/src/Migration/Validation/Log/MigrationValidationInvalidRequiredFieldValueLog.php @@ -11,7 +11,7 @@ use SwagMigrationAssistant\Migration\Logging\Log\Builder\AbstractMigrationLogEntry; #[Package('fundamentals@after-sales')] -readonly class MigrationValidationUnexpectedFieldLog extends AbstractMigrationLogEntry +readonly class MigrationValidationInvalidRequiredFieldValueLog extends AbstractMigrationLogEntry { public function isUserFixable(): bool { @@ -20,11 +20,11 @@ public function isUserFixable(): bool public function getLevel(): string { - return self::LOG_LEVEL_WARNING; + return self::LOG_LEVEL_ERROR; } public function getCode(): string { - return 'SWAG_MIGRATION_VALIDATION_UNEXPECTED_FIELD'; + return 'SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE'; } } diff --git a/src/Migration/Validation/Log/MigrationValidationInvalidRequiredTranslation.php b/src/Migration/Validation/Log/MigrationValidationInvalidRequiredTranslation.php new file mode 100644 index 000000000..9633c1f90 --- /dev/null +++ b/src/Migration/Validation/Log/MigrationValidationInvalidRequiredTranslation.php @@ -0,0 +1,30 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Migration\Validation\Log; + +use Shopware\Core\Framework\Log\Package; +use SwagMigrationAssistant\Migration\Logging\Log\Builder\AbstractMigrationLogEntry; + +#[Package('fundamentals@after-sales')] +readonly class MigrationValidationInvalidRequiredTranslation extends AbstractMigrationLogEntry +{ + public function isUserFixable(): bool + { + return false; + } + + public function getLevel(): string + { + return self::LOG_LEVEL_ERROR; + } + + public function getCode(): string + { + return 'SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_TRANSLATION'; + } +} diff --git a/src/Migration/Validation/Log/MigrationValidationMissingRequiredFieldLog.php b/src/Migration/Validation/Log/MigrationValidationMissingRequiredFieldLog.php index fb39b8810..ff5909673 100644 --- a/src/Migration/Validation/Log/MigrationValidationMissingRequiredFieldLog.php +++ b/src/Migration/Validation/Log/MigrationValidationMissingRequiredFieldLog.php @@ -15,7 +15,7 @@ { public function isUserFixable(): bool { - return false; + return true; } public function getLevel(): string diff --git a/src/Migration/Validation/MigrationEntityValidationService.php b/src/Migration/Validation/MigrationEntityValidationService.php new file mode 100644 index 000000000..4a514f32b --- /dev/null +++ b/src/Migration/Validation/MigrationEntityValidationService.php @@ -0,0 +1,451 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SwagMigrationAssistant\Migration\Validation; + +use Doctrine\DBAL\Connection; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\CompiledFieldCollection; +use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry; +use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition; +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\Log\Package; +use Shopware\Core\Framework\Uuid\Uuid; +use SwagMigrationAssistant\Migration\Logging\Log\Builder\MigrationLogBuilder; +use SwagMigrationAssistant\Migration\Logging\LoggingServiceInterface; +use SwagMigrationAssistant\Migration\MigrationContextInterface; +use SwagMigrationAssistant\Migration\Validation\Event\MigrationPostValidationEvent; +use SwagMigrationAssistant\Migration\Validation\Event\MigrationPreValidationEvent; +use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationExceptionLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidAssociationLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidOptionalFieldValueLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredFieldValueLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredTranslation; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationMissingRequiredFieldLog; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\Service\ResetInterface; + +/** + * @internal + */ +#[Package('fundamentals@after-sales')] +class MigrationEntityValidationService implements ResetInterface +{ + /** + * System managed fields that are managed by Shopware and should not be validated as required fields + * + * @var list> + */ + private const SYSTEM_MANAGED_FIELDS = [ + CreatedAtField::class, + UpdatedAtField::class, + VersionField::class, + ReferenceVersionField::class, + TranslationsAssociationField::class, + ]; + + /** + * Maps entity name to an associative array of required field property names. + * + * example: + * [ + * 'entity_name' => [ + * 'required_field_name' => true, + * ], + * ] + * + * @var array> + */ + private array $requiredDefinitionFieldsCache = []; + + public function __construct( + private readonly DefinitionInstanceRegistry $definitionRegistry, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly LoggingServiceInterface $loggingService, + private readonly MigrationFieldValidationService $fieldValidationService, + private readonly Connection $connection, + ) { + } + + public function reset(): void + { + $this->requiredDefinitionFieldsCache = []; + } + + /** + * @param array|null $convertedEntity + * @param array $sourceData + */ + public function validate( + MigrationContextInterface $migrationContext, + Context $shopwareContext, + ?array $convertedEntity, + string $entityName, + array $sourceData, + ): ?MigrationValidationResult { + if (empty($convertedEntity)) { + return null; + } + + if (!$this->definitionRegistry->has($entityName)) { + return null; + } + + $entityDefinition = $this->definitionRegistry->getByEntityName($entityName); + + $validationContext = new MigrationValidationContext( + $shopwareContext, + $migrationContext, + $entityDefinition, + $convertedEntity, + $sourceData, + ); + + $this->eventDispatcher->dispatch( + new MigrationPreValidationEvent($validationContext), + ); + + try { + $this->validateEntityStructure($validationContext); + $this->validateRootEntityFields($validationContext); + } catch (\Throwable $exception) { + $this->addExceptionLog($validationContext, $exception); + } + + $this->eventDispatcher->dispatch( + new MigrationPostValidationEvent($validationContext), + ); + + foreach ($validationContext->getValidationResult()->getLogs() as $log) { + $this->loggingService->addLogEntry($log); + } + + $this->loggingService->saveLogging($validationContext->getContext()); + + return $validationContext->getValidationResult(); + } + + /** + * Validates that all required fields are present in the converted data. + */ + private function validateEntityStructure(MigrationValidationContext $validationContext): void + { + $entityDefinition = $validationContext->getEntityDefinition(); + + $requiredFields = $this->getRequiredFields( + $entityDefinition->getFields(), + $entityDefinition->getEntityName() + ); + + $missingRequiredFields = array_diff( + array_keys($requiredFields), + array_keys($validationContext->getConvertedData()) + ); + + foreach ($missingRequiredFields as $missingField) { + $this->addMissingRequiredFieldLog($validationContext, $missingField); + } + } + + /** + * Validates all fields of the root entity, including nested associations. + */ + private function validateRootEntityFields(MigrationValidationContext $validationContext): void + { + $convertedData = $validationContext->getConvertedData(); + $id = $convertedData['id'] ?? null; + + $entityDefinition = $validationContext->getEntityDefinition(); + $entityName = $entityDefinition->getEntityName(); + + if (!$this->validateId($validationContext, $entityName, $id)) { + return; + } + + $fields = $entityDefinition->getFields(); + $requiredFields = $this->getRequiredFields($fields, $entityName); + + foreach ($convertedData as $fieldName => $value) { + $field = $fields->get($fieldName); + + // recursively validate nested association entities + if ($field !== null && $value !== null) { + $this->validateNestedAssociations($validationContext, $field, $fieldName, $value); + } + + try { + $this->fieldValidationService->validateField( + $entityName, + $fieldName, + $value, + $validationContext->getContext(), + isset($requiredFields[$fieldName]) + ); + } catch (MigrationValidationException $exception) { + $this->addValidationExceptionLog($validationContext, $exception, $entityName, $fieldName, $value, (string) $id); + } catch (\Throwable $exception) { + $this->addExceptionLog($validationContext, $exception); + } + } + } + + /** + * Recursively validates nested entities within association fields. + * + * @param array|mixed $value + */ + private function validateNestedAssociations( + MigrationValidationContext $validationContext, + Field $field, + string $fieldPath, + mixed $value, + ): void { + if (!\is_array($value)) { + return; + } + + // skip translations associations, they are validated by field validation + if ($field instanceof TranslationsAssociationField) { + return; + } + + if ($field instanceof OneToManyAssociationField || $field instanceof ManyToManyAssociationField) { + $referenceDefinition = $field instanceof ManyToManyAssociationField + ? $field->getToManyReferenceDefinition() + : $field->getReferenceDefinition(); + + foreach ($value as $nestedEntityData) { + $this->validateNestedEntityFields( + $validationContext, + $referenceDefinition, + $nestedEntityData, + $fieldPath + ); + } + + return; + } + + if ($field instanceof ManyToOneAssociationField || $field instanceof OneToOneAssociationField) { + $this->validateNestedEntityFields( + $validationContext, + $field->getReferenceDefinition(), + $value, + $fieldPath + ); + } + } + + /** + * Validates a single nested entity's fields and recurses into deeper associations. + * + * @param array|mixed $nestedEntityData + */ + private function validateNestedEntityFields( + MigrationValidationContext $validationContext, + EntityDefinition $referenceDefinition, + mixed $nestedEntityData, + string $fieldPath, + ): void { + if (!\is_array($nestedEntityData)) { + return; + } + + $nestedEntityName = $referenceDefinition->getEntityName(); + $fields = $referenceDefinition->getFields(); + $requiredFields = $this->getRequiredFields($fields, $nestedEntityName); + + $rootEntityName = $validationContext->getEntityDefinition()->getEntityName(); + $rootEntityId = $validationContext->getConvertedData()['id'] ?? null; + + if (\count($nestedEntityData) === 1 && isset($nestedEntityData['id'])) { + return; + } + + foreach ($nestedEntityData as $fieldName => $value) { + if ($fieldName === 'id') { + continue; + } + + $field = $fields->get($fieldName); + $nestedFieldPath = $fieldPath . '.' . $fieldName; + + if ($field !== null && $value !== null) { + $this->validateNestedAssociations($validationContext, $field, $nestedFieldPath, $value); + } + + try { + $this->fieldValidationService->validateField( + $nestedEntityName, + $fieldName, + $value, + $validationContext->getContext(), + isset($requiredFields[$fieldName]) + ); + } catch (MigrationValidationException $exception) { + $this->addValidationExceptionLog($validationContext, $exception, $rootEntityName, $nestedFieldPath, $value, $rootEntityId); + } catch (\Throwable $exception) { + $this->addExceptionLog($validationContext, $exception); + } + } + } + + private function validateId(MigrationValidationContext $validationContext, string $entityName, mixed $id): bool + { + if ($id === null) { + $this->addExceptionLog( + $validationContext, + MigrationValidationException::unexpectedNullValue('id') + ); + + return false; + } + + if (!\is_string($id) || !Uuid::isValid($id)) { + $this->addExceptionLog( + $validationContext, + MigrationValidationException::invalidId((string) $id, $entityName) + ); + + return false; + } + + return true; + } + + /** + * Loads and caches the required fields for the given entity definition. + * It considers both the definition flags and the database schema. + * + * A field is considered required if: + * - It has the Required flag + * - It is not a system managed field + * - Its corresponding database column is non-nullable without a default value + * + * @return array + */ + private function getRequiredFields(CompiledFieldCollection $fields, string $entityName): array + { + if (isset($this->requiredDefinitionFieldsCache[$entityName])) { + return $this->requiredDefinitionFieldsCache[$entityName]; + } + + $requiredDbColumns = $this->getRequiredDatabaseColumns($entityName); + $requiredFields = []; + + foreach ($fields->filterByFlag(Required::class) as $field) { + if (\in_array($field::class, self::SYSTEM_MANAGED_FIELDS, true)) { + continue; + } + + if (!($field instanceof StorageAware)) { + $requiredFields[$field->getPropertyName()] = true; + + continue; + } + + if (isset($requiredDbColumns[$field->getStorageName()])) { + $requiredFields[$field->getPropertyName()] = true; + } + } + + return $this->requiredDefinitionFieldsCache[$entityName] = $requiredFields; + } + + /** + * @return array + */ + private function getRequiredDatabaseColumns(string $entityName): array + { + $requiredColumns = []; + + $columns = $this->connection + ->createSchemaManager() + ->listTableColumns($entityName); + + foreach ($columns as $column) { + if ($column->getNotnull() && $column->getDefault() === null && !$column->getAutoincrement()) { + $requiredColumns[$column->getName()] = true; + } + } + + return $requiredColumns; + } + + private function addMissingRequiredFieldLog(MigrationValidationContext $validationContext, string $fieldName): void + { + $convertedData = $validationContext->getConvertedData(); + $entityId = isset($convertedData['id']) ? (string) $convertedData['id'] : null; + + $validationContext->getValidationResult()->addLog( + MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) + ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) + ->withFieldName($fieldName) + ->withConvertedData($convertedData) + ->withEntityId($entityId) + ->build(MigrationValidationMissingRequiredFieldLog::class) + ); + } + + private function addValidationExceptionLog( + MigrationValidationContext $validationContext, + MigrationValidationException $exception, + string $entityName, + string $fieldName, + mixed $value, + ?string $entityId, + ): void { + $logClass = match ($exception->getErrorCode()) { + MigrationValidationException::VALIDATION_INVALID_ASSOCIATION => MigrationValidationInvalidAssociationLog::class, + MigrationValidationException::VALIDATION_INVALID_REQUIRED_FIELD_VALUE => MigrationValidationInvalidRequiredFieldValueLog::class, + MigrationValidationException::VALIDATION_INVALID_OPTIONAL_FIELD_VALUE => MigrationValidationInvalidOptionalFieldValueLog::class, + MigrationValidationException::VALIDATION_INVALID_TRANSLATION => MigrationValidationInvalidRequiredTranslation::class, + default => MigrationValidationExceptionLog::class, + }; + + $validationContext->getValidationResult()->addLog( + MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) + ->withEntityName($entityName) + ->withFieldName($fieldName) + ->withConvertedData([$fieldName => $value]) + ->withSourceData($validationContext->getSourceData()) + ->withExceptionMessage($exception->getMessage()) + ->withExceptionTrace($exception->getTrace()) + ->withEntityId($entityId) + ->build($logClass) + ); + } + + private function addExceptionLog(MigrationValidationContext $validationContext, \Throwable $exception): void + { + $convertedData = $validationContext->getConvertedData(); + $entityId = isset($convertedData['id']) ? (string) $convertedData['id'] : null; + + $validationContext->getValidationResult()->addLog( + MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) + ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) + ->withSourceData($validationContext->getSourceData()) + ->withConvertedData($convertedData) + ->withExceptionMessage($exception->getMessage()) + ->withExceptionTrace($exception->getTrace()) + ->withEntityId($entityId) + ->build(MigrationValidationExceptionLog::class) + ); + } +} diff --git a/src/Migration/Validation/MigrationFieldValidationService.php b/src/Migration/Validation/MigrationFieldValidationService.php new file mode 100644 index 000000000..9043e5d5e --- /dev/null +++ b/src/Migration/Validation/MigrationFieldValidationService.php @@ -0,0 +1,308 @@ + + * 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\EntityDefinition; +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\Migration\Validation\Exception\MigrationValidationException; + +/** + * @internal + */ +#[Package('fundamentals@after-sales')] +readonly class MigrationFieldValidationService +{ + public function __construct( + private DefinitionInstanceRegistry $definitionRegistry, + ) { + } + + /** + * Validates a single field value against its entity definition. + * Supports nested field paths like "prices.shippingMethodId". + * Silently skips validation for unknown entities & fields. + * + * @throws MigrationValidationException + */ + public function validateField( + string $entityName, + string $fieldName, + mixed $value, + Context $context, + bool $isRequired = true, + ): void { + if (!$this->definitionRegistry->has($entityName)) { + return; + } + + $resolved = $this->resolveFieldPath($entityName, $fieldName); + + if ($resolved === null) { + return; + } + + [$entityDefinition, $field] = $resolved; + + // needed to avoid side effects when modifying flags later + $field = clone $field; + + if ($field instanceof AssociationField) { + $this->validateAssociationStructure($field, $value, $entityDefinition->getEntityName()); + + return; + } + + $this->validateScalarField($field, $value, $isRequired, $entityDefinition, $context); + } + + /** + * Resolves a potentially nested field path (e.g., "prices.shippingMethodId") to its target. + * Traverses through association fields to find the final entity definition and field. + * + * @return array{EntityDefinition, Field}|null + */ + public function resolveFieldPath(string $entityName, string $fieldPath): ?array + { + if (!$this->definitionRegistry->has($entityName)) { + return null; + } + + $currentDefinition = $this->definitionRegistry->getByEntityName($entityName); + $paths = \explode('.', $fieldPath); + + foreach ($paths as $index => $path) { + $fields = $currentDefinition->getFields(); + + if (!$fields->has($path)) { + return null; + } + + $field = $fields->get($path); + + if ($index === \count($paths) - 1) { + return [$currentDefinition, $field]; + } + + if (!$field instanceof AssociationField) { + return null; + } + + $referenceEntity = $field instanceof ManyToManyAssociationField + ? $field->getToManyReferenceDefinition()->getEntityName() + : $field->getReferenceDefinition()->getEntityName(); + + if (!$this->definitionRegistry->has($referenceEntity)) { + return null; + } + + $currentDefinition = $this->definitionRegistry->getByEntityName($referenceEntity); + } + + return null; + } + + /** + * Validates the structure of an association field value (not its nested content). + */ + private function validateAssociationStructure(AssociationField $field, mixed $value, string $entityName): void + { + 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); + } + } + + /** + * Validates a scalar (non-association) field using its serializer. + */ + private function validateScalarField( + Field $field, + mixed $value, + bool $isRequired, + EntityDefinition $entityDefinition, + Context $context, + ): void { + $existence = EntityExistence::createForEntity( + $entityDefinition->getEntityName(), + ['id' => Uuid::randomHex()], + ); + + $parameters = new WriteParameterBag( + $entityDefinition, + WriteContext::createFromContext($context), + '', + new WriteCommandQueue(), + ); + + $this->validateFieldByFieldSerializer($field, $value, $isRequired, $existence, $parameters); + } + + /** + * @throws MigrationValidationException + */ + 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 + ); + } + } + + /** + * @throws MigrationValidationException + */ + 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(), + \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() . '.id', + \sprintf('invalid UUID "%s" at index %s', $entry['id'], $index) + ); + } + } + } + + /** + * @throws MigrationValidationException + */ + 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']) + ); + } + } + + /** + * @throws MigrationValidationException + */ + private function validateTranslationAssociationStructure(TranslationsAssociationField $field, mixed $value, string $entityName): void + { + if (!\is_array($value)) { + throw MigrationValidationException::invalidTranslation( + $entityName, + $field->getPropertyName(), + ); + } + + foreach ($value as $key => $translation) { + if (!\is_array($translation)) { + throw MigrationValidationException::invalidTranslation( + $entityName, + $field->getPropertyName() . '.' . $key, + ); + } + } + } +} diff --git a/src/Migration/Validation/MigrationValidationResult.php b/src/Migration/Validation/MigrationValidationResult.php index 48fc13147..c316303e3 100644 --- a/src/Migration/Validation/MigrationValidationResult.php +++ b/src/Migration/Validation/MigrationValidationResult.php @@ -8,26 +8,31 @@ namespace SwagMigrationAssistant\Migration\Validation; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Util\Hasher; use SwagMigrationAssistant\Migration\Logging\Log\Builder\MigrationLogEntry; /** * @final + * + * @codeCoverageIgnore */ #[Package('fundamentals@after-sales')] class MigrationValidationResult { /** - * @param MigrationLogEntry[] $logs + * @var array */ + private array $logs = []; + public function __construct( private readonly string $entityName, - private array $logs = [], ) { } public function addLog(MigrationLogEntry $log): void { - $this->logs[] = $log; + $key = $this->createLogKey($log); + $this->logs[$key] = $log; } /** @@ -35,12 +40,17 @@ public function addLog(MigrationLogEntry $log): void */ public function getLogs(): array { - return $this->logs; + return \array_values($this->logs); } - public function hasLogs(): bool + private function createLogKey(MigrationLogEntry $log): string { - return \count($this->logs) !== 0; + return Hasher::hash(\implode('.', [ + $log->getCode(), + $log->getEntityName() ?? '', + $log->getEntityId() ?? '', + $log->getFieldName() ?? '', + ])); } public function getEntityName(): string diff --git a/src/Migration/Validation/MigrationValidationService.php b/src/Migration/Validation/MigrationValidationService.php deleted file mode 100644 index e1d939c2a..000000000 --- a/src/Migration/Validation/MigrationValidationService.php +++ /dev/null @@ -1,255 +0,0 @@ - - * 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\Field; -use Shopware\Core\Framework\DataAbstractionLayer\Field\FkField; -use Shopware\Core\Framework\DataAbstractionLayer\Field\Flag\Required; -use Shopware\Core\Framework\DataAbstractionLayer\Write\Command\WriteCommandQueue; -use Shopware\Core\Framework\DataAbstractionLayer\Write\DataStack\KeyValuePair; -use Shopware\Core\Framework\DataAbstractionLayer\Write\EntityExistence; -use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteContext; -use Shopware\Core\Framework\DataAbstractionLayer\Write\WriteParameterBag; -use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Uuid\Uuid; -use SwagMigrationAssistant\Exception\MigrationException; -use SwagMigrationAssistant\Migration\Logging\Log\Builder\MigrationLogBuilder; -use SwagMigrationAssistant\Migration\Logging\LoggingServiceInterface; -use SwagMigrationAssistant\Migration\Mapping\MappingServiceInterface; -use SwagMigrationAssistant\Migration\MigrationContextInterface; -use SwagMigrationAssistant\Migration\Validation\Event\MigrationPostValidationEvent; -use SwagMigrationAssistant\Migration\Validation\Event\MigrationPreValidationEvent; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationExceptionLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidFieldValueLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidForeignKeyLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationMissingRequiredFieldLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationUnexpectedFieldLog; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; - -/** - * @internal - */ -#[Package('fundamentals@after-sales')] -readonly class MigrationValidationService -{ - public function __construct( - private DefinitionInstanceRegistry $definitionRegistry, - private EventDispatcherInterface $eventDispatcher, - private LoggingServiceInterface $loggingService, - private MappingServiceInterface $mappingService, - ) { - } - - /** - * @param array|null $convertedEntity - * @param array $sourceData - */ - public function validate( - MigrationContextInterface $migrationContext, - Context $shopwareContext, - ?array $convertedEntity, - string $entityName, - array $sourceData, - ): ?MigrationValidationResult { - if (empty($convertedEntity)) { - return null; - } - - $entityDefinition = $this->definitionRegistry->getByEntityName($entityName); - - $validationContext = new MigrationValidationContext( - $shopwareContext, - $migrationContext, - $entityDefinition, - $convertedEntity, - $sourceData, - ); - - $this->eventDispatcher->dispatch( - new MigrationPreValidationEvent($validationContext), - ); - - try { - $this->validateEntityStructure($validationContext); - $this->validateFields($validationContext); - $this->validateAssociations($validationContext); - } catch (\Throwable $exception) { - $validationContext->getValidationResult()->addLog( - MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) - ->withSourceData($validationContext->getSourceData()) - ->withConvertedData($validationContext->getConvertedData()) - ->withExceptionMessage($exception->getMessage()) - ->withExceptionTrace($exception->getTrace()) - ->withEntityId($convertedEntity['id'] ?? null) - ->build(MigrationValidationExceptionLog::class) - ); - } - - $this->eventDispatcher->dispatch( - new MigrationPostValidationEvent($validationContext), - ); - - foreach ($validationContext->getValidationResult()->getLogs() as $log) { - $this->loggingService->addLogEntry($log); - } - - $this->loggingService->saveLogging($validationContext->getContext()); - - return $validationContext->getValidationResult(); - } - - private function validateEntityStructure(MigrationValidationContext $validationContext): void - { - $fields = $validationContext->getEntityDefinition()->getFields(); - - $requiredFields = array_values(array_map( - static fn (Field $field) => $field->getPropertyName(), - $fields->filterByFlag(Required::class)->getElements() - )); - - $convertedFieldNames = array_keys($validationContext->getConvertedData()); - $missingRequiredFields = array_diff($requiredFields, $convertedFieldNames); - - foreach ($missingRequiredFields as $missingField) { - $validationContext->getValidationResult()->addLog( - MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) - ->withFieldName($missingField) - ->withConvertedData($validationContext->getConvertedData()) - ->withEntityId($validationContext->getConvertedData()['id'] ?? null) - ->build(MigrationValidationMissingRequiredFieldLog::class) - ); - } - - $unexpectedFields = array_diff($convertedFieldNames, array_keys($fields->getElements())); - - foreach ($unexpectedFields as $unexpectedField) { - $validationContext->getValidationResult()->addLog( - MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) - ->withFieldName($unexpectedField) - ->withConvertedData($validationContext->getConvertedData()) - ->withEntityId($validationContext->getConvertedData()['id'] ?? null) - ->build(MigrationValidationUnexpectedFieldLog::class) - ); - } - } - - private function validateFields(MigrationValidationContext $validationContext): void - { - $fields = $validationContext->getEntityDefinition()->getFields(); - - if (!isset($validationContext->getConvertedData()['id'])) { - throw MigrationException::unexpectedNullValue('id'); - } - - if (!Uuid::isValid($validationContext->getConvertedData()['id'])) { - throw MigrationException::invalidId($validationContext->getConvertedData()['id'], $validationContext->getEntityDefinition()->getEntityName()); - } - - $entityExistence = EntityExistence::createForEntity( - $validationContext->getEntityDefinition()->getEntityName(), - ['id' => $validationContext->getConvertedData()['id']], - ); - - $parameters = new WriteParameterBag( - $validationContext->getEntityDefinition(), - WriteContext::createFromContext($validationContext->getContext()), - '', - new WriteCommandQueue(), - ); - - foreach ($validationContext->getConvertedData() as $fieldName => $value) { - if (!$fields->has($fieldName)) { - continue; - } - - $field = clone $fields->get($fieldName); - $field->setFlags(new Required()); - - $keyValue = new KeyValuePair( - $field->getPropertyName(), - $value, - true - ); - - try { - $serializer = $field->getSerializer(); - \iterator_to_array($serializer->encode($field, $entityExistence, $keyValue, $parameters), false); - } catch (\Throwable $e) { - $validationContext->getValidationResult()->addLog( - MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) - ->withFieldName($fieldName) - ->withConvertedData([$fieldName => $value]) - ->withSourceData($validationContext->getSourceData()) - ->withExceptionMessage($e->getMessage()) - ->withExceptionTrace($e->getTrace()) - ->withEntityId($validationContext->getConvertedData()['id'] ?? null) - ->build(MigrationValidationInvalidFieldValueLog::class) - ); - } - } - } - - private function validateAssociations(MigrationValidationContext $validationContext): void - { - $fields = $validationContext->getEntityDefinition()->getFields(); - - $fkFields = array_values(array_map( - static fn (Field $field) => $field->getPropertyName(), - $fields->filterInstance(FkField::class)->getElements() - )); - - foreach ($fkFields as $fkFieldName) { - if (!isset($validationContext->getConvertedData()[$fkFieldName])) { - continue; - } - - $fkValue = $validationContext->getConvertedData()[$fkFieldName]; - - if ($fkValue === '') { - continue; - } - - $fkField = $fields->get($fkFieldName); - - if (!$fkField instanceof FkField) { - throw MigrationException::unexpectedNullValue($fkFieldName); - } - - $referenceEntity = $fkField->getReferenceEntity(); - - if (!$referenceEntity) { - throw MigrationException::unexpectedNullValue($fkFieldName); - } - - $hasMapping = $this->mappingService->hasValidMappingByEntityId( - $validationContext->getMigrationContext()->getConnection()->getId(), - $referenceEntity, - $fkValue, - $validationContext->getContext() - ); - - if (!$hasMapping) { - $validationContext->getValidationResult()->addLog( - MigrationLogBuilder::fromMigrationContext($validationContext->getMigrationContext()) - ->withEntityName($validationContext->getEntityDefinition()->getEntityName()) - ->withFieldName($fkFieldName) - ->withConvertedData([$fkFieldName => $fkValue]) - ->withSourceData($validationContext->getSourceData()) - ->withEntityId($validationContext->getConvertedData()['id'] ?? null) - ->build(MigrationValidationInvalidForeignKeyLog::class) - ); - } - } - } -} diff --git a/src/Profile/Shopware/Converter/OrderConverter.php b/src/Profile/Shopware/Converter/OrderConverter.php index 91a78f41f..d4503fc52 100644 --- a/src/Profile/Shopware/Converter/OrderConverter.php +++ b/src/Profile/Shopware/Converter/OrderConverter.php @@ -312,7 +312,11 @@ public function convert( } if (isset($data['attributes'])) { - $converted['customFields'] = $this->getAttributes($data['attributes'], DefaultEntities::ORDER, $this->connectionName, ['id', 'orderID'], $this->context); + $customField = $this->getAttributes($data['attributes'], DefaultEntities::ORDER, $this->connectionName, ['id', 'orderID'], $this->context); + + if ($customField !== null) { + $converted['customFields'] = $customField; + } } unset($data['attributes']); 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/card/swag-migration-shop-information/swag-migration-shop-information.html.twig b/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.html.twig index afac4e53f..6f8917bce 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.html.twig +++ b/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.html.twig @@ -72,15 +72,16 @@ v-if="sslActive" class="swag-migration-shop-information__shop-domain-prefix-icon" name="regular-lock" - size="12px" + size="14px" /> + {{ shopUrlPrefix }}{{ shopUrl }} {% endblock %} @@ -228,6 +229,7 @@ diff --git a/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.scss b/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.scss index 514735cc9..9458620d0 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.scss +++ b/src/Resources/app/administration/src/module/swag-migration/component/card/swag-migration-shop-information/swag-migration-shop-information.scss @@ -11,6 +11,10 @@ align-items: center; } + &__more-information { + border-radius: var(--border-radius-card); + } + .swag-migration-shop-information__header-right { display: flex; align-items: center; @@ -38,7 +42,7 @@ align-items: center; font-size: var(--font-size-xs); white-space: nowrap; - gap: var(--scale-size-4); + gap: var(--scale-size-2); } .swag-migration-shop-information__profile-avatar { @@ -46,11 +50,14 @@ } .swag-migration-shop-information__shop-domain-prefix { + display: flex; + align-items: center; + gap: var(--scale-size-2); color: var(--color-icon-attention-default); } .swag-migration-shop-information__shop-domain-prefix--is-ssl { - color: var(--color-icon-attention-default); + color: var(--color-icon-positive-default); } .swag-migration-shop-information__connection-info { 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 8f0ef08cd..0c19274f9 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 @@ -17,8 +17,8 @@ const { Criteria } = Shopware.Data; * null will render an unresolvable message. */ export const ERROR_CODE_COMPONENT_MAPPING: Record = { - SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE: 'DEFAULT', - SWAG_MIGRATION_VALIDATION_INVALID_FOREIGN_KEY: 'DEFAULT', + SWAG_MIGRATION_VALIDATION_INVALID_OPTIONAL_FIELD_VALUE: 'DEFAULT', + SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE: 'DEFAULT', SWAG_MIGRATION_VALIDATION_MISSING_REQUIRED_FIELD: 'DEFAULT', } as const; @@ -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,49 @@ 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; + } + + 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/component/swag-migration-error-resolution/swag-migration-error-resolution-step/swag-migration-error-resolution-step.scss b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-step/swag-migration-error-resolution-step.scss index 3b4c3574c..e6e22fa24 100644 --- a/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-step/swag-migration-error-resolution-step.scss +++ b/src/Resources/app/administration/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-step/swag-migration-error-resolution-step.scss @@ -58,7 +58,7 @@ } .mt-card__content { - padding: 0; + padding: 0 !important; } .mt-tabs__item:first-of-type { diff --git a/src/Resources/app/administration/src/module/swag-migration/service/swag-migration-error-resolution.service.ts b/src/Resources/app/administration/src/module/swag-migration/service/swag-migration-error-resolution.service.ts index 2d1885069..7bc86ea7f 100644 --- a/src/Resources/app/administration/src/module/swag-migration/service/swag-migration-error-resolution.service.ts +++ b/src/Resources/app/administration/src/module/swag-migration/service/swag-migration-error-resolution.service.ts @@ -58,7 +58,6 @@ export const UNHANDLED_FIELD_TYPES = [ export const UNHANDLED_FIELD_NAMES = [ 'id', 'autoIncrement', - 'translated', ] as const; /** @@ -172,6 +171,16 @@ export const PRIORITY_FIELD_MAP: Map = new Map( */ export const CONTENT_TEXT_MAX_LENGTH = 100; +/** + * @private + * result of resolving a nested field path. + */ +export interface ResolvedFieldPath { + schema: EntityDefinition; + property: Property; + fieldName: string; +} + /** * @private */ @@ -255,65 +264,84 @@ export default class SwagMigrationErrorResolutionService { * grouped by scalar fields, associations, and required fields. */ extractEntityFields(entityName: string | null | undefined): EntityFields { - const fields = { - scalar: {}, - associations: {}, - required: {}, - } as EntityFields; - - if (!entityName) { - return fields; - } - - if (!Shopware.EntityDefinition.has(entityName)) { - return fields; + if (!entityName || !Shopware.EntityDefinition.has(entityName)) { + return { scalar: {}, associations: {}, required: {} }; } const definition = Shopware.EntityDefinition.get(entityName); - definition.forEachField((property: Property, propertyName: string) => { - if (definition.isScalarField(property)) { - fields.scalar[propertyName] = property; - } - - if (property.type === 'association' && definition.isToOneAssociation(property)) { - fields.associations[propertyName] = property; - } - - if (property.flags?.required) { - fields.required[propertyName] = property; - } - }); - - return fields; + return { + scalar: definition.filterProperties((property) => definition.isScalarField(property)), + associations: definition.getToOneAssociations(), + required: definition.getRequiredFields(), + }; } /** * gets the entity schema for a given entity name. */ - getEntitySchema(entityName: string | null | undefined): EntityDefinition { - if (entityName && Shopware.EntityDefinition.has(entityName)) { - return Shopware.EntityDefinition.get(entityName); + getEntitySchema(entityName: string | null | undefined): EntityDefinition | null { + return entityName && Shopware.EntityDefinition.has(entityName) ? Shopware.EntityDefinition.get(entityName) : null; + } + + /** + * resolves a potentially nested field path (e.g., "prices.shippingMethodId") to its target. + * traverses through association fields to find the final schema and property. + */ + resolveFieldPath(entityName: string | null | undefined, fieldPath: string | null | undefined): ResolvedFieldPath | null { + if (!fieldPath || !entityName) { + return null; } - return null; + const initialSchema = this.getEntitySchema(entityName); + + if (!initialSchema) { + return null; + } + + const paths = fieldPath.split('.'); + + return ( + paths.reduce<{ schema: EntityDefinition; result: ResolvedFieldPath | null } | null>( + (acc, path, index) => { + if (!acc || acc.result) { + return acc; + } + + const property = acc.schema.getField(path); + + if (!property) { + return null; + } + + if (index === paths.length - 1) { + return { ...acc, result: { schema: acc.schema, property, fieldName: path } }; + } + + if (property.type !== DATA_TYPES.ASSOCIATION || !property.entity) { + return null; + } + + const nextSchema = this.getEntitySchema(property.entity); + + return nextSchema ? { schema: nextSchema, result: null } : null; + }, + { schema: initialSchema, result: null }, + )?.result ?? null + ); } /** * gets the entity field definition for a specific field. */ getEntityField(entityName: string | null | undefined, fieldName: string | null | undefined): Property | null { - if (!fieldName || (UNHANDLED_FIELD_NAMES as readonly string[]).includes(fieldName)) { - return null; - } - - const schema = this.getEntitySchema(entityName); + const lastFieldName = fieldName?.split('.').pop(); - if (!schema) { + if (!lastFieldName || (UNHANDLED_FIELD_NAMES as readonly string[]).includes(lastFieldName)) { return null; } - return schema.getField(fieldName) ?? null; + return this.resolveFieldPath(entityName, fieldName)?.property ?? null; } /** @@ -324,69 +352,55 @@ export default class SwagMigrationErrorResolutionService { entityName: string | null | undefined, fieldName: string | null | undefined, ): Property | null { - const schema = this.getEntitySchema(entityName); - const entityField = this.getEntityField(entityName, fieldName); + const resolved = this.resolveFieldPath(entityName, fieldName); - if (!schema || !entityField || !fieldName) { + if (!resolved) { return null; } - // only id fields can have corresponding association fields - if (entityField.type !== DATA_TYPES.UUID) { - return null; - } + const { schema, property, fieldName: actualFieldName } = resolved; - // primary key fields do not have corresponding association fields - if (entityField.flags?.primary_key === true) { + if (property.type !== DATA_TYPES.UUID || property.flags?.primary_key) { return null; } - // try to find association field by checking all fields for matching localField - let associationField = Object.values(schema.properties).find( - (property) => - property.type === DATA_TYPES.ASSOCIATION && - (property as Property & { localField?: string }).localField === fieldName, + const byLocalField = Object.values(schema.properties).find( + (prop) => + prop.type === DATA_TYPES.ASSOCIATION && + (prop as Property & { localField?: string }).localField === actualFieldName, ); + if (byLocalField) { + return byLocalField; + } + const versionIdSuffix = 'VersionId'; - // fallback: try to infer association name from field name - // example: "productVersionId" -> "product" - if (!associationField && fieldName.endsWith(versionIdSuffix) && fieldName !== 'versionId') { - const inferredName = fieldName.slice(0, -versionIdSuffix.length); - const inferredField = schema.getField(inferredName); + if (actualFieldName.endsWith(versionIdSuffix) && actualFieldName !== 'versionId') { + const inferredField = schema.getField(actualFieldName.slice(0, -versionIdSuffix.length)); if (inferredField?.type === DATA_TYPES.ASSOCIATION) { - associationField = inferredField; + return inferredField; } } - return associationField; + return null; } /** * determines if a field is unhandled (not recognized or unsupported). */ isUnhandledField(entityName: string | null | undefined, fieldName: string | null | undefined): boolean { - if (!entityName) { - return true; - } - - if (!Shopware.EntityDefinition.has(entityName)) { + if (!entityName || !Shopware.EntityDefinition.has(entityName)) { return true; } const entityField = this.getEntityField(entityName, fieldName); - if (!entityField) { - return true; - } - - if ((UNHANDLED_FIELD_NAMES as readonly string[]).includes(entityField.type)) { + if (!entityField || (UNHANDLED_FIELD_TYPES as readonly string[]).includes(entityField.type)) { return true; } - // field type is not supported return this.getFieldType(entityName, fieldName) === null; } @@ -396,15 +410,10 @@ export default class SwagMigrationErrorResolutionService { isScalarField(entityName: string | null | undefined, fieldName: string | null | undefined): boolean { const entityField = this.getEntityField(entityName, fieldName); - if (!entityField) { + if (!entityField || entityField.type === DATA_TYPES.ASSOCIATION) { return false; } - if (entityField.type === DATA_TYPES.ASSOCIATION) { - return false; - } - - // id fields with corresponding association fields are treated as relation fields const correspondingAssociation = this.findCorrespondingAssociationField(entityName, fieldName); return !(entityField.type === DATA_TYPES.UUID && correspondingAssociation); @@ -412,16 +421,12 @@ export default class SwagMigrationErrorResolutionService { /** * checks if a field is a "to many" association (one_to_many or many_to_many). + * supports nested field paths like "prices.shippingMethodId". */ isToManyAssociationField(entityName: string | null | undefined, fieldName: string | null | undefined): boolean { - const schema = this.getEntitySchema(entityName); - const entityField = this.getEntityField(entityName, fieldName); + const resolved = this.resolveFieldPath(entityName, fieldName); - if (!schema || !entityField) { - return false; - } - - return schema.isToManyAssociation(entityField); + return resolved ? resolved.schema.isToManyAssociation(resolved.property) : false; } /** @@ -429,13 +434,7 @@ export default class SwagMigrationErrorResolutionService { * for id fields with associations, returns the association field instead. */ getEffectiveEntityField(entityName: string | null | undefined, fieldName: string | null | undefined): Property | null { - const correspondingAssociation = this.findCorrespondingAssociationField(entityName, fieldName); - - if (correspondingAssociation) { - return correspondingAssociation; - } - - return this.getEntityField(entityName, fieldName); + return this.findCorrespondingAssociationField(entityName, fieldName) ?? this.getEntityField(entityName, fieldName); } /** @@ -444,7 +443,7 @@ export default class SwagMigrationErrorResolutionService { getFieldType(entityName: string | null | undefined, fieldName: string | null | undefined): string | null { const entityField = this.getEntityField(entityName, fieldName); - if (!entityField || (UNHANDLED_FIELD_NAMES as readonly string[]).includes(entityField.type)) { + if (!entityField || (UNHANDLED_FIELD_TYPES as readonly string[]).includes(entityField.type)) { return null; } @@ -490,15 +489,7 @@ export default class SwagMigrationErrorResolutionService { } // fallback to alphabetical order - if (a < b) { - return -1; - } - - if (a > b) { - return 1; - } - - return 0; + return a.localeCompare(b); }); } @@ -609,58 +600,38 @@ export default class SwagMigrationErrorResolutionService { /** * maps entity field properties from converted data and formats association fields. * extracts only the specified properties and formats "to many" association fields to display ids. + * supports nested field paths like "prices.shippingMethodId". */ mapEntityFieldProperties( entityName: string | null | undefined, fieldProperties: string[], convertedData: Record, - fieldName?: string | null | undefined, ): Record { - const isToManyRelation = fieldName && this.isToManyAssociationField(entityName, fieldName); - const dataToMap = isToManyRelation ? this.getFirstNestedItem(convertedData[fieldName]) : convertedData; - return fieldProperties.reduce>((acc, property) => { - if (isToManyRelation && property === fieldName) { - acc[property] = this.formatAssociationFieldValue(entityName, property, convertedData[property]); + const value = Shopware.Utils.object.get(convertedData, property) as unknown; + if (value === undefined) { return acc; } - if (property in dataToMap) { - const value = dataToMap[property]; - - const shouldFormat = - this.isToManyAssociationField(entityName, property) || - Array.isArray(value) || - (typeof value === 'object' && value !== null && 'id' in value); + const shouldFormat = + this.isToManyAssociationField(entityName, property) || + Array.isArray(value) || + (typeof value === 'object' && value !== null && 'id' in value); - let finalValue = shouldFormat ? this.formatAssociationFieldValue(entityName, property, value) : value; + let finalValue = shouldFormat ? this.formatAssociationFieldValue(entityName, property, value) : value; - // truncate long text values - if (typeof finalValue === 'string' && finalValue.length > CONTENT_TEXT_MAX_LENGTH) { - finalValue = `${finalValue.substring(0, CONTENT_TEXT_MAX_LENGTH)}...`; - } - - acc[property] = finalValue; + // truncate long text values + if (typeof finalValue === 'string' && finalValue.length > CONTENT_TEXT_MAX_LENGTH) { + finalValue = `${finalValue.substring(0, CONTENT_TEXT_MAX_LENGTH)}...`; } + acc[property] = finalValue; + return acc; }, {}); } - private getFirstNestedItem(fieldValue: unknown): Record { - if (fieldValue && typeof fieldValue === 'object' && !Array.isArray(fieldValue)) { - const firstKey = Object.keys(fieldValue)[0]; - const firstItem = firstKey ? (fieldValue as Record)[firstKey] : null; - - if (firstItem && typeof firstItem === 'object') { - return firstItem as Record; - } - } - - return {}; - } - /** * validates if a field value is valid for submission based on field type. * returns error message snippet suffix if invalid, null if valid. 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 d5550478a..dc8e4d1c5 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 @@ -624,10 +624,10 @@ "SWAG_MIGRATION__SHOPWARE_UNSUPPORTED_OBJECT_TYPE": "Nicht unterstützter Objekttyp", "SWAG_MIGRATION__WRITE_EXCEPTION_OCCURRED": "Ein Schreibfehler ist aufgetreten", "SWAG_MIGRATION_VALIDATION_EXCEPTION": "Validierungsfehler aufgetreten", - "SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE": "Feld hat einen ungültigen Wert", - "SWAG_MIGRATION_VALIDATION_INVALID_FOREIGN_KEY": "Ungültige Fremdschlüssel-Referenz", + "SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE": "Pflichtfeld hat einen ungültigen Wert", + "SWAG_MIGRATION_VALIDATION_INVALID_OPTIONAL_FIELD_VALUE": "Optionales Feld hat einen ungültigen Wert", + "SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_TRANSLATION": "Erforderliche Übersetzung ist ungültig", "SWAG_MIGRATION_VALIDATION_MISSING_REQUIRED_FIELD": "Erforderliches Feld fehlt", - "SWAG_MIGRATION_VALIDATION_UNEXPECTED_FIELD": "Unerwartetes Feld in Daten gefunden", "SWAG_MIGRATION__DEACTIVATED_PACK_LANGUAGE": "Pack-Sprache ist deaktiviert", "SWAG_MIGRATION__INVALID_EMAIL_ADDRESS": "Ungültige E-Mail-Adresse", "SWAG_MIGRATION__SHOPWARE_UNSUPPORTED_MAIL_TEMPLATE_TYPE": "E-Mail-Template-Typ nicht unterstützt", @@ -649,7 +649,8 @@ "noEntityIdsFound": "Keine gültigen Entitäts-IDs in den ausgewählten Log-Einträgen gefunden.", "fetchFilterDataFailed": "Das Abrufen der Filterdaten ist fehlgeschlagen.", "fetchExistingFixesFailed": "Das Abrufen vorhandener Korrekturen ist fehlgeschlagen.", - "invalidJsonInput": "Diese Eingabe ist kein gültiges JSON.", + "fetchExampleFailed": "Beispieldaten konnten nicht abgerufen werden.", + "validationFailed": "Validierung fehlgeschlagen.", "resetResolutionFailed": "Fehler beim Zurücksetzen der Fehlerbehebung." }, "step": { 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 4af536c7b..cbbacd83a 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 @@ -475,10 +475,10 @@ "SWAG_MIGRATION__SHOPWARE_UNSUPPORTED_OBJECT_TYPE": "Unsupported object type", "SWAG_MIGRATION__WRITE_EXCEPTION_OCCURRED": "A write exception has occurred", "SWAG_MIGRATION_VALIDATION_EXCEPTION": "Validation exception occurred", - "SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE": "Field has an invalid value", - "SWAG_MIGRATION_VALIDATION_INVALID_FOREIGN_KEY": "Invalid foreign key reference", + "SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE": "Required field has an invalid value", + "SWAG_MIGRATION_VALIDATION_INVALID_OPTIONAL_FIELD_VALUE": "Optional field has an invalid value", + "SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_TRANSLATION": "Required translation is invalid", "SWAG_MIGRATION_VALIDATION_MISSING_REQUIRED_FIELD": "Required field is missing", - "SWAG_MIGRATION_VALIDATION_UNEXPECTED_FIELD": "Unexpected field found in data", "SWAG_MIGRATION__DEACTIVATED_PACK_LANGUAGE": "Pack language is deactivated", "SWAG_MIGRATION__INVALID_EMAIL_ADDRESS": "Invalid email address", "SWAG_MIGRATION__SHOPWARE_UNSUPPORTED_MAIL_TEMPLATE_TYPE": "Unsupported mail template type", @@ -500,8 +500,9 @@ "noEntityIdsFound": "No valid entity IDs found in the selected log entries.", "fetchFilterDataFailed": "Failed fetching filter data.", "fetchExistingFixesFailed": "Failed fetching existing fixes.", - "invalidJsonInput": "This input is not valid JSON.", - "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/fixture.js b/tests/Jest/src/fixture.js index cee9c18c6..95aabee19 100644 --- a/tests/Jest/src/fixture.js +++ b/tests/Jest/src/fixture.js @@ -112,7 +112,7 @@ export const fixtureLogGroups = Object.freeze([ }, { // relation - code: 'SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE', + code: 'SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE', count: 161, entityName: 'product', fieldName: 'options', 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 24fa7d714..a078e6c37 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), @@ -182,8 +183,8 @@ describe('module/swag-migration/component/swag-migration-error-resolution/swag-m describe('constants', () => { it('should provide error code to component mapping', () => { expect(ERROR_CODE_COMPONENT_MAPPING).toStrictEqual({ - SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE: 'DEFAULT', - SWAG_MIGRATION_VALIDATION_INVALID_FOREIGN_KEY: 'DEFAULT', + SWAG_MIGRATION_VALIDATION_INVALID_OPTIONAL_FIELD_VALUE: 'DEFAULT', + SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE: 'DEFAULT', SWAG_MIGRATION_VALIDATION_MISSING_REQUIRED_FIELD: 'DEFAULT', }); }); @@ -545,6 +546,93 @@ describe('module/swag-migration/component/swag-migration-error-resolution/swag-m }); describe('create resolution fix', () => { + 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/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-step.spec.js b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-step.spec.js index 3eb9ea981..252b91ed7 100644 --- a/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-step.spec.js +++ b/tests/Jest/src/module/swag-migration/component/swag-migration-error-resolution/swag-migration-error-resolution-step.spec.js @@ -161,7 +161,7 @@ describe('src/module/swag-migration/component/swag-migration-error-resolution/sw resolved: true, }), expect.objectContaining({ - name: 'swag-migration.index.error-resolution.codes.SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE', + name: 'swag-migration.index.error-resolution.codes.SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE', resolved: false, }), ]), diff --git a/tests/Jest/src/module/swag-migration/service/swag-migration-error-resolution.service.spec.js b/tests/Jest/src/module/swag-migration/service/swag-migration-error-resolution.service.spec.js index a8c048dfe..97ce2f4cf 100644 --- a/tests/Jest/src/module/swag-migration/service/swag-migration-error-resolution.service.spec.js +++ b/tests/Jest/src/module/swag-migration/service/swag-migration-error-resolution.service.spec.js @@ -4,6 +4,7 @@ import SwagMigrationErrorResolutionService, { DATA_TYPES, UNHANDLED_FIELD_TYPES, + UNHANDLED_FIELD_NAMES, HANDLED_RELATION_TYPES, FIELD_COMPONENT_TYPES, FIELD_TYPE_COMPONENT_MAPPING, @@ -110,7 +111,7 @@ const ENTITY_FIELD_TESTS = [ { name: 'forbidden field name', entityName: 'product', - fieldName: UNHANDLED_FIELD_TYPES.at(0), + fieldName: UNHANDLED_FIELD_NAMES.at(0), expected: null, }, { @@ -452,7 +453,7 @@ const CORRESPONDING_ASSOCIATION_FIELD_TESTS = [ name: 'field without corresponding association or translation match', entityName: 'category', fieldName: 'afterCategoryVersionId', - expected: undefined, + expected: null, }, { name: 'infer translation association from field name', @@ -812,7 +813,6 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ entityName: 'product', fieldProperties: [], convertedData: { id: 'prod-1', name: 'Product 1' }, - fieldName: null, expected: {}, }, { @@ -824,7 +824,6 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ 'productNumber', ], convertedData: { id: 'prod-1', name: 'Product 1', productNumber: 'P-001' }, - fieldName: null, expected: { id: 'prod-1', name: 'Product 1', productNumber: 'P-001' }, }, { @@ -836,7 +835,6 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ 'description', ], convertedData: { id: 'prod-1', name: 'Product 1' }, - fieldName: null, expected: { id: 'prod-1', name: 'Product 1' }, }, { @@ -850,7 +848,6 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ id: 'prod-1', manufacturer: { id: 'manu-1', name: 'Manufacturer 1' }, }, - fieldName: null, expected: { id: 'prod-1', manufacturer: 'manu-1', @@ -870,7 +867,6 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ { id: 'cat-2' }, ], }, - fieldName: null, expected: { id: 'prod-1', categories: 'cat-1, cat-2', @@ -890,9 +886,8 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ 'cat-2': { id: 'cat-2', name: 'Category 2' }, }, }, - fieldName: 'categories', expected: { - id: 'cat-1', + id: 'prod-1', categories: 'cat-1, cat-2', }, }, @@ -907,8 +902,8 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ id: 'prod-1', categories: null, }, - fieldName: 'categories', expected: { + id: 'prod-1', categories: '', }, }, @@ -921,7 +916,6 @@ const MAP_ENTITY_FIELD_PROPERTIES_TESTS = [ convertedData: { customerComment: 'A'.repeat(CONTENT_TEXT_MAX_LENGTH + 1), }, - fieldName: 'customerComment', expected: { customerComment: `${'A'.repeat(CONTENT_TEXT_MAX_LENGTH)}...`, }, @@ -1420,10 +1414,8 @@ describe('module/swag-migration/service/swag-migration-error-resolution.service' it.each(testCases.mapEntityFieldProperties)( 'should map entity field properties: $name', - ({ entityName, fieldProperties, convertedData, fieldName, expected }) => { - expect( - service.mapEntityFieldProperties(entityName, fieldProperties, convertedData, fieldName), - ).toStrictEqual(expected); + ({ entityName, fieldProperties, convertedData, expected }) => { + expect(service.mapEntityFieldProperties(entityName, fieldProperties, convertedData)).toStrictEqual(expected); }, ); }); 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 9135d0557..25cfc6f03 100644 --- a/tests/MigrationServicesTrait.php +++ b/tests/MigrationServicesTrait.php @@ -7,6 +7,7 @@ namespace SwagMigrationAssistant\Test; +use Doctrine\DBAL\Connection; use Psr\Log\NullLogger; use Shopware\Core\Checkout\Cart\Tax\TaxCalculator; use Shopware\Core\Checkout\Order\Aggregate\OrderDelivery\OrderDeliveryStates; @@ -53,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; @@ -207,11 +209,12 @@ protected function getMigrationDataConverter( ) ); - $validationService = new MigrationValidationService( + $validationService = new MigrationEntityValidationService( $this->getContainer()->get(DefinitionInstanceRegistry::class), $this->getContainer()->get('event_dispatcher'), $loggingService, - $mappingService, + $this->getContainer()->get(MigrationFieldValidationService::class), + $this->getContainer()->get(Connection::class), ); return new MigrationDataConverter( @@ -221,7 +224,7 @@ protected function getMigrationDataConverter( $loggingService, $dataDefinition, new DummyMappingService(), - $validationService + $validationService, ); } diff --git a/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php b/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php new file mode 100644 index 000000000..60f430681 --- /dev/null +++ b/tests/integration/Migration/Controller/ErrorResolutionControllerTest.php @@ -0,0 +1,339 @@ + + * 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' => [], + ], + ]; + + yield 'nested invalid number range field' => [ + 'entityName' => 'number_range', + 'fieldName' => 'numberRangeSalesChannels.numberRangeId', + 'fieldValue' => 'not-a-uuid', + 'expected' => [ + 'valid' => false, + 'violations' => [ + 'The string "not-a-uuid" is not a valid uuid.', + ], + ], + ]; + } + + /** + * @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/ErrorResolution/MigrationErrorResolutionServiceTest.php b/tests/integration/Migration/ErrorResolution/MigrationErrorResolutionServiceTest.php index 5c95ee207..06f088fb8 100644 --- a/tests/integration/Migration/ErrorResolution/MigrationErrorResolutionServiceTest.php +++ b/tests/integration/Migration/ErrorResolution/MigrationErrorResolutionServiceTest.php @@ -12,15 +12,18 @@ use Shopware\Core\Framework\Context; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Test\TestCaseBase\EventDispatcherBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Uuid\Uuid; use SwagMigrationAssistant\Migration\Connection\SwagMigrationConnectionEntity; use SwagMigrationAssistant\Migration\ErrorResolution\Entity\SwagMigrationFixEntity; +use SwagMigrationAssistant\Migration\ErrorResolution\Event\MigrationPreErrorResolutionEvent; use SwagMigrationAssistant\Migration\ErrorResolution\MigrationErrorResolutionService; use SwagMigrationAssistant\Migration\Logging\SwagMigrationLoggingEntity; use SwagMigrationAssistant\Migration\MigrationContext; use SwagMigrationAssistant\Migration\Run\SwagMigrationRunEntity; use SwagMigrationAssistant\Profile\Shopware\Gateway\Local\ShopwareLocalGateway; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; /** * @internal @@ -28,8 +31,106 @@ #[Package('fundamentals@after-sales')] class MigrationErrorResolutionServiceTest extends TestCase { + use EventDispatcherBehaviour; use IntegrationTestBehaviour; + private EventDispatcherInterface $eventDispatcher; + + protected function setUp(): void + { + parent::setUp(); + + $this->eventDispatcher = $this->getContainer()->get('event_dispatcher'); + } + + public function testEarlyReturnIfNoFixesExist(): void + { + $connectionId = Uuid::randomHex(); + $connection = $this->createConnection($connectionId); + $run = $this->createRun($connection); + + $data = [ + ['id' => Uuid::randomHex(), 'some' => ['data' => 'value']], + ['id' => Uuid::randomHex(), 'other' => ['data' => 'value']], + ]; + + $originalData = $data; + + $service = new MigrationErrorResolutionService( + $this->getContainer()->get(Connection::class), + $this->eventDispatcher, + ); + + $events = []; + $this->addEventListener($this->eventDispatcher, MigrationPreErrorResolutionEvent::class, function (MigrationPreErrorResolutionEvent $event) use (&$events): void { + $events[] = $event; + }); + + $service->applyFixes($data, $connection->getId(), $run->getId(), Context::createDefaultContext()); + + static::assertCount(0, $events); + static::assertSame($originalData, $data); + } + + public function testApplyToManyArray(): void + { + $connectionId = Uuid::randomHex(); + $connection = $this->createConnection($connectionId); + $run = $this->createRun($connection); + + $id = Uuid::randomHex(); + + $service = new MigrationErrorResolutionService( + $this->getContainer()->get(Connection::class), + $this->eventDispatcher, + ); + + // if path is prop in array, apply to all items in array ('items.[index].missingId' -> 'items.missingId') + $this->createFixAndLogging($connection->getId(), $id, 'someId', 'items.missingId', $run); + + $data = [ + [ + 'id' => $id, + 'items' => [ + [ + 'value' => 'oldValue1', + 'missingId' => null, + ], + [ + 'value' => 'oldValue2', + 'missingId' => null, + ], + [ + 'value' => 'oldValue3', + 'missingId' => null, + ], + ], + ], + ]; + + $service->applyFixes($data, $connection->getId(), $run->getId(), Context::createDefaultContext()); + + $expected = [[ + 'id' => $id, + 'items' => [ + [ + 'value' => 'oldValue1', + 'missingId' => 'someId', + ], + [ + 'value' => 'oldValue2', + 'missingId' => 'someId', + ], + [ + 'value' => 'oldValue3', + 'missingId' => 'someId', + ], + ], + ]]; + + static::assertSame($expected, $data); + } + public function testApply(): void { $connectionId = Uuid::randomHex(); @@ -42,7 +143,7 @@ public function testApply(): void $service = new MigrationErrorResolutionService( $this->getContainer()->get(Connection::class), - $this->getContainer()->get('event_dispatcher') + $this->eventDispatcher, ); $this->createFixAndLogging($connection->getId(), $idOne, 'val1', 'first.path', $run); @@ -64,6 +165,11 @@ public function testApply(): void ['id' => $idThree], ]; + $events = []; + $this->addEventListener($this->eventDispatcher, MigrationPreErrorResolutionEvent::class, function (MigrationPreErrorResolutionEvent $event) use (&$events): void { + $events[] = $event; + }); + $service->applyFixes($data, $connection->getId(), $run->getId(), Context::createDefaultContext()); $expected = [[ @@ -90,6 +196,7 @@ public function testApply(): void 'id' => $idThree, ]]; + static::assertCount(1, $events); static::assertSame($expected, $data); } diff --git a/tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php new file mode 100644 index 000000000..05f01dcdc --- /dev/null +++ b/tests/integration/Migration/Validation/MigrationEntityValidationServiceTest.php @@ -0,0 +1,895 @@ + + * 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\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; +use Shopware\Core\Content\Product\ProductDefinition; +use Shopware\Core\Defaults; +use Shopware\Core\Framework\Context; +use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; +use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; +use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; +use Shopware\Core\Framework\Uuid\Uuid; +use SwagMigrationAssistant\Migration\Connection\SwagMigrationConnectionEntity; +use SwagMigrationAssistant\Migration\Logging\SwagMigrationLoggingCollection; +use SwagMigrationAssistant\Migration\Logging\SwagMigrationLoggingDefinition; +use SwagMigrationAssistant\Migration\Mapping\SwagMigrationMappingCollection; +use SwagMigrationAssistant\Migration\Mapping\SwagMigrationMappingDefinition; +use SwagMigrationAssistant\Migration\MigrationContext; +use SwagMigrationAssistant\Migration\Run\MigrationStep; +use SwagMigrationAssistant\Migration\Run\SwagMigrationRunCollection; +use SwagMigrationAssistant\Migration\Run\SwagMigrationRunDefinition; +use SwagMigrationAssistant\Migration\Validation\Exception\MigrationValidationException; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationExceptionLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidAssociationLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidOptionalFieldValueLog; +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\Profile\Shopware54\Shopware54Profile; +use SwagMigrationAssistant\Test\Mock\Gateway\Dummy\Local\DummyLocalGateway; + +/** + * @internal + */ +#[Package('fundamentals@after-sales')] +#[CoversClass(MigrationEntityValidationService::class)] +class MigrationEntityValidationServiceTest extends TestCase +{ + use IntegrationTestBehaviour; + + private const CONNECTION_ID = '01991554142d73348ea58793d98f1989'; + + private MigrationContext $migrationContext; + + private MigrationEntityValidationService $validationService; + + /** + * @var EntityRepository + */ + private EntityRepository $loggingRepo; + + /** + * @var EntityRepository + */ + private EntityRepository $runRepo; + + /** + * @var EntityRepository + */ + private EntityRepository $mappingRepo; + + private Context $context; + + private string $runId; + + protected function setUp(): void + { + $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'); + $this->context = Context::createDefaultContext(); + + $connection = new SwagMigrationConnectionEntity(); + $connection->setId(self::CONNECTION_ID); + $connection->setProfileName(Shopware54Profile::PROFILE_NAME); + $connection->setGatewayName(DummyLocalGateway::GATEWAY_NAME); + + $this->runId = Uuid::randomHex(); + + $this->migrationContext = new MigrationContext( + $connection, + new Shopware54Profile(), + new DummyLocalGateway(), + null, + $this->runId, + ); + + static::getContainer()->get('swag_migration_connection.repository')->create( + [ + [ + 'id' => self::CONNECTION_ID, + 'name' => 'test connection', + 'profileName' => Shopware54Profile::PROFILE_NAME, + 'gatewayName' => DummyLocalGateway::GATEWAY_NAME, + ], + ], + $this->context + ); + + $this->runRepo->create( + [ + [ + 'id' => $this->runId, + 'step' => MigrationStep::FETCHING->value, + 'connectionId' => self::CONNECTION_ID, + ], + ], + $this->context + ); + } + + public function testShouldEarlyReturnNullWhenConvertedDataIsEmpty(): void + { + $migrationContext = new MigrationContext( + new SwagMigrationConnectionEntity(), + new Shopware54Profile(), + new DummyLocalGateway(), + null, + $this->runId, + ); + + static::assertNull($this->validationService->validate( + $migrationContext, + $this->context, + [], + ProductDefinition::ENTITY_NAME, + [] + )); + static::assertNull($this->validationService->validate( + $migrationContext, + $this->context, + null, + ProductDefinition::ENTITY_NAME, + [] + )); + } + + /** + * @param array $convertedData + * @param array $expectedLogs + */ + #[DataProvider('entityStructureAndFieldProvider')] + public function testShouldValidateStructureAndFieldsValues(array $convertedData, array $expectedLogs): void + { + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + SwagMigrationLoggingDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + static::assertSame(SwagMigrationLoggingDefinition::ENTITY_NAME, $result->getEntityName()); + + $this->clearCacheData(); + + $logs = $this->loggingRepo->search(new Criteria(), $this->context)->getEntities(); + static::assertInstanceOf(SwagMigrationLoggingCollection::class, $logs); + + static::assertCount(\count($expectedLogs), $logs); + static::assertCount(\count($expectedLogs), $result->getLogs()); + + $logCodes = \array_map(fn ($log) => $log::class, $result->getLogs()); + static::assertSame($expectedLogs, $logCodes); + } + + public static function entityStructureAndFieldProvider(): \Generator + { + $log = [ + 'id' => Uuid::randomHex(), + 'profileName' => 'profile', + 'gatewayName' => 'gateway', + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + 'sourceData' => [ + 'some' => 'data', + ], + 'createdAt' => (new \DateTime())->format(\DATE_ATOM), + ]; + + yield 'valid' => [ + 'convertedData' => $log, + 'expectedLogs' => [], + ]; + + yield 'structure - missing required fields' => [ + 'convertedData' => [ + 'id' => Uuid::randomHex(), + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + 'createdAt' => (new \DateTime())->format(\DATE_ATOM), + ], + 'expectedLogs' => [ + MigrationValidationMissingRequiredFieldLog::class, + MigrationValidationMissingRequiredFieldLog::class, + ], + ]; + + yield 'fields - invalid type' => [ + 'convertedData' => [ + ...$log, + 'userFixable' => 'not_a_boolean', + ], + 'expectedLogs' => [ + MigrationValidationInvalidOptionalFieldValueLog::class, + ], + ]; + + yield 'fields - too long' => [ + 'convertedData' => [ + ...$log, + 'code' => str_repeat('sw', 128), + ], + 'expectedLogs' => [ + MigrationValidationInvalidRequiredFieldValueLog::class, + ], + ]; + + yield 'fields - invalid uuid' => [ + 'convertedData' => [ + ...$log, + 'id' => 'not-a-uuid', + ], + 'expectedLogs' => [ + MigrationValidationExceptionLog::class, + ], + ]; + + yield 'fields - invalid json' => [ + 'convertedData' => [ + ...$log, + 'sourceData' => "\xB1\x31", + ], + 'expectedLogs' => [ + MigrationValidationInvalidOptionalFieldValueLog::class, + ], + ]; + + yield 'structure/field - multiple errors' => [ + 'convertedData' => [ + 'id' => Uuid::randomHex(), + 'gatewayName' => true, + 'level' => 1, + 'code' => ['sw'], + 'userFixable' => true, + 'createdAt' => (new \DateTime())->format(\DATE_ATOM), + ], + 'expectedLogs' => [ + MigrationValidationMissingRequiredFieldLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, + MigrationValidationInvalidRequiredFieldValueLog::class, + ], + ]; + } + + public function testShouldFilterNullableFields(): void + { + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + [ + 'id' => Uuid::randomHex(), + ], + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $missingFields = \array_map(fn ($log) => $log->getFieldName(), $result->getLogs()); + + // Only 'stock' should be required as its not nullable in db and has no default + static::assertCount(1, $missingFields); + static::assertContains('stock', $missingFields); + } + + /** + * @param array $convertedData + */ + #[DataProvider('invalidIdProvider')] + public function testShouldLogWhenEntityHasInvalidOrMissingId(array $convertedData, string $expectedExceptionMessage): void + { + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + SwagMigrationLoggingDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logs = \array_filter($result->getLogs(), fn ($log) => $log instanceof MigrationValidationExceptionLog); + static::assertCount(1, $logs); + + $exceptionLog = array_values($logs)[0]; + static::assertInstanceOf(MigrationValidationExceptionLog::class, $exceptionLog); + + static::assertSame($expectedExceptionMessage, $exceptionLog->getExceptionMessage()); + } + + /** + * @return \Generator, expectedExceptionMessage: string}> + */ + public static function invalidIdProvider(): \Generator + { + $baseData = [ + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + 'createdAt' => (new \DateTime())->format(\DATE_ATOM), + ]; + + yield 'missing id (null)' => [ + 'convertedData' => $baseData, + 'expectedExceptionMessage' => MigrationValidationException::unexpectedNullValue('id')->getMessage(), + ]; + + yield 'invalid uuid string' => [ + 'convertedData' => [...$baseData, 'id' => 'invalid-uuid'], + 'expectedExceptionMessage' => MigrationValidationException::invalidId('invalid-uuid', SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), + ]; + + yield 'integer id instead of uuid string' => [ + 'convertedData' => [...$baseData, 'id' => 12345], + 'expectedExceptionMessage' => MigrationValidationException::invalidId('12345', SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), + ]; + + yield 'empty string id' => [ + 'convertedData' => [...$baseData, 'id' => ''], + 'expectedExceptionMessage' => MigrationValidationException::invalidId('', SwagMigrationLoggingDefinition::ENTITY_NAME)->getMessage(), + ]; + } + + /** + * @param array $convertedData + * @param array> $mappings + * @param array $expectedLogs + */ + #[DataProvider('associationProvider')] + public function testValidateAssociations(array $convertedData, array $mappings, array $expectedLogs): void + { + if (!empty($mappings)) { + $this->mappingRepo->create($mappings, $this->context); + } + + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + SwagMigrationLoggingDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); + static::assertEquals($expectedLogs, $logClasses); + } + + /** + * @return \Generator, mappings: array>, expectedLogs: array}> + */ + public static function associationProvider(): \Generator + { + $log = [ + 'id' => Uuid::randomHex(), + 'profileName' => 'profile', + 'gatewayName' => 'gateway', + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + 'sourceData' => [ + 'some' => 'data', + ], + 'createdAt' => (new \DateTime())->format(\DATE_ATOM), + ]; + + $runId = Uuid::randomHex(); + $mapping = [ + 'id' => Uuid::randomHex(), + 'connectionId' => self::CONNECTION_ID, + 'entity' => SwagMigrationRunDefinition::ENTITY_NAME, + 'oldIdentifier' => $runId, + 'entityId' => $runId, + ]; + + yield 'valid fk' => [ + 'convertedData' => [ + ...$log, + 'runId' => $runId, + ], + 'mappings' => [$mapping], + 'expectedLogs' => [], + ]; + + yield 'fk field not in converted data' => [ + 'convertedData' => $log, + 'mappings' => [], + 'expectedLogs' => [], + ]; + + yield 'fk value is null' => [ + 'convertedData' => [ + ...$log, + 'runId' => null, + ], + 'mappings' => [], + 'expectedLogs' => [ + MigrationValidationInvalidOptionalFieldValueLog::class, + ], + ]; + + yield 'fk value is empty string' => [ + 'convertedData' => [ + ...$log, + 'runId' => '', + ], + 'mappings' => [], + 'expectedLogs' => [ + MigrationValidationInvalidOptionalFieldValueLog::class, + ], + ]; + } + + public function testMissingTranslationAssociation(): void + { + $convertedData = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => ['lel'], + ]; + + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); + static::assertCount(1, $logClasses); + static::assertEquals([MigrationValidationInvalidRequiredTranslation::class], $logClasses); + } + + public function testValidTranslationAssociation(): void + { + $convertedData = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Valid name', + ], + ], + ]; + + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); + static::assertCount(0, $logClasses); + } + + /** + * @param array $convertedData + * @param array $expectedLogs + */ + #[DataProvider('toManyAssociationProvider')] + public function testValidateToManyAssociations(array $convertedData, array $expectedLogs): void + { + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); + \var_dump($logClasses); + + static::assertEquals($expectedLogs, $logClasses); + } + + /*** + * @return \Generator, array}> + */ + public static function toManyAssociationProvider(): \Generator + { + $baseProduct = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Product', + ], + ], + ]; + + yield 'valid categories association (empty array)' => [ + 'convertedData' => [ + ...$baseProduct, + 'categories' => [], + ], + 'expectedLogs' => [], + ]; + + yield 'valid categories association (with valid entries)' => [ + 'convertedData' => [ + ...$baseProduct, + 'categories' => [ + ['id' => Uuid::randomHex()], + ['id' => Uuid::randomHex()], + ], + ], + 'expectedLogs' => [], + ]; + + yield 'invalid categories association (non-array value)' => [ + 'convertedData' => [ + ...$baseProduct, + 'categories' => 'not-an-array', + ], + 'expectedLogs' => [ + MigrationValidationInvalidAssociationLog::class, + ], + ]; + + yield 'invalid categories association (entry is not array)' => [ + 'convertedData' => [ + ...$baseProduct, + 'categories' => [ + 'not-an-array-entry', + ], + ], + 'expectedLogs' => [ + MigrationValidationInvalidAssociationLog::class, + ], + ]; + + yield 'invalid categories association (invalid UUID in entry)' => [ + 'convertedData' => [ + ...$baseProduct, + 'categories' => [ + ['id' => 'invalid-uuid'], + ], + ], + 'expectedLogs' => [ + MigrationValidationInvalidAssociationLog::class, + ], + ]; + + yield 'invalid categories association (multiple errors)' => [ + 'convertedData' => [ + ...$baseProduct, + 'categories' => [ + ['id' => Uuid::randomHex()], + 'invalid-entry', + ['id' => 'invalid-uuid'], + ], + ], + 'expectedLogs' => [ + // Only first error is logged since validation throws on first failure + MigrationValidationInvalidAssociationLog::class, + ], + ]; + } + + /** + * @param array $convertedData + * @param array $expectedLogs + */ + #[DataProvider('toOneAssociationProvider')] + public function testValidateToOneAssociations(array $convertedData, array $expectedLogs): void + { + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); + static::assertEquals($expectedLogs, $logClasses); + } + + /** + * @return \Generator, expectedLogs: array}> + */ + public static function toOneAssociationProvider(): \Generator + { + $baseProduct = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Product', + ], + ], + ]; + + yield 'valid manufacturer association (null value)' => [ + 'convertedData' => $baseProduct, + 'expectedLogs' => [], + ]; + + yield 'valid manufacturer association (with valid id)' => [ + 'convertedData' => [ + ...$baseProduct, + 'manufacturer' => ['id' => Uuid::randomHex(), 'name' => 'Test Manufacturer'], + ], + 'expectedLogs' => [], + ]; + + yield 'invalid manufacturer association (non-array value)' => [ + 'convertedData' => [ + ...$baseProduct, + 'manufacturer' => 'not-an-array', + ], + 'expectedLogs' => [ + MigrationValidationInvalidAssociationLog::class, + ], + ]; + + yield 'invalid manufacturer association (invalid UUID)' => [ + 'convertedData' => [ + ...$baseProduct, + 'manufacturer' => ['id' => 'invalid-uuid'], + ], + 'expectedLogs' => [ + MigrationValidationInvalidAssociationLog::class, + ], + ]; + } + + public function testShouldReturnNullWhenEntityDefinitionDoesNotExist(): void + { + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + ['id' => Uuid::randomHex()], + 'non_existent_entity_definition', + [] + ); + + static::assertNull($result); + } + + public function testResetShouldClearRequiredFieldsCache(): void + { + $result1 = $this->validationService->validate( + $this->migrationContext, + $this->context, + [ + 'id' => Uuid::randomHex(), + 'profileName' => 'profile', + 'gatewayName' => 'gateway', + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + ], + SwagMigrationLoggingDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result1); + + $this->validationService->reset(); + + $result2 = $this->validationService->validate( + $this->migrationContext, + $this->context, + [ + 'id' => Uuid::randomHex(), + 'profileName' => 'profile', + 'gatewayName' => 'gateway', + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + ], + SwagMigrationLoggingDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result2); + + $this->clearCacheData(); + + static::assertCount(\count($result1->getLogs()), $result2->getLogs()); + } + + public function testValidNestedAssociationWithValidUuids(): void + { + $categoryId1 = Uuid::randomHex(); + $categoryId2 = Uuid::randomHex(); + + $convertedData = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Product', + ], + ], + 'categories' => [ + ['id' => $categoryId1], + ['id' => $categoryId2], + ], + ]; + + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + static::assertCount(0, $result->getLogs()); + } + + public function testValidationLogsAreSavedToDatabase(): void + { + $this->validationService->validate( + $this->migrationContext, + $this->context, + [ + 'id' => Uuid::randomHex(), + 'level' => 'error', + 'code' => 'some_code', + 'userFixable' => true, + ], + SwagMigrationLoggingDefinition::ENTITY_NAME, + [] + ); + + $this->clearCacheData(); + + $logs = $this->loggingRepo->search(new Criteria(), $this->context)->getEntities(); + static::assertInstanceOf(SwagMigrationLoggingCollection::class, $logs); + static::assertGreaterThan(0, $logs->count()); + } + + public function testDeepNestedValidationTwoLevels(): void + { + $convertedData = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Product', + ], + ], + 'manufacturer' => [ + 'id' => Uuid::randomHex(), + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Manufacturer', + ], + ], + 'media' => [ + 'id' => Uuid::randomHex(), + 'mimeType' => 12345, // invalid: should be string + ], + ], + ]; + + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); + static::assertContains(MigrationValidationInvalidOptionalFieldValueLog::class, $logClasses); + } + + public function testInvalidFieldValueInNestedEntity(): void + { + $convertedData = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Product', + ], + ], + 'manufacturer' => [ + 'id' => Uuid::randomHex(), + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Manufacturer', + ], + ], + 'mediaId' => 'not-a-valid-uuid', // invalid FK value + ], + ]; + + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + + $logClasses = \array_map(static fn ($log) => $log::class, $result->getLogs()); + static::assertContains(MigrationValidationInvalidOptionalFieldValueLog::class, $logClasses); + } + + public function testNestedFieldPathCorrectness(): void + { + $convertedData = [ + 'id' => Uuid::randomHex(), + 'versionId' => Uuid::randomHex(), + 'stock' => 10, + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Product', + ], + ], + 'manufacturer' => [ + 'id' => Uuid::randomHex(), + 'translations' => [ + Defaults::LANGUAGE_SYSTEM => [ + 'name' => 'Test Manufacturer', + ], + ], + 'media' => [ + 'id' => Uuid::randomHex(), + 'mimeType' => ['invalid' => 'array'], // invalid: should be string + ], + ], + ]; + + $result = $this->validationService->validate( + $this->migrationContext, + $this->context, + $convertedData, + ProductDefinition::ENTITY_NAME, + [] + ); + + static::assertInstanceOf(MigrationValidationResult::class, $result); + static::assertNotEmpty($result->getLogs()); + + $fieldPaths = \array_map(static fn ($log) => $log->getFieldName(), $result->getLogs()); + static::assertContains('manufacturer.media.mimeType', $fieldPaths); + } +} diff --git a/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php new file mode 100644 index 000000000..c23d33d5a --- /dev/null +++ b/tests/integration/Migration/Validation/MigrationFieldValidationServiceTest.php @@ -0,0 +1,320 @@ + + * 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\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 Shopware\Core\Framework\Uuid\Uuid; +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); + } + + /** + * @param class-string<\Throwable>|null $exception + */ + #[DataProvider('validateFieldProvider')] + public function testValidateField( + string $entityName, + string $fieldName, + mixed $value, + ?string $exception, + ): void { + if ($exception) { + static::expectException($exception); + } else { + static::expectNotToPerformAssertions(); + } + + $this->migrationFieldValidationService->validateField( + $entityName, + $fieldName, + $value, + Context::createDefaultContext(), + ); + } + + public static function validateFieldProvider(): \Generator + { + yield 'not existing entity' => [ + 'entityName' => 'unknown_entity', + 'fieldName' => 'name', + 'value' => 'value', + 'exception' => null, + ]; + + yield 'not existing field' => [ + 'entityName' => 'product', + 'fieldName' => 'nonExistingField', + 'value' => 'value', + 'exception' => null, + ]; + + yield 'valid string field' => [ + 'entityName' => 'product', + 'fieldName' => 'name', + 'value' => 'Valid Product Name', + 'exception' => null, + ]; + + yield 'valid price field' => [ + 'entityName' => 'product', + 'fieldName' => 'price', + 'value' => [ + [ + 'currencyId' => Defaults::CURRENCY, + 'gross' => 100.0, + 'net' => 84.03, + 'linked' => true, + ], + ], + 'exception' => null, + ]; + + yield 'invalid price field (gross type)' => [ + 'entityName' => 'product', + 'fieldName' => 'price', + 'value' => [ + [ + 'currencyId' => Defaults::CURRENCY, + 'gross' => 'invalid', // should be numeric + 'net' => 84.03, + 'linked' => true, + ], + ], + 'exception' => MigrationValidationException::class, + ]; + + yield 'invalid price field (missing net)' => [ + 'entityName' => 'product', + 'fieldName' => 'price', + 'value' => [ + [ + 'currencyId' => Defaults::CURRENCY, + 'gross' => 100.0, + // 'net' is missing + 'linked' => true, + ], + ], + 'exception' => MigrationValidationException::class, + ]; + + yield 'invalid price field (invalid currencyId)' => [ + 'entityName' => 'product', + 'fieldName' => 'price', + 'value' => [ + [ + 'currencyId' => 'not-a-valid-uuid', + 'gross' => 100.0, + 'net' => 84.03, + 'linked' => true, + ], + ], + 'exception' => MigrationValidationException::class, + ]; + + yield 'valid UUID field' => [ + 'entityName' => 'product', + 'fieldName' => 'id', + 'value' => Uuid::randomHex(), + 'exception' => null, + ]; + + yield 'invalid UUID field' => [ + 'entityName' => 'product', + 'fieldName' => 'id', + 'value' => 'invalid-uuid', + 'exception' => MigrationValidationException::class, + ]; + + yield 'valid nested field' => [ + 'entityName' => 'shipping_method', + 'fieldName' => 'prices.shippingMethodId', + 'value' => Uuid::randomHex(), + 'exception' => null, + ]; + + yield 'invalid nested field' => [ + 'entityName' => 'shipping_method', + 'fieldName' => 'prices.shippingMethodId', + 'value' => 'invalid-uuid', + 'exception' => MigrationValidationException::class, + ]; + + yield 'valid translation field' => [ + 'entityName' => 'product', + 'fieldName' => 'translations', + 'value' => ['en-GB' => [], 'de-DE' => []], + 'exception' => null, + ]; + + yield 'invalid translation field (non array)' => [ + 'entityName' => 'product', + 'fieldName' => 'translations', + 'value' => 'not-an-array', + 'exception' => MigrationValidationException::class, + ]; + + yield 'invalid translation field (non translations)' => [ + 'entityName' => 'product', + 'fieldName' => 'translations', + 'value' => ['en-GB' => null, 'de-DE' => []], + 'exception' => MigrationValidationException::class, + ]; + + yield 'valid to many field' => [ + 'entityName' => 'product', + 'fieldName' => 'tags', + 'value' => [ + ['id' => Uuid::randomHex()], + ['id' => Uuid::randomHex()], + ], + 'exception' => null, + ]; + + yield 'invalid to many field (non array)' => [ + 'entityName' => 'product', + 'fieldName' => 'tags', + 'value' => 'not-an-array', + 'exception' => MigrationValidationException::class, + ]; + + yield 'invalid to many field (non array child)' => [ + 'entityName' => 'product', + 'fieldName' => 'tags', + 'value' => [ + ['id' => Uuid::randomHex()], + 'not-an-array', + ], + 'exception' => MigrationValidationException::class, + ]; + + yield 'invalid to many field (invalid uuid)' => [ + 'entityName' => 'product', + 'fieldName' => 'tags', + 'value' => [ + ['id' => Uuid::randomHex()], + ['id' => 'invalid-uuid'], + ], + 'exception' => MigrationValidationException::class, + ]; + + yield 'valid to one field' => [ + 'entityName' => 'product', + 'fieldName' => 'tax', + 'value' => [ + 'id' => Uuid::randomHex(), + ], + 'exception' => null, + ]; + + yield 'invalid to one field (non array)' => [ + 'entityName' => 'product', + 'fieldName' => 'tax', + 'value' => 'not-an-array', + 'exception' => MigrationValidationException::class, + ]; + + yield 'invalid to one field (invalid id)' => [ + 'entityName' => 'product', + 'fieldName' => 'tax', + 'value' => [ + 'id' => 'invalid-uuid', + ], + 'exception' => MigrationValidationException::class, + ]; + } + + /** + * @param array{entityName: string, propertyName: string}|null $expectedResult + */ + #[DataProvider('resolveFieldPathProvider')] + public function testResolveFieldPath( + string $entityName, + string $fieldName, + ?array $expectedResult, + ): void { + $result = $this->migrationFieldValidationService->resolveFieldPath($entityName, $fieldName); + + if ($expectedResult !== null) { + static::assertNotNull($result); + static::assertCount(2, $result); + static::assertSame($expectedResult['entityName'], $result[0]->getEntityName()); + static::assertSame($expectedResult['propertyName'], $result[1]->getPropertyName()); + } else { + static::assertNull($result); + } + } + + public static function resolveFieldPathProvider(): \Generator + { + yield 'simple field' => [ + 'entityName' => 'product', + 'fieldName' => 'name', + 'expectedResult' => [ + 'entityName' => 'product', + 'propertyName' => 'name', + ], + ]; + + yield 'nested field' => [ + 'entityName' => 'shipping_method', + 'fieldName' => 'prices.shippingMethodId', + 'expectedResult' => [ + 'entityName' => 'shipping_method_price', + 'propertyName' => 'shippingMethodId', + ], + ]; + + yield 'deeply nested field' => [ + 'entityName' => 'product', + 'fieldName' => 'categories.media.alt', + 'expectedResult' => [ + 'entityName' => 'media', + 'propertyName' => 'alt', + ], + ]; + + yield 'unknown entity' => [ + 'entityName' => 'unknown_entity', + 'fieldName' => 'field', + 'expectedResult' => null, + ]; + + yield 'unknown field' => [ + 'entityName' => 'product', + 'fieldName' => 'unknownField', + 'expectedResult' => null, + ]; + + yield 'unknown nested field' => [ + 'entityName' => 'shipping_method', + 'fieldName' => 'prices.unknownField', + 'expectedResult' => null, + ]; + } +} diff --git a/tests/integration/Migration/Validation/MigrationValidationServiceTest.php b/tests/integration/Migration/Validation/MigrationValidationServiceTest.php deleted file mode 100644 index fa4748df4..000000000 --- a/tests/integration/Migration/Validation/MigrationValidationServiceTest.php +++ /dev/null @@ -1,388 +0,0 @@ - - * 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\Attributes\DataProvider; -use PHPUnit\Framework\TestCase; -use Shopware\Core\Content\Product\ProductDefinition; -use Shopware\Core\Framework\Context; -use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository; -use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; -use Shopware\Core\Framework\Log\Package; -use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; -use Shopware\Core\Framework\Uuid\Uuid; -use SwagMigrationAssistant\Migration\Connection\SwagMigrationConnectionEntity; -use SwagMigrationAssistant\Migration\Logging\SwagMigrationLoggingCollection; -use SwagMigrationAssistant\Migration\Logging\SwagMigrationLoggingDefinition; -use SwagMigrationAssistant\Migration\Mapping\SwagMigrationMappingCollection; -use SwagMigrationAssistant\Migration\Mapping\SwagMigrationMappingDefinition; -use SwagMigrationAssistant\Migration\MigrationContext; -use SwagMigrationAssistant\Migration\Run\MigrationStep; -use SwagMigrationAssistant\Migration\Run\SwagMigrationRunCollection; -use SwagMigrationAssistant\Migration\Run\SwagMigrationRunDefinition; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationExceptionLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidFieldValueLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidForeignKeyLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationMissingRequiredFieldLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationUnexpectedFieldLog; -use SwagMigrationAssistant\Migration\Validation\MigrationValidationResult; -use SwagMigrationAssistant\Migration\Validation\MigrationValidationService; -use SwagMigrationAssistant\Profile\Shopware54\Shopware54Profile; -use SwagMigrationAssistant\Test\Mock\Gateway\Dummy\Local\DummyLocalGateway; - -/** - * @internal - */ -#[Package('fundamentals@after-sales')] -#[CoversClass(MigrationValidationService::class)] -class MigrationValidationServiceTest extends TestCase -{ - use IntegrationTestBehaviour; - - private const CONNECTION_ID = '01991554142d73348ea58793d98f1989'; - - private MigrationValidationService $validationService; - - /** - * @var EntityRepository - */ - private EntityRepository $loggingRepo; - - /** - * @var EntityRepository - */ - private EntityRepository $runRepo; - - /** - * @var EntityRepository - */ - private EntityRepository $mappingRepo; - - private Context $context; - - private string $runId; - - protected function setUp(): void - { - $this->validationService = static::getContainer()->get(MigrationValidationService::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'); - $this->context = Context::createDefaultContext(); - - $this->runId = Uuid::randomHex(); - static::getContainer()->get('swag_migration_connection.repository')->create( - [ - [ - 'id' => self::CONNECTION_ID, - 'name' => 'test connection', - 'profileName' => Shopware54Profile::PROFILE_NAME, - 'gatewayName' => DummyLocalGateway::GATEWAY_NAME, - ], - ], - $this->context - ); - - $this->runRepo->create( - [ - [ - 'id' => $this->runId, - 'step' => MigrationStep::FETCHING->value, - 'connectionId' => self::CONNECTION_ID, - ], - ], - $this->context - ); - } - - public function testShouldEarlyReturnNullWhenConvertedDataIsEmpty(): void - { - $migrationContext = new MigrationContext( - new SwagMigrationConnectionEntity(), - new Shopware54Profile(), - new DummyLocalGateway(), - null, - $this->runId, - ); - - static::assertNull($this->validationService->validate( - $migrationContext, - $this->context, - [], - ProductDefinition::ENTITY_NAME, - [] - )); - static::assertNull($this->validationService->validate( - $migrationContext, - $this->context, - null, - ProductDefinition::ENTITY_NAME, - [] - )); - } - - /** - * @param array $convertedData - * @param array $expectedLogs - */ - #[DataProvider('entityStructureAndFieldProvider')] - public function testShouldValidateStructureAndFieldsValues(array $convertedData, array $expectedLogs): void - { - $connection = new SwagMigrationConnectionEntity(); - $connection->setId(self::CONNECTION_ID); - $connection->setProfileName(Shopware54Profile::PROFILE_NAME); - $connection->setGatewayName(DummyLocalGateway::GATEWAY_NAME); - - $migrationContext = new MigrationContext( - $connection, - new Shopware54Profile(), - new DummyLocalGateway(), - null, - $this->runId, - ); - - $result = $this->validationService->validate( - $migrationContext, - $this->context, - $convertedData, - SwagMigrationLoggingDefinition::ENTITY_NAME, - [] - ); - - static::assertInstanceOf(MigrationValidationResult::class, $result); - static::assertSame(SwagMigrationLoggingDefinition::ENTITY_NAME, $result->getEntityName()); - - $this->clearCacheData(); - - $logs = $this->loggingRepo->search(new Criteria(), $this->context)->getEntities(); - static::assertInstanceOf(SwagMigrationLoggingCollection::class, $logs); - - static::assertCount(\count($expectedLogs), $logs); - static::assertCount(\count($expectedLogs), $result->getLogs()); - - $logCodes = array_map(fn ($log) => $log::class, $result->getLogs()); - static::assertSame($expectedLogs, $logCodes); - } - - /** - * @param array $convertedData - * @param array> $mappings - * @param array $expectedLogs - */ - #[DataProvider('associationProvider')] - public function testValidateAssociations(array $convertedData, array $mappings, array $expectedLogs): void - { - $connection = new SwagMigrationConnectionEntity(); - $connection->setId(self::CONNECTION_ID); - $connection->setProfileName(Shopware54Profile::PROFILE_NAME); - $connection->setGatewayName(DummyLocalGateway::GATEWAY_NAME); - - $migrationContext = new MigrationContext( - $connection, - new Shopware54Profile(), - new DummyLocalGateway(), - null, - $this->runId, - ); - - if (!empty($mappings)) { - $this->mappingRepo->create($mappings, $this->context); - } - - $result = $this->validationService->validate( - $migrationContext, - $this->context, - $convertedData, - SwagMigrationLoggingDefinition::ENTITY_NAME, - [] - ); - - static::assertInstanceOf(MigrationValidationResult::class, $result); - - $logClasses = array_map(static fn ($log) => $log::class, $result->getLogs()); - static::assertEquals($expectedLogs, $logClasses); - } - - public static function entityStructureAndFieldProvider(): \Generator - { - $log = [ - 'id' => Uuid::randomHex(), - 'profileName' => 'profile', - 'gatewayName' => 'gateway', - 'level' => 'error', - 'code' => 'some_code', - 'userFixable' => true, - 'sourceData' => [ - 'some' => 'data', - ], - 'createdAt' => (new \DateTime())->format(\DATE_ATOM), - ]; - - yield 'valid' => [ - $log, - [], - ]; - - yield 'structure - missing required fields' => [ - [ - 'id' => Uuid::randomHex(), - 'level' => 'error', - 'code' => 'some_code', - 'userFixable' => true, - 'createdAt' => (new \DateTime())->format(\DATE_ATOM), - ], - [ - MigrationValidationMissingRequiredFieldLog::class, - MigrationValidationMissingRequiredFieldLog::class, - ], - ]; - - yield 'structure - unexpected fields' => [ - [ - ...$log, - 'unexpectedField1' => 'value', - 'unexpectedField2' => 'value', - ], - [ - MigrationValidationUnexpectedFieldLog::class, - MigrationValidationUnexpectedFieldLog::class, - ], - ]; - - yield 'fields - invalid type' => [ - [ - ...$log, - 'userFixable' => 'not_a_boolean', - ], - [ - MigrationValidationInvalidFieldValueLog::class, - ], - ]; - - yield 'fields - too long' => [ - [ - ...$log, - 'code' => str_repeat('sw', 128), - ], - [ - MigrationValidationInvalidFieldValueLog::class, - ], - ]; - - yield 'fields - invalid uuid' => [ - [ - ...$log, - 'id' => 'not-a-uuid', - ], - [ - MigrationValidationExceptionLog::class, - ], - ]; - - yield 'fields - invalid json' => [ - [ - ...$log, - 'sourceData' => "\xB1\x31", - ], - [ - MigrationValidationInvalidFieldValueLog::class, - ], - ]; - - yield 'structure/field - multiple errors' => [ - [ - 'id' => Uuid::randomHex(), - 'gatewayName' => true, - 'level' => 1, - 'code' => ['sw'], - 'userFixable' => true, - 'createdAt' => (new \DateTime())->format(\DATE_ATOM), - 'unexpectedField' => 'value', - ], - [ - MigrationValidationMissingRequiredFieldLog::class, - MigrationValidationUnexpectedFieldLog::class, - MigrationValidationInvalidFieldValueLog::class, - MigrationValidationInvalidFieldValueLog::class, - MigrationValidationInvalidFieldValueLog::class, - ], - ]; - } - - public static function associationProvider(): \Generator - { - $log = [ - 'id' => Uuid::randomHex(), - 'profileName' => 'profile', - 'gatewayName' => 'gateway', - 'level' => 'error', - 'code' => 'some_code', - 'userFixable' => true, - 'sourceData' => [ - 'some' => 'data', - ], - 'createdAt' => (new \DateTime())->format(\DATE_ATOM), - ]; - - $runId = Uuid::randomHex(); - $mapping = [ - 'id' => Uuid::randomHex(), - 'connectionId' => self::CONNECTION_ID, - 'entity' => SwagMigrationRunDefinition::ENTITY_NAME, - 'oldIdentifier' => $runId, - 'entityId' => $runId, - ]; - - yield 'valid fk' => [ - [ - ...$log, - 'runId' => $runId, - ], - [$mapping], - [], - ]; - - yield 'invalid fk' => [ - [ - ...$log, - 'runId' => Uuid::randomHex(), - ], - [$mapping], - [MigrationValidationInvalidForeignKeyLog::class], - ]; - - yield 'fk field not in converted data' => [ - $log, - [], - [], - ]; - - yield 'fk value is null' => [ - [ - ...$log, - 'runId' => null, - ], - [], - [ - MigrationValidationInvalidFieldValueLog::class, - ], - ]; - - yield 'fk value is empty string' => [ - [ - ...$log, - 'runId' => '', - ], - [], - [ - MigrationValidationInvalidFieldValueLog::class, - ], - ]; - } -} 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'), + ]; + } +} diff --git a/tests/unit/Migration/ErrorResolution/MigrationFixTest.php b/tests/unit/Migration/ErrorResolution/MigrationFixTest.php index 19a968834..cf5599734 100644 --- a/tests/unit/Migration/ErrorResolution/MigrationFixTest.php +++ b/tests/unit/Migration/ErrorResolution/MigrationFixTest.php @@ -78,6 +78,190 @@ public function testApplyFix(): void static::assertSame('untouchedValue', $item['path']['to']['the']['value']['which']['needs']['to']['be']['doNotTouch']); } + public function testApplyFixToArrayAssociation(): void + { + $expectedValue = 'fixedTypeId'; + + $fix = new MigrationFix( + 'anyId', + \json_encode($expectedValue, \JSON_THROW_ON_ERROR), + 'numberRangeSalesChannels.numberRangeTypeId', + ); + + $item = [ + 'id' => 'anyId', + 'name' => 'Order Number', + 'numberRangeSalesChannels' => [ + [ + 'id' => 'channel1', + 'numberRangeTypeId' => null, + 'salesChannelId' => 'sc1', + ], + [ + 'id' => 'channel2', + 'numberRangeTypeId' => null, + 'salesChannelId' => 'sc2', + ], + [ + 'id' => 'channel3', + 'numberRangeTypeId' => 'existingValue', + 'salesChannelId' => 'sc3', + ], + ], + ]; + + $fix->apply($item); + + // All array items should have the fix applied + static::assertSame($expectedValue, $item['numberRangeSalesChannels'][0]['numberRangeTypeId']); + static::assertSame($expectedValue, $item['numberRangeSalesChannels'][1]['numberRangeTypeId']); + static::assertSame($expectedValue, $item['numberRangeSalesChannels'][2]['numberRangeTypeId']); + + // Other fields should remain untouched + static::assertSame('Order Number', $item['name']); + static::assertSame('channel1', $item['numberRangeSalesChannels'][0]['id']); + static::assertSame('sc1', $item['numberRangeSalesChannels'][0]['salesChannelId']); + static::assertSame('channel2', $item['numberRangeSalesChannels'][1]['id']); + static::assertSame('sc2', $item['numberRangeSalesChannels'][1]['salesChannelId']); + } + + public function testApplyFixToEmptyArray(): void + { + $fix = new MigrationFix( + 'anyId', + \json_encode('fixedValue', \JSON_THROW_ON_ERROR), + 'items.fieldName', + ); + + $item = [ + 'id' => 'anyId', + 'items' => [], + ]; + + // Should not crash when array is empty + $fix->apply($item); + + static::assertSame([], $item['items']); + } + + public function testApplyFixToDeeplyNestedArrays(): void + { + $expectedValue = 'deepFixedValue'; + + $fix = new MigrationFix( + 'anyId', + \json_encode($expectedValue, \JSON_THROW_ON_ERROR), + 'categories.children.name', + ); + + $item = [ + 'id' => 'anyId', + 'categories' => [ + [ + 'id' => 'cat1', + 'children' => [ + ['id' => 'child1', 'name' => null], + ['id' => 'child2', 'name' => null], + ], + ], + [ + 'id' => 'cat2', + 'children' => [ + ['id' => 'child3', 'name' => 'existingName'], + ], + ], + ], + ]; + + $fix->apply($item); + + // All nested array items should have the fix applied + static::assertSame($expectedValue, $item['categories'][0]['children'][0]['name']); + static::assertSame($expectedValue, $item['categories'][0]['children'][1]['name']); + static::assertSame($expectedValue, $item['categories'][1]['children'][0]['name']); + + // Other fields should remain untouched + static::assertSame('cat1', $item['categories'][0]['id']); + static::assertSame('cat2', $item['categories'][1]['id']); + static::assertSame('child1', $item['categories'][0]['children'][0]['id']); + } + + public function testApplyFixToMixedAssociativeAndArrayPaths(): void + { + $expectedValue = 'mixedPathValue'; + + $fix = new MigrationFix( + 'anyId', + \json_encode($expectedValue, \JSON_THROW_ON_ERROR), + 'product.prices.currencyId', + ); + + $item = [ + 'id' => 'anyId', + 'product' => [ + 'id' => 'prod1', + 'name' => 'Test Product', + 'prices' => [ + ['id' => 'price1', 'currencyId' => null, 'gross' => 100], + ['id' => 'price2', 'currencyId' => null, 'gross' => 200], + ], + ], + ]; + + $fix->apply($item); + + // Fix should be applied to all price items + static::assertSame($expectedValue, $item['product']['prices'][0]['currencyId']); + static::assertSame($expectedValue, $item['product']['prices'][1]['currencyId']); + + // Other fields should remain untouched + static::assertSame('prod1', $item['product']['id']); + static::assertSame('Test Product', $item['product']['name']); + static::assertSame(100, $item['product']['prices'][0]['gross']); + static::assertSame(200, $item['product']['prices'][1]['gross']); + } + + public function testApplyFixCreatesPathIfNotExists(): void + { + $expectedValue = 'newValue'; + + $fix = new MigrationFix( + 'anyId', + \json_encode($expectedValue, \JSON_THROW_ON_ERROR), + 'new.path.field', + ); + + $item = [ + 'id' => 'anyId', + 'existingField' => 'existingValue', + ]; + + $fix->apply($item); + + static::assertSame($expectedValue, $item['new']['path']['field']); + static::assertSame('existingValue', $item['existingField']); + } + + public function testApplyFixWithArrayValue(): void + { + $expectedValue = ['id1', 'id2', 'id3']; + + $fix = new MigrationFix( + 'anyId', + \json_encode($expectedValue, \JSON_THROW_ON_ERROR), + 'tags', + ); + + $item = [ + 'id' => 'anyId', + 'tags' => [], + ]; + + $fix->apply($item); + + static::assertSame($expectedValue, $item['tags']); + } + public function testCreateFromDatabaseQuery(): void { $data = [ diff --git a/tests/unit/Migration/Logging/Log/MigrationLogTest.php b/tests/unit/Migration/Logging/Log/MigrationLogTest.php index cdf99f432..c2ac1943c 100644 --- a/tests/unit/Migration/Logging/Log/MigrationLogTest.php +++ b/tests/unit/Migration/Logging/Log/MigrationLogTest.php @@ -39,10 +39,11 @@ use SwagMigrationAssistant\Migration\Logging\Log\WriteExceptionRunLog; use SwagMigrationAssistant\Migration\MigrationContext; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationExceptionLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidFieldValueLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidForeignKeyLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidAssociationLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidOptionalFieldValueLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredFieldValueLog; +use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationInvalidRequiredTranslation; use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationMissingRequiredFieldLog; -use SwagMigrationAssistant\Migration\Validation\Log\MigrationValidationUnexpectedFieldLog; use SwagMigrationAssistant\Profile\Shopware54\Shopware54Profile; use SwagMigrationAssistant\Test\Mock\Gateway\Dummy\Local\DummyLocalGateway; @@ -107,32 +108,39 @@ public static function logProvider(): \Generator 'userFixable' => false, ]; - yield MigrationValidationInvalidFieldValueLog::class => [ - 'logClass' => MigrationValidationInvalidFieldValueLog::class, - 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_FIELD_VALUE', + yield MigrationValidationInvalidAssociationLog::class => [ + 'logClass' => MigrationValidationInvalidAssociationLog::class, + 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_ASSOCIATION', 'level' => AbstractMigrationLogEntry::LOG_LEVEL_ERROR, 'userFixable' => true, ]; - yield MigrationValidationInvalidForeignKeyLog::class => [ - 'logClass' => MigrationValidationInvalidForeignKeyLog::class, - 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_FOREIGN_KEY', + yield MigrationValidationInvalidOptionalFieldValueLog::class => [ + 'logClass' => MigrationValidationInvalidOptionalFieldValueLog::class, + 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_OPTIONAL_FIELD_VALUE', 'level' => AbstractMigrationLogEntry::LOG_LEVEL_WARNING, 'userFixable' => true, ]; + yield MigrationValidationInvalidRequiredFieldValueLog::class => [ + 'logClass' => MigrationValidationInvalidRequiredFieldValueLog::class, + 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_FIELD_VALUE', + 'level' => AbstractMigrationLogEntry::LOG_LEVEL_ERROR, + 'userFixable' => true, + ]; + yield MigrationValidationMissingRequiredFieldLog::class => [ 'logClass' => MigrationValidationMissingRequiredFieldLog::class, 'code' => 'SWAG_MIGRATION_VALIDATION_MISSING_REQUIRED_FIELD', 'level' => AbstractMigrationLogEntry::LOG_LEVEL_ERROR, - 'userFixable' => false, + 'userFixable' => true, ]; - yield MigrationValidationUnexpectedFieldLog::class => [ - 'logClass' => MigrationValidationUnexpectedFieldLog::class, - 'code' => 'SWAG_MIGRATION_VALIDATION_UNEXPECTED_FIELD', - 'level' => AbstractMigrationLogEntry::LOG_LEVEL_WARNING, - 'userFixable' => true, + yield MigrationValidationInvalidRequiredTranslation::class => [ + 'logClass' => MigrationValidationInvalidRequiredTranslation::class, + 'code' => 'SWAG_MIGRATION_VALIDATION_INVALID_REQUIRED_TRANSLATION', + 'level' => AbstractMigrationLogEntry::LOG_LEVEL_ERROR, + 'userFixable' => false, ]; yield AssociationRequiredMissingLog::class => [