diff --git a/.gitignore b/.gitignore index 6c9b19445..2bfece247 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,10 @@ tests/IntegrationTests/src #IDE .idea +# macOS +.DS_Store +*-backups.zip + # Playwright E2E .env.playwright tests/playwright/.auth/ diff --git a/inc/Smartling/ApiWrapper.php b/inc/Smartling/ApiWrapper.php index 2fd6ba5cb..0d09b2b7f 100644 --- a/inc/Smartling/ApiWrapper.php +++ b/inc/Smartling/ApiWrapper.php @@ -68,7 +68,7 @@ public function __construct(SettingsManager $manager, string $pluginName, string */ private function getConfigurationProfile(SubmissionEntity $submission): ConfigurationProfileEntity { - $profile = $this->settings->getSingleSettingsProfile($submission->getSourceBlogId()); + $profile = $this->settings->getProfileBySubmission($submission); LogContextMixinHelper::addToContext('projectId', $profile->getProjectId()); if (TestRunHelper::isTestRunBlog($submission->getTargetBlogId())) { diff --git a/inc/Smartling/Base/SmartlingCoreAttachments.php b/inc/Smartling/Base/SmartlingCoreAttachments.php index eeac14bd8..812aa399f 100644 --- a/inc/Smartling/Base/SmartlingCoreAttachments.php +++ b/inc/Smartling/Base/SmartlingCoreAttachments.php @@ -35,7 +35,7 @@ public function syncAttachment(SubmissionEntity $submission): void $submission->getTargetId(), ]) ); - $profile = $this->getSettingsManager()->getSingleSettingsProfile($submission->getSourceBlogId()); + $profile = $this->getSettingsManager()->getProfileBySubmission($submission); if (1 === $profile->getAlwaysSyncImagesOnUpload() || ($submission->getStatus() === SubmissionEntity::SUBMISSION_STATUS_NEW && !$targetFileExists)) { $this->syncMediaFile($submission); } diff --git a/inc/Smartling/Base/SmartlingCoreDownloadTrait.php b/inc/Smartling/Base/SmartlingCoreDownloadTrait.php index 69a27d5a4..bf0e2ec36 100644 --- a/inc/Smartling/Base/SmartlingCoreDownloadTrait.php +++ b/inc/Smartling/Base/SmartlingCoreDownloadTrait.php @@ -50,7 +50,7 @@ public function downloadTranslationBySubmission(SubmissionEntity $entity): void LiveNotificationController::pushNotification( $this ->getSettingsManager() - ->getSingleSettingsProfile($entity->getSourceBlogId()) + ->getProfileBySubmission($entity) ->getProjectId(), LiveNotificationController::getContentId($entity), LiveNotificationController::SEVERITY_SUCCESS, @@ -65,7 +65,7 @@ public function downloadTranslationBySubmission(SubmissionEntity $entity): void LiveNotificationController::pushNotification( $this ->getSettingsManager() - ->getSingleSettingsProfile($entity->getSourceBlogId()) + ->getProfileBySubmission($entity) ->getProjectId(), LiveNotificationController::getContentId($entity), LiveNotificationController::SEVERITY_SUCCESS, @@ -78,7 +78,7 @@ public function downloadTranslationBySubmission(SubmissionEntity $entity): void LiveNotificationController::pushNotification( $this ->getSettingsManager() - ->getSingleSettingsProfile($entity->getSourceBlogId()) + ->getProfileBySubmission($entity) ->getProjectId(), LiveNotificationController::getContentId($entity), LiveNotificationController::SEVERITY_SUCCESS, @@ -100,7 +100,7 @@ public function downloadTranslationBySubmission(SubmissionEntity $entity): void LiveNotificationController::pushNotification( $this ->getSettingsManager() - ->getSingleSettingsProfile($entity->getSourceBlogId()) + ->getProfileBySubmission($entity) ->getProjectId(), LiveNotificationController::getContentId($entity), LiveNotificationController::SEVERITY_ERROR, diff --git a/inc/Smartling/Base/SmartlingCoreUploadTrait.php b/inc/Smartling/Base/SmartlingCoreUploadTrait.php index 832a7bea3..b1e89ca41 100644 --- a/inc/Smartling/Base/SmartlingCoreUploadTrait.php +++ b/inc/Smartling/Base/SmartlingCoreUploadTrait.php @@ -61,6 +61,8 @@ protected function getFunctionProxyHelper(): WordpressFunctionProxyHelper public function prepareUpload(SubmissionEntity $submission): SubmissionEntity { + $this->getSubmissionManager()->stampConfigurationProfile($submission); + return $this->renewContentHash( $this->createTargetContent( $this->setFileUriIfNullId($submission) @@ -259,7 +261,7 @@ public function applyXML(SubmissionEntity $submission, string $xml, XmlHelper $x $targetContent = $targetContent->fromArray($translation['entity']); } $configurationProfile = $this->getSettingsManager() - ->getSingleSettingsProfile($submission->getSourceBlogId()); + ->getProfileBySubmission($submission); $percentage = $submission->getCompletionPercentage(); $this->getLogger()->debug(vsprintf('Current percentage is %s', [$percentage])); @@ -424,7 +426,7 @@ public function bulkSubmit(UploadQueueItem $item): void } $submission = $item->getSubmissions()[0]; $locales = $item->getSmartlingLocales()->getList(); - $profile = $this->getSettingsManager()->getSingleSettingsProfile($submission->getSourceBlogId()); + $profile = $this->getSettingsManager()->getProfileBySubmission($submission); try { $xml = $this->getXMLFiltered($submission); if ($xml === '') { @@ -530,7 +532,7 @@ public function sendForTranslation(UploadQueueItem $item): void return; } - $configurationProfile = $this->getSettingsManager()->getSingleSettingsProfile($item->getSubmissions()[0]->getSourceBlogId()); + $configurationProfile = $this->getSettingsManager()->getProfileBySubmission($item->getSubmissions()[0]); // Clone attachment submission instead of uploading it, if "Clone attachment" // option is enabled in configuration profile. diff --git a/inc/Smartling/Base/SmartlingEntityAbstract.php b/inc/Smartling/Base/SmartlingEntityAbstract.php index 270bd5897..4d714ff4a 100644 --- a/inc/Smartling/Base/SmartlingEntityAbstract.php +++ b/inc/Smartling/Base/SmartlingEntityAbstract.php @@ -13,6 +13,7 @@ abstract class SmartlingEntityAbstract implements SmartlingTableDefinitionInterf public const DB_TYPE_DEFAULT_EMPTYSTRING = 'DEFAULT \'\''; public const DB_TYPE_U_BIGINT = 'INT(20) UNSIGNED NOT NULL'; // BIGINT alias of INT(20) + public const DB_TYPE_U_BIGINT_NULL = 'INT(20) UNSIGNED NULL'; public const DB_TYPE_DATETIME = 'DATETIME NOT NULL DEFAULT \'0000-00-00 00:00:00\''; public const DB_TYPE_DATETIME_NULL = 'DATETIME NULL DEFAULT NULL'; public const DB_TYPE_STRING_STANDARD = 'VARCHAR(255) NOT NULL'; diff --git a/inc/Smartling/DbAl/Migrations/Migration261001.php b/inc/Smartling/DbAl/Migrations/Migration261001.php new file mode 100644 index 000000000..ca786a723 --- /dev/null +++ b/inc/Smartling/DbAl/Migrations/Migration261001.php @@ -0,0 +1,43 @@ +completeTableName(SubmissionEntity::getTableName()); + + // Migration240315 may already have created the table with the current field definitions. + $existingColumns = $db->getColumnArray("SHOW COLUMNS FROM `$tableName`"); + if (in_array(SubmissionEntity::FIELD_CONFIGURATION_PROFILE_ID, $existingColumns, true)) { + return []; + } + + return [ + sprintf( + 'ALTER TABLE `%s` ADD COLUMN `%s` %s', + $tableName, + SubmissionEntity::FIELD_CONFIGURATION_PROFILE_ID, + SubmissionEntity::getFieldDefinitions()[SubmissionEntity::FIELD_CONFIGURATION_PROFILE_ID] + ), + ]; + } +} diff --git a/inc/Smartling/DbAl/UploadQueueManager.php b/inc/Smartling/DbAl/UploadQueueManager.php index ad5a0ac16..d833623a4 100644 --- a/inc/Smartling/DbAl/UploadQueueManager.php +++ b/inc/Smartling/DbAl/UploadQueueManager.php @@ -270,15 +270,16 @@ public function purge(): void if ($submission === null) { continue; } - if (!array_key_exists($submission->getSourceBlogId(), $profiles)) { + $profileKey = $submission->getConfigurationProfileId() ?? "blog{$submission->getSourceBlogId()}"; + if (!array_key_exists($profileKey, $profiles)) { try { - $profile = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + $profile = $this->settingsManager->getProfileBySubmission($submission); } catch (SmartlingDbException) { $profile = null; } - $profiles[$submission->getSourceBlogId()] = $profile; + $profiles[$profileKey] = $profile; } - $profile = $profiles[$submission->getSourceBlogId()]; + $profile = $profiles[$profileKey]; if (!$profile instanceof ConfigurationProfileEntity) { continue; } diff --git a/inc/Smartling/FTS/FtsApiWrapper.php b/inc/Smartling/FTS/FtsApiWrapper.php index 90ee934e9..1fe1c74d6 100644 --- a/inc/Smartling/FTS/FtsApiWrapper.php +++ b/inc/Smartling/FTS/FtsApiWrapper.php @@ -32,7 +32,7 @@ public function __construct( */ private function getConfigurationProfile(SubmissionEntity $submission): ConfigurationProfileEntity { - return $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + return $this->settingsManager->getProfileBySubmission($submission); } private function getFileTranslationsApi(ConfigurationProfileEntity $profile): FileTranslationsApiExtended diff --git a/inc/Smartling/FTS/FtsService.php b/inc/Smartling/FTS/FtsService.php index 5b6f9b2de..3df711de7 100644 --- a/inc/Smartling/FTS/FtsService.php +++ b/inc/Smartling/FTS/FtsService.php @@ -114,7 +114,7 @@ public function requestInstantTranslationBatch(array $submissions): array try { $fileUid = $this->uploadFile($firstSubmission); - $profile = $this->settingsManager->getSingleSettingsProfile($firstSubmission->getSourceBlogId()); + $profile = $this->settingsManager->getProfileBySubmission($firstSubmission); $sourceLocale = $this->apiWrapper->getSourceLocale($profile); $targetLocales = []; @@ -304,7 +304,7 @@ private function submitFile(SubmissionEntity $submission, string $fileUid): stri { $this->getLogger()->debug("Submitting file for instant translation, submissionId={$submission->getId()}, fileUid=$fileUid"); - $profile = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + $profile = $this->settingsManager->getProfileBySubmission($submission); $sourceLocale = $this->apiWrapper->getSourceLocale($profile); $targetLocale = $profile->getSmartlingLocale($submission->getTargetBlogId()); @@ -398,7 +398,7 @@ private function downloadAndApply(SubmissionEntity $submission, string $fileUid, { $this->getLogger()->info("Downloading and applying translation, submissionId={$submission->getId()}, fileUid=$fileUid, mtUid=$mtUid"); - $profile = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + $profile = $this->settingsManager->getProfileBySubmission($submission); $targetLocale = $profile->getSmartlingLocale($submission->getTargetBlogId()); if (empty($targetLocale)) { diff --git a/inc/Smartling/Helpers/DetectChangesHelper.php b/inc/Smartling/Helpers/DetectChangesHelper.php index c13644a1e..5f374e94e 100644 --- a/inc/Smartling/Helpers/DetectChangesHelper.php +++ b/inc/Smartling/Helpers/DetectChangesHelper.php @@ -96,7 +96,7 @@ private function update(SubmissionEntity $submission, bool $needUpdateStatus, st LiveNotificationController::pushNotification( $this->settingsManager - ->getSingleSettingsProfile($submission->getSourceBlogId()) + ->getProfileBySubmission($submission) ->getProjectId(), LiveNotificationController::getContentId($submission), LiveNotificationController::SEVERITY_WARNING, diff --git a/inc/Smartling/Helpers/FieldsFilterHelper.php b/inc/Smartling/Helpers/FieldsFilterHelper.php index ac8162bf6..2ca96789e 100644 --- a/inc/Smartling/Helpers/FieldsFilterHelper.php +++ b/inc/Smartling/Helpers/FieldsFilterHelper.php @@ -111,7 +111,7 @@ public function removeIgnoringFields(SubmissionEntity $submission, array $data): $this->prepareSourceData($data) ), $this->contentSerializationHelper->prepareFieldProcessorValues($submission)['ignore'], - $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId())->getFilterFieldNameRegExp()), + $this->settingsManager->getProfileBySubmission($submission)->getFilterFieldNameRegExp()), ); } @@ -128,6 +128,7 @@ public function processStringsBeforeEncoding( } $settings = $this->contentSerializationHelper->prepareFieldProcessorValues($submission); + $filterFieldNameRegExp = $this->settingsManager->getProfileBySubmission($submission)->getFilterFieldNameRegExp(); return $this->passConnectionProfileFilters( $this->passFieldProcessorsBeforeSendFilters( @@ -135,11 +136,11 @@ public function processStringsBeforeEncoding( $this->removeFields( $this->flattenArray($data), $settings['ignore'], - $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId())->getFilterFieldNameRegExp(), + $filterFieldNameRegExp, ) ), $strategy, - $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId())->getFilterFieldNameRegExp(), + $filterFieldNameRegExp, $settings, ); } @@ -174,17 +175,19 @@ public function applyTranslatedValues(SubmissionEntity $submission, array $origi private function filterArray(array $array, SubmissionEntity $submission, string $strategy, array $settings): array { + $filterFieldNameRegExp = $this->settingsManager->getProfileBySubmission($submission)->getFilterFieldNameRegExp(); + return $this->passConnectionProfileFilters( $this->passFieldProcessorsFilters( $submission, $this->removeFields( $array, $settings['ignore'], - $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId())->getFilterFieldNameRegExp(), + $filterFieldNameRegExp, ), ), $strategy, - $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId())->getFilterFieldNameRegExp(), + $filterFieldNameRegExp, $this->contentSerializationHelper->prepareFieldProcessorValues($submission), ); } diff --git a/inc/Smartling/Helpers/RuntimeCacheHelper.php b/inc/Smartling/Helpers/RuntimeCacheHelper.php index a824a5eb6..8fd73d760 100644 --- a/inc/Smartling/Helpers/RuntimeCacheHelper.php +++ b/inc/Smartling/Helpers/RuntimeCacheHelper.php @@ -54,4 +54,9 @@ public function set($key, $value, $scope = self::DEFAULT_SCOPE) { $this->storage[$scope][$key] = $value; } + + public function clear(): void + { + $this->storage = []; + } } \ No newline at end of file diff --git a/inc/Smartling/Helpers/SubstringProcessorHelperAbstract.php b/inc/Smartling/Helpers/SubstringProcessorHelperAbstract.php index fd5903a0f..baada768e 100644 --- a/inc/Smartling/Helpers/SubstringProcessorHelperAbstract.php +++ b/inc/Smartling/Helpers/SubstringProcessorHelperAbstract.php @@ -256,7 +256,7 @@ private function passProfileFilters(array $attributes) $fFilter = $this->getFieldsFilter(); $settings = $this->contentSerializationHelper->prepareFieldProcessorValues($submission); - $removeAsRegExp = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId())->getFilterFieldNameRegExp(); + $removeAsRegExp = $this->settingsManager->getProfileBySubmission($submission)->getFilterFieldNameRegExp(); $attributes = $fFilter->removeFields($attributes, $settings['ignore'], $removeAsRegExp); $attributes = $fFilter->removeFields($attributes, $settings['copy']['name'], $removeAsRegExp); diff --git a/inc/Smartling/Helpers/XmlHelper.php b/inc/Smartling/Helpers/XmlHelper.php index 108856d7b..40fa157f7 100644 --- a/inc/Smartling/Helpers/XmlHelper.php +++ b/inc/Smartling/Helpers/XmlHelper.php @@ -90,7 +90,7 @@ public function xmlEncode(array $source, SubmissionEntity $submission, array $or { $this->getLogger()->debug(sprintf('Started creating XML for fields: %s', base64_encode(var_export($source, true)))); try { - $profile = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + $profile = $this->settingsManager->getProfileBySubmission($submission); } catch (SmartlingDbException) { $profile = null; } diff --git a/inc/Smartling/Jobs/LastModifiedCheckJob.php b/inc/Smartling/Jobs/LastModifiedCheckJob.php index 5a9902e95..5989c16d6 100644 --- a/inc/Smartling/Jobs/LastModifiedCheckJob.php +++ b/inc/Smartling/Jobs/LastModifiedCheckJob.php @@ -194,7 +194,7 @@ private function lastModifiedCheck(string $queueName, bool $failMissing): void protected function processDownloadOnChange(array $submissions): void { foreach ($submissions as $submission) { - $profile = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + $profile = $this->settingsManager->getProfileBySubmission($submission); if (ConfigurationProfileEntity::TRANSLATION_DOWNLOAD_MODE_PROGRESS_CHANGES === $profile->getDownloadOnChange()) { $this->getLogger() @@ -238,7 +238,7 @@ public function statusCheck(array $submissions): void $submissions = $this->submissionManager->storeSubmissions($statusCheckResult); foreach ($submissions as $submission) { - $profile = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + $profile = $this->settingsManager->getProfileBySubmission($submission); if ($profile->getDownloadOnChange() !== ConfigurationProfileEntity::TRANSLATION_DOWNLOAD_MODE_MANUAL) { $this->checkEntityForDownload($submission); } @@ -273,7 +273,7 @@ public function getSmartlingLocaleIdBySubmission(SubmissionEntity $submission): { return $this->settingsManager ->getSmartlingLocaleIdBySettingsProfile( - $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()), + $this->settingsManager->getProfileBySubmission($submission), $submission->getTargetBlogId() ); } diff --git a/inc/Smartling/Jobs/UploadJob.php b/inc/Smartling/Jobs/UploadJob.php index 5a1a93398..4c1eef2e1 100644 --- a/inc/Smartling/Jobs/UploadJob.php +++ b/inc/Smartling/Jobs/UploadJob.php @@ -77,16 +77,24 @@ private function processUploadQueue(int $blogId): void $submission->setFileUri($this->fileUriHelper->generateFileUri($submission)); $this->submissionManager->storeEntity($submission); } - if (!array_key_exists($submission->getSourceBlogId(), $profiles)) { + // Existing submissions are loaded from the queue by id, so they never pass through + // SubmissionManager::getSubmissionEntity(): remember the profile used for this upload here. + $previousProfileId = $submission->getConfigurationProfileId(); + $this->submissionManager->stampConfigurationProfile($submission); + if ($submission->getConfigurationProfileId() !== $previousProfileId) { + $this->submissionManager->storeEntity($submission); + } + $profileKey = $submission->getConfigurationProfileId() ?? "blog{$submission->getSourceBlogId()}"; + if (!array_key_exists($profileKey, $profiles)) { try { - $profiles[$submission->getSourceBlogId()] = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + $profiles[$profileKey] = $this->settingsManager->getProfileBySubmission($submission); } catch (SmartlingDbException) { $this->failItem($item, 'Skipping upload of', "No active profile found for blogId={$submission->getSourceBlogId()}"); $this->uploadQueueManager->complete($item); continue; } } - $profile = $profiles[$submission->getSourceBlogId()]; + $profile = $profiles[$profileKey]; if ($item->getBatchUid() === '') { try { $item = $item->setBatchUid($this->api->getOrCreateJobInfoForDailyBucketJob($profile, [$submission->getFileUri()])->getBatchUid()); diff --git a/inc/Smartling/Settings/SettingsManager.php b/inc/Smartling/Settings/SettingsManager.php index beb5cd854..a8599315a 100644 --- a/inc/Smartling/Settings/SettingsManager.php +++ b/inc/Smartling/Settings/SettingsManager.php @@ -119,6 +119,29 @@ public function getSingleSettingsProfile(int $mainBlogId): ConfigurationProfileE throw new SmartlingDbException($message); } + /** + * Returns the profile the submission was requested with, so delivery doesn't depend on which profile is active now. + * Falls back to the active profile of the source blog for submissions without a stored (or an existing) profile. + * + * @throws SmartlingDbException + */ + public function getProfileBySubmission(SubmissionEntity $submission): ConfigurationProfileEntity + { + $profileId = $submission->getConfigurationProfileId(); + if ($profileId !== null) { + $profile = ArrayHelper::first($this->getEntityById($profileId)); + if ($profile instanceof ConfigurationProfileEntity) { + if ($profile->getSourceLocale()->getBlogId() !== $submission->getSourceBlogId()) { + $this->getLogger()->warning("Profile id=$profileId stored for submission id={$submission->getId()} has source blog {$profile->getSourceLocale()->getBlogId()}, but submission source blog is {$submission->getSourceBlogId()}, profile may have been repurposed since the submission was stamped"); + } + return $profile; + } + $this->getLogger()->warning("Profile id=$profileId stored for submission id={$submission->getId()} not found, using active profile of source blog"); + } + + return $this->getSingleSettingsProfile($submission->getSourceBlogId()); + } + /** * @return int[] * @throws SmartlingDbException diff --git a/inc/Smartling/Submissions/SubmissionEntity.php b/inc/Smartling/Submissions/SubmissionEntity.php index 766d63cc6..1e3220d88 100644 --- a/inc/Smartling/Submissions/SubmissionEntity.php +++ b/inc/Smartling/Submissions/SubmissionEntity.php @@ -83,6 +83,7 @@ class SubmissionEntity extends SmartlingEntityAbstract implements Submission public const FIELD_LAST_ERROR = 'last_error'; public const FIELD_LOCKED_FIELDS = 'locked_fields'; public const FIELD_CREATED_AT = 'created_at'; + public const FIELD_CONFIGURATION_PROFILE_ID = 'configuration_profile_id'; public const VIRTUAL_FIELD_JOB_LINK = 'job_link'; @@ -117,6 +118,7 @@ public static function getFieldDefinitions(): array static::FIELD_LAST_ERROR => static::DB_TYPE_STRING_TEXT, static::FIELD_LOCKED_FIELDS => 'TEXT NULL', static::FIELD_CREATED_AT => static::DB_TYPE_DATETIME, + static::FIELD_CONFIGURATION_PROFILE_ID => static::DB_TYPE_U_BIGINT_NULL, ]; } @@ -515,6 +517,20 @@ public function setSubmitter(string $submitter): SubmissionEntity return $this; } + public function getConfigurationProfileId(): ?int + { + $value = $this->stateFields[static::FIELD_CONFIGURATION_PROFILE_ID]; + + return $value === null ? null : (int)$value; + } + + public function setConfigurationProfileId(?int $configurationProfileId): self + { + $this->stateFields[static::FIELD_CONFIGURATION_PROFILE_ID] = $configurationProfileId; + + return $this; + } + public function getCreatedAt(): ?string { return $this->stateFields[static::FIELD_CREATED_AT]; diff --git a/inc/Smartling/Submissions/SubmissionManager.php b/inc/Smartling/Submissions/SubmissionManager.php index f29278252..426aa6b77 100644 --- a/inc/Smartling/Submissions/SubmissionManager.php +++ b/inc/Smartling/Submissions/SubmissionManager.php @@ -5,6 +5,7 @@ use Smartling\DbAl\EntityManagerAbstract; use Smartling\DbAl\LocalizationPluginProxyInterface; use Smartling\DbAl\SmartlingToCMSDatabaseAccessWrapperInterface; +use Smartling\Exception\SmartlingDbException; use Smartling\Exception\SmartlingHumanReadableException; use Smartling\Helpers\ArrayHelper; use Smartling\Helpers\DateTimeHelper; @@ -20,6 +21,7 @@ use Smartling\Jobs\SubmissionJobEntity; use Smartling\Jobs\SubmissionsJobsManager; use Smartling\Models\DuplicateSubmissionDetails; +use Smartling\Settings\SettingsManager; class SubmissionManager extends EntityManagerAbstract { @@ -39,7 +41,7 @@ public function getDefaultSubmissionStatus(): string return SubmissionEntity::SUBMISSION_STATUS_IN_PROGRESS; } - public function __construct(SmartlingToCMSDatabaseAccessWrapperInterface $dbal, int $pageSize, JobManager $jobManager, LocalizationPluginProxyInterface $localizationPluginProxy, SiteHelper $siteHelper, SubmissionsJobsManager $submissionsJobsManager) + public function __construct(SmartlingToCMSDatabaseAccessWrapperInterface $dbal, int $pageSize, JobManager $jobManager, LocalizationPluginProxyInterface $localizationPluginProxy, SiteHelper $siteHelper, SubmissionsJobsManager $submissionsJobsManager, private SettingsManager $settingsManager) { parent::__construct($dbal, $pageSize, $siteHelper, $localizationPluginProxy); $this->jobManager = $jobManager; @@ -484,10 +486,24 @@ public function getSubmissionEntity( $entity->setSourceTitle('no title'); $entity->setCreatedAt(DateTimeHelper::nowAsString()); } + $this->stampConfigurationProfile($entity); return $entity; } + /** + * Remembers the profile used for this translation request, so that delivery uses the same profile + * even if the active profile has been switched in the meantime. + */ + public function stampConfigurationProfile(SubmissionEntity $entity): void + { + try { + $entity->setConfigurationProfileId($this->settingsManager->getSingleSettingsProfile($entity->getSourceBlogId())->getId()); + } catch (SmartlingDbException) { + $this->getLogger()->debug("No active profile for source blog {$entity->getSourceBlogId()}, configuration profile not stored for submission"); + } + } + /** * @param SubmissionEntity[] $submissions * diff --git a/inc/Smartling/WP/Controller/PostBasedWidgetControllerStd.php b/inc/Smartling/WP/Controller/PostBasedWidgetControllerStd.php index 438961d3d..6a6731f36 100644 --- a/inc/Smartling/WP/Controller/PostBasedWidgetControllerStd.php +++ b/inc/Smartling/WP/Controller/PostBasedWidgetControllerStd.php @@ -138,7 +138,7 @@ public function ajaxDownloadHandler(): void if ($submission !== null) { $submissions[] = $submission; if ($profile === null) { - $profile = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + $profile = $this->settingsManager->getProfileBySubmission($submission); } $logSubmissions[] = [ 'sourceBlogId' => $submission->getSourceBlogId(), diff --git a/inc/Smartling/WP/Table/SubmissionTableWidget.php b/inc/Smartling/WP/Table/SubmissionTableWidget.php index a8175b511..fe3d36b9a 100644 --- a/inc/Smartling/WP/Table/SubmissionTableWidget.php +++ b/inc/Smartling/WP/Table/SubmissionTableWidget.php @@ -216,7 +216,7 @@ public function processBulkAction(): void $profile = null; foreach ($submissions as $submission) { if ($profile === null) { - $profile = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); + $profile = $this->settingsManager->getProfileBySubmission($submission); } $logSubmissions[] = [ 'sourceBlogId' => $submission->getSourceBlogId(), diff --git a/inc/config/migrations.yml b/inc/config/migrations.yml index a536f8102..00c7420cf 100644 --- a/inc/config/migrations.yml +++ b/inc/config/migrations.yml @@ -73,6 +73,9 @@ services: migration.260825: class: Smartling\DbAl\Migrations\Migration260825 + migration.261001: + class: Smartling\DbAl\Migrations\Migration261001 + manager.db.migrations: class: Smartling\DbAl\Migrations\DbMigrationManager calls: @@ -100,3 +103,4 @@ services: - [ "registerMigration", [ "@migration.220701" ]] - [ "registerMigration", [ "@migration.240315" ]] - [ "registerMigration", [ "@migration.260825" ]] + - [ "registerMigration", [ "@migration.261001" ]] diff --git a/inc/config/services.yml b/inc/config/services.yml index 3d51a7123..9aa4ab025 100644 --- a/inc/config/services.yml +++ b/inc/config/services.yml @@ -300,6 +300,7 @@ services: - "@multilang.proxy" - "@site.helper" - "@manager.submissions.jobs" + - "@manager.settings" manager.submissions.jobs: class: Smartling\Jobs\SubmissionsJobsManager diff --git a/tests/IntegrationTests/SmartlingUnitTestCaseAbstract.php b/tests/IntegrationTests/SmartlingUnitTestCaseAbstract.php index a8c6c6341..06e6ee714 100644 --- a/tests/IntegrationTests/SmartlingUnitTestCaseAbstract.php +++ b/tests/IntegrationTests/SmartlingUnitTestCaseAbstract.php @@ -2,7 +2,6 @@ namespace Smartling\Tests\IntegrationTests; -use Psr\Log\LoggerInterface; use Smartling\ApiWrapperInterface; use Smartling\Bootstrap; use Smartling\ContentTypes\CustomPostType; @@ -11,6 +10,7 @@ use Smartling\Helpers\ArrayHelper; use Smartling\Helpers\ContentHelper; use Smartling\Helpers\GutenbergBlockHelper; +use Smartling\Helpers\RuntimeCacheHelper; use Smartling\Helpers\SiteHelper; use Smartling\Helpers\TranslationHelper; use Smartling\Jobs\DownloadTranslationJob; @@ -31,6 +31,7 @@ use Smartling\Submissions\SubmissionEntity; use Smartling\Submissions\SubmissionManager; use Smartling\Tuner\MediaAttachmentRulesManager; +use Smartling\Vendor\Psr\Log\LoggerInterface; use Smartling\Vendor\Symfony\Component\DependencyInjection\ContainerBuilder; abstract class SmartlingUnitTestCaseAbstract extends WP_UnitTestCase @@ -106,6 +107,7 @@ protected function cleanUpTables() 'smartling_submissions', JobEntity::getTableName(), SubmissionJobEntity::getTableName(), + UploadQueueEntity::getTableName(), ]; $tablePrefix = getenv('WP_DB_TABLE_PREFIX'); @@ -122,6 +124,14 @@ public function setUp(): void { parent::setUp(); $this->cleanUpTables(); + /* + * cleanUpTables() truncates posts/submissions, so every test's fixtures restart from + * auto-increment id 1. ContentHelper's RuntimeCacheHelper is a process-wide singleton + * keyed by contentType-sourceBlogId-sourceId, so without this, one test's "submission + * 1 / post 1" can serve cached (stale) metadata to every later test that also lands on + * id 1 - which is effectively all of them. + */ + RuntimeCacheHelper::getInstance()->clear(); $this->registerPostTypes(); $this->ensureProfileExists(); } @@ -138,6 +148,18 @@ private static function getWPInstallDirEnv(): string return getenv('WP_INSTALL_DIR'); } + /** + * wp-cli needs a WordPress install whose wp-config.php points at the test database. + * WPCLI_PATH is that dedicated install (see tests/setup-local-test-db.sh); fall back to + * WP_INSTALL_DIR for environments that don't set it. + */ + private static function getWPcliPathEnv(): string + { + $path = getenv('WPCLI_PATH'); + + return $path !== false && $path !== '' ? $path : self::getWPInstallDirEnv(); + } + public function getApiWrapper(): ApiWrapperInterface { return $this->get('api.wrapper.with.retries'); @@ -208,7 +230,7 @@ protected function forceSubmissionDownload(SubmissionEntity $submission): void protected static function wpCliExec(string $command, string $subCommand, string $parameters): void { - shell_exec(sprintf('%s %s %s %s --path=%s', self::getWPcliEnv(), $command, $subCommand, $parameters, self::getWPInstallDirEnv())); + shell_exec(sprintf('%s %s %s %s --path=%s', self::getWPcliEnv(), $command, $subCommand, $parameters, self::getWPcliPathEnv())); } protected function getContainer(): ContainerBuilder diff --git a/tests/IntegrationTests/includes/bootstrap-local.php b/tests/IntegrationTests/includes/bootstrap-local.php new file mode 100644 index 000000000..52c66612b --- /dev/null +++ b/tests/IntegrationTests/includes/bootstrap-local.php @@ -0,0 +1,43 @@ +load(); diff --git a/tests/IntegrationTests/includes/local-wp-tests-config.php b/tests/IntegrationTests/includes/local-wp-tests-config.php new file mode 100644 index 000000000..dc301a219 --- /dev/null +++ b/tests/IntegrationTests/includes/local-wp-tests-config.php @@ -0,0 +1,40 @@ +ftsService = $this->get('fts.service'); + } + + /** + * Full FTS workflow: upload, translate, poll, download, apply. + * Verifies that a post is translated and the translated content appears in the target blog. + */ + public function testFullFtsWorkflow(): void + { + $this->loadBuiltInFilters(); + + $sourceContent = 'Hello world. This is a test post for instant translation.'; + $postId = $this->createPost('post', 'FTS Integration Test Post', $sourceContent); + $this->assertGreaterThan(0, $postId, 'Post creation failed'); + + $submission = $this->createSubmission('post', $postId, 1, 2); + $submission = $this->getSubmissionManager()->storeEntity($submission); + $this->assertNotNull($submission->getId(), 'Submission store failed'); + + // Initiate non-blocking FTS request + $result = $this->ftsService->requestInstantTranslationBatch([$submission]); + $this->assertTrue($result['success'], 'FTS batch request failed: ' . ($result['message'] ?? 'unknown error')); + $this->assertArrayHasKey('fileUid', $result); + $this->assertArrayHasKey('mtUid', $result); + $this->assertNotEmpty($result['fileUid']); + $this->assertNotEmpty($result['mtUid']); + + // Re-fetch submission to verify fileUid:mtUid was stored + $submission = $this->getSubmissionById($submission->getId()); + $this->assertNotNull($submission, 'Could not re-fetch submission'); + $this->assertNotEmpty($submission->getFileUri(), 'fileUid:mtUid was not stored in submission.file_uri'); + $this->assertStringContainsString(':', $submission->getFileUri(), 'file_uri should be in fileUid:mtUid format'); + + // Poll until completed or timeout + $finalStatus = $this->pollUntilDone($submission); + + $this->assertEquals('completed', $finalStatus['status'], + 'FTS translation did not complete within ' . self::POLL_TIMEOUT_SECONDS . ' seconds. ' . + 'Last status: ' . ($finalStatus['status'] ?? 'unknown') . '. ' . + 'Message: ' . ($finalStatus['message'] ?? '') + ); + + // Verify submission was marked as completed in the database + $completedSubmission = $this->getSubmissionById($submission->getId()); + $this->assertNotNull($completedSubmission); + $this->assertEquals( + SubmissionEntity::SUBMISSION_STATUS_COMPLETED, + $completedSubmission->getStatus(), + 'Submission status was not updated to COMPLETED' + ); + $this->assertGreaterThan(0, $completedSubmission->getTargetId(), + 'Target post was not created in the target blog' + ); + + // Verify the translated content exists in the target blog + $targetPost = $this->getTargetPost($this->getSiteHelper(), $completedSubmission); + $this->assertNotNull($targetPost, 'Target post not found in target blog'); + $this->assertNotEmpty($targetPost->post_content, 'Translated post content is empty'); + } + + /** + * Verifies that requestInstantTranslationBatch enforces the same-source constraint. + */ + public function testBatchRejectsSubmissionsFromDifferentSources(): void + { + $postId1 = $this->createPost('post', 'Source Post 1', 'Content 1'); + $postId2 = $this->createPost('post', 'Source Post 2', 'Content 2'); + + $submission1 = $this->getSubmissionManager()->storeEntity( + $this->createSubmission('post', $postId1, 1, 2) + ); + $submission2 = $this->getSubmissionManager()->storeEntity( + $this->createSubmission('post', $postId2, 1, 2) + ); + + // Two different source posts: should be rejected by the batch method + $result = $this->ftsService->requestInstantTranslationBatch([$submission1, $submission2]); + + $this->assertFalse($result['success']); + $this->assertStringContainsString('Same source', $result['message']); + } + + /** + * Verifies that checkAndApplyTranslation returns an error for a submission + * that has no fileUid:mtUid stored. + */ + public function testCheckStatusFailsWithoutFileUri(): void + { + $postId = $this->createPost('post', 'Post Without FTS', 'Some content'); + $submission = $this->getSubmissionManager()->storeEntity( + $this->createSubmission('post', $postId, 1, 2) + ); + + // file_uri is empty at this point (FTS not requested) + $result = $this->ftsService->checkAndApplyTranslation($submission); + + $this->assertEquals('error', $result['status']); + $this->assertArrayHasKey('message', $result); + } + + /** + * Polls FTS status until completed/failed/error or timeout. + */ + private function pollUntilDone(SubmissionEntity $submission): array + { + $start = time(); + $lastResult = ['status' => 'unknown']; + + $this->getLogger()->info(sprintf( + 'FtsIntegrationTest: Starting poll for submission %d (fileUri=%s)', + $submission->getId(), + $submission->getFileUri() + )); + + while ((time() - $start) < self::POLL_TIMEOUT_SECONDS) { + $result = $this->ftsService->checkAndApplyTranslation($submission); + $lastResult = $result; + + $this->getLogger()->info(sprintf( + 'FtsIntegrationTest: Poll result for submission %d: status=%s', + $submission->getId(), + $result['status'] + )); + + if (in_array($result['status'], ['completed', 'failed', 'error'], true)) { + return $result; + } + + sleep(self::POLL_SLEEP_SECONDS); + } + + $this->getLogger()->warning(sprintf( + 'FtsIntegrationTest: Polling timed out after %d seconds for submission %d', + self::POLL_TIMEOUT_SECONDS, + $submission->getId() + )); + + return array_merge($lastResult, ['status' => 'timeout']); + } +} diff --git a/tests/Services/ContentRelationsDiscoveryServiceTest.php b/tests/Services/ContentRelationsDiscoveryServiceTest.php index a480947fc..eb3767ac0 100644 --- a/tests/Services/ContentRelationsDiscoveryServiceTest.php +++ b/tests/Services/ContentRelationsDiscoveryServiceTest.php @@ -466,6 +466,7 @@ public function testJobInfoGetsStoredOnNewSubmissions() $this->createMock(LocalizationPluginProxyInterface::class), $this->createMock(SiteHelper::class), $submissionsJobsManager, + $this->createMock(SettingsManager::class), ])->onlyMethods(['find'])->getMock(); $submissionManager->method('find')->willReturn([]); diff --git a/tests/Smartling/Base/SmartlingCoreTest.php b/tests/Smartling/Base/SmartlingCoreTest.php index d71948331..a935f90c8 100644 --- a/tests/Smartling/Base/SmartlingCoreTest.php +++ b/tests/Smartling/Base/SmartlingCoreTest.php @@ -52,7 +52,13 @@ class SmartlingCoreTest extends TestCase protected function setUp(): void { WordpressFunctionsMockHelper::injectFunctionsMocks(); - $wpProxy = new WordpressFunctionProxyHelper(); + // add_action/add_filter are stubbed out: SmartlingCore::__construct() registers real + // WordPress hooks bound to $this, and under a real WP bootstrap (as used when this + // suite runs alongside the integration tests) those hooks leak into global state for + // the rest of the process, firing against this un-DI-wired instance in later tests. + $wpProxy = $this->getMockBuilder(WordpressFunctionProxyHelper::class) + ->onlyMethods(['add_action', 'add_filter']) + ->getMock(); $acf = $this->createMock(AcfDynamicSupport::class); $gutenbergBlockHelper = new GutenbergBlockHelper( $acf, @@ -474,7 +480,7 @@ private function buildCoreForSendForTranslation( ?SubmissionManager $submissionManager = null, ): SmartlingCore|\PHPUnit\Framework\MockObject\MockObject { $settingsManager = $this->createMock(SettingsManager::class); - $settingsManager->method('getSingleSettingsProfile')->willReturn($profile); + $settingsManager->method('getProfileBySubmission')->willReturn($profile); $submissionManager ??= $this->createMock(SubmissionManager::class); diff --git a/tests/Smartling/Base/SmartlingCoreUploadTraitTest.php b/tests/Smartling/Base/SmartlingCoreUploadTraitTest.php index f0ad36b4c..5b5e88446 100644 --- a/tests/Smartling/Base/SmartlingCoreUploadTraitTest.php +++ b/tests/Smartling/Base/SmartlingCoreUploadTraitTest.php @@ -105,7 +105,7 @@ public function testApplyXmlNoCleanMetadata() $fieldsFilterHelper->method('applyTranslatedValues')->willReturnArgument(2); $settingsManager = $this->getMockBuilder(SettingsManager::class)->disableOriginalConstructor()->getMock(); - $settingsManager->method('getSingleSettingsProfile')->willReturn($this->createMock(ConfigurationProfileEntity::class)); + $settingsManager->method('getProfileBySubmission')->willReturn($this->createMock(ConfigurationProfileEntity::class)); $submissionManager = $this->getMockBuilder(SubmissionManager::class)->disableOriginalConstructor()->getMock(); $submissionManager->method('storeEntity')->willReturnArgument(0); @@ -143,7 +143,7 @@ public function testApplyXmlCleanMetadata() $profile->method('getFilterSkipArray')->willReturn(['excluded']); $settingsManager = $this->getMockBuilder(SettingsManager::class)->disableOriginalConstructor()->getMock(); - $settingsManager->method('getSingleSettingsProfile')->willReturn($profile); + $settingsManager->method('getProfileBySubmission')->willReturn($profile); $submissionManager = $this->getMockBuilder(SubmissionManager::class)->disableOriginalConstructor()->getMock(); $submissionManager->method('storeEntity')->willReturnArgument(0); @@ -297,7 +297,7 @@ public function testApplyXmlLockedBlocksById() $profile = $this->getMockBuilder(ConfigurationProfileEntity::class)->disableOriginalConstructor()->getMock(); $settingsManager = $this->getMockBuilder(SettingsManager::class)->disableOriginalConstructor()->getMock(); - $settingsManager->method('getSingleSettingsProfile')->willReturn($profile); + $settingsManager->method('getProfileBySubmission')->willReturn($profile); $submissionManager = $this->getMockBuilder(SubmissionManager::class)->disableOriginalConstructor()->getMock(); $submissionManager->method('storeEntity')->willReturnArgument(0); diff --git a/tests/Smartling/DbAl/UploadQueueManagerTest.php b/tests/Smartling/DbAl/UploadQueueManagerTest.php index b4481dc83..54f9590a1 100644 --- a/tests/Smartling/DbAl/UploadQueueManagerTest.php +++ b/tests/Smartling/DbAl/UploadQueueManagerTest.php @@ -8,6 +8,7 @@ use Smartling\Exception\SmartlingDbException; use Smartling\Models\IntegerIterator; use Smartling\Models\UploadQueueEntity; +use Smartling\Settings\ConfigurationProfileEntity; use Smartling\Settings\SettingsManager; use Smartling\Submissions\SubmissionEntity; use Smartling\Submissions\SubmissionManager; @@ -112,6 +113,72 @@ public function query() {} ))->enqueue(new IntegerIterator([1, 2, 3, 4, 7]), ''); // Submission with id 7 does not exist, and should not be stored } + public function testPurgeUsesDistinctProfilePerSubmissionSharingSourceBlog() + { + $profileA = $this->createMock(ConfigurationProfileEntity::class); + $profileB = $this->createMock(ConfigurationProfileEntity::class); + + $submission1 = $this->createMock(SubmissionEntity::class); + $submission1->method('getId')->willReturn(1); + $submission1->method('getSourceBlogId')->willReturn(1); + $submission1->method('getConfigurationProfileId')->willReturn(100); + $submission1->method('getFileUri')->willReturn('file1.xml'); + + $submission2 = $this->createMock(SubmissionEntity::class); + $submission2->method('getId')->willReturn(2); + $submission2->method('getSourceBlogId')->willReturn(1); // same source blog as submission1 + $submission2->method('getConfigurationProfileId')->willReturn(200); // different profile + $submission2->method('getFileUri')->willReturn('file2.xml'); + + $stored = [$submission1, $submission2]; + + $submissionManager = $this->createMock(SubmissionManager::class); + $submissionManager->method('getEntityById')->willReturnCallback(function ($id) use ($stored) { + foreach ($stored as $submission) { + if ($submission->getId() === $id) { + return $submission; + } + } + return null; + }); + + $settingsManager = $this->createMock(SettingsManager::class); + $settingsManager->method('getProfileBySubmission')->willReturnCallback( + function (SubmissionEntity $submission) use ($submission1, $profileA, $profileB) { + return $submission === $submission1 ? $profileA : $profileB; + }, + ); + + $this->mockDbAl(); + $db = $this->getMockBuilder(DB::class) + ->setConstructorArgs([new class { + public string $base_prefix = ''; + public function getResultsArray() {} + public function query() {} + }]) + ->onlyMethods(['getResultsArray', 'query']) + ->getMock(); + $db->method('getResultsArray')->willReturn([ + ['id' => 1, 'batch_uid' => 'batch-1', 'submission_ids' => '1,2'], + ]); + + $cancelledWith = []; + $apiWrapper = $this->createMock(ApiWrapperInterface::class); + $apiWrapper->method('cancelBatchFile')->willReturnCallback( + function (ConfigurationProfileEntity $profile, string $batchUid, string $fileUri) use (&$cancelledWith) { + $cancelledWith[] = [$profile, $batchUid, $fileUri]; + }, + ); + + (new UploadQueueManager($apiWrapper, $settingsManager, $db, $submissionManager))->purge(); + + $this->assertCount(2, $cancelledWith, 'Expected cancelBatchFile to be called once per submission'); + $this->assertSame($profileA, $cancelledWith[0][0], 'Submission 1 must be cancelled against its own stamped profile'); + $this->assertSame('file1.xml', $cancelledWith[0][2]); + $this->assertSame($profileB, $cancelledWith[1][0], 'Submission 2 must be cancelled against its own stamped profile, not submission 1\'s cached one'); + $this->assertSame('file2.xml', $cancelledWith[1][2]); + } + public function testDequeue() { $submission1 = $this->createMock(SubmissionEntity::class); diff --git a/tests/Smartling/FTS/FtsApiWrapperTest.php b/tests/Smartling/FTS/FtsApiWrapperTest.php index 4ceaf494b..e4c478035 100644 --- a/tests/Smartling/FTS/FtsApiWrapperTest.php +++ b/tests/Smartling/FTS/FtsApiWrapperTest.php @@ -35,7 +35,7 @@ public function testUploadFileRequiresConfiguration(): void $submission->method('getSourceBlogId')->willReturn(1); $this->settingsManager - ->method('getSingleSettingsProfile') + ->method('getProfileBySubmission') ->willThrowException(new SmartlingDbException('No profile found')); $this->ftsApiWrapper->uploadFile( @@ -53,7 +53,7 @@ public function testSubmitForInstantTranslationRequiresConfiguration(): void $submission->method('getSourceBlogId')->willReturn(1); $this->settingsManager - ->method('getSingleSettingsProfile') + ->method('getProfileBySubmission') ->willThrowException(new SmartlingDbException('No profile found')); $this->ftsApiWrapper->submitForInstantTranslation( @@ -72,7 +72,7 @@ public function testPollTranslationStatusRequiresConfiguration(): void $submission->method('getSourceBlogId')->willReturn(1); $this->settingsManager - ->method('getSingleSettingsProfile') + ->method('getProfileBySubmission') ->willThrowException(new SmartlingDbException('No profile found')); $this->ftsApiWrapper->pollTranslationStatus( @@ -90,7 +90,7 @@ public function testDownloadTranslatedFileRequiresConfiguration(): void $submission->method('getSourceBlogId')->willReturn(1); $this->settingsManager - ->method('getSingleSettingsProfile') + ->method('getProfileBySubmission') ->willThrowException(new SmartlingDbException('No profile found')); $this->ftsApiWrapper->downloadTranslatedFile( diff --git a/tests/Smartling/FTS/FtsServiceTest.php b/tests/Smartling/FTS/FtsServiceTest.php index 50493c391..8db1a4568 100644 --- a/tests/Smartling/FTS/FtsServiceTest.php +++ b/tests/Smartling/FTS/FtsServiceTest.php @@ -173,7 +173,7 @@ public function testCheckAndApplyTranslationWithCompletedState(): void $profile->method('getSmartlingLocale')->willReturn('de-DE'); $this->settingsManager - ->method('getSingleSettingsProfile') + ->method('getProfileBySubmission') ->willReturn($profile); $this->ftsApiWrapper @@ -329,7 +329,7 @@ public function testCheckAndApplyTranslationWithDownloadException(): void $profile->method('getSmartlingLocale')->willReturn('de-DE'); $this->settingsManager - ->method('getSingleSettingsProfile') + ->method('getProfileBySubmission') ->willReturn($profile); $this->ftsApiWrapper diff --git a/tests/Smartling/Jobs/UploadJobTest.php b/tests/Smartling/Jobs/UploadJobTest.php index 86ffbbaaf..642c008a6 100644 --- a/tests/Smartling/Jobs/UploadJobTest.php +++ b/tests/Smartling/Jobs/UploadJobTest.php @@ -125,6 +125,31 @@ public function testClonedSubmissionIsSkippedAndCompletesQueueItemWithoutUploadi $this->assertFalse($uploaded, 'Cloned submissions must not be uploaded'); } + /** + * Existing submissions are loaded from the queue by id and never pass through + * SubmissionManager::getSubmissionEntity(), so the profile has to be stored on upload. + */ + public function testStoresConfigurationProfileOfExistingSubmissionOnUpload() + { + $submission = new SubmissionEntity(); + $submission->setId(1); + $submission->setFileUri('file.xml'); + $submission->setSourceBlogId(1); + $item = $this->buildItem($submission); + + $submissionManager = $this->createMock(SubmissionManager::class); + $submissionManager->method('stampConfigurationProfile')->willReturnCallback( + static function (SubmissionEntity $submission) { + $submission->setConfigurationProfileId(5); + }, + ); + $submissionManager->expects($this->once())->method('storeEntity')->with($submission); + + $this->buildJob($this->buildQueueManager($item), $submissionManager)->run(''); + + $this->assertSame(5, $submission->getConfigurationProfileId()); + } + /** * A queue item groups submissions for the same content across multiple target * locales; only the first one is used to look up the profile/batch job. If either @@ -255,9 +280,9 @@ private function buildJob( ): UploadJob { $settingsManager = $this->createMock(SettingsManager::class); if ($onGetSingleSettingsProfile !== null) { - $settingsManager->method('getSingleSettingsProfile')->willReturnCallback($onGetSingleSettingsProfile); + $settingsManager->method('getProfileBySubmission')->willReturnCallback($onGetSingleSettingsProfile); } else { - $settingsManager->method('getSingleSettingsProfile') + $settingsManager->method('getProfileBySubmission') ->willReturn($this->createMock(ConfigurationProfileEntity::class)); } $settingsManager->method('getActiveProfile') diff --git a/tests/Smartling/Settings/SettingsManagerTest.php b/tests/Smartling/Settings/SettingsManagerTest.php index 59a12be8c..b0f99e5a9 100644 --- a/tests/Smartling/Settings/SettingsManagerTest.php +++ b/tests/Smartling/Settings/SettingsManagerTest.php @@ -8,8 +8,10 @@ use Smartling\Exception\SmartlingConfigException; use Smartling\Exception\SmartlingDbException; use Smartling\Settings\ConfigurationProfileEntity; +use Smartling\Settings\Locale; use Smartling\Settings\SettingsManager; use Smartling\Settings\TargetLocale; +use Smartling\Submissions\SubmissionEntity; use Smartling\Tests\Traits\SettingsManagerMock; class SettingsManagerTest extends TestCase @@ -87,6 +89,70 @@ public function testGetProfileTargetBlogIdsByMainBlogIdWithConfigException() $mock->getProfileTargetBlogIdsByMainBlogId(5); } + private function profileWithId(int $id): ConfigurationProfileEntity + { + $profile = new ConfigurationProfileEntity(); + $profile->setId($id); + + return $profile; + } + + public function testGetProfileBySubmissionUsesStoredProfile() + { + $stored = $this->profileWithId(7); + $sourceLocale = new Locale(); + $sourceLocale->setBlogId(1); + $stored->setSourceLocale($sourceLocale); + $mock = $this->createPartialMock(SettingsManager::class, ['getSingleSettingsProfile', 'getEntityById']); + $mock->expects(self::once())->method('getEntityById')->with(7)->willReturn([$stored]); + $mock->expects(self::never())->method('getSingleSettingsProfile'); + + $submission = (new SubmissionEntity())->setSourceBlogId(1)->setConfigurationProfileId(7); + + self::assertSame($stored, $mock->getProfileBySubmission($submission)); + } + + public function testGetProfileBySubmissionLogsWarningWhenStoredProfileBlogMismatches() + { + $stored = $this->profileWithId(7); + $sourceLocale = new Locale(); + $sourceLocale->setBlogId(2); + $stored->setSourceLocale($sourceLocale); + $mock = $this->createPartialMock(SettingsManager::class, ['getSingleSettingsProfile', 'getEntityById', 'getLogger']); + $mock->method('getLogger')->willReturn(new NullLogger()); + $mock->expects(self::once())->method('getEntityById')->with(7)->willReturn([$stored]); + $mock->expects(self::never())->method('getSingleSettingsProfile'); + + $submission = (new SubmissionEntity())->setSourceBlogId(1)->setConfigurationProfileId(7); + + self::assertSame($stored, $mock->getProfileBySubmission($submission)); + } + + public function testGetProfileBySubmissionFallsBackWithoutStoredProfile() + { + $active = $this->profileWithId(3); + $mock = $this->createPartialMock(SettingsManager::class, ['getSingleSettingsProfile', 'getEntityById']); + $mock->expects(self::never())->method('getEntityById'); + $mock->expects(self::once())->method('getSingleSettingsProfile')->with(1)->willReturn($active); + + $submission = (new SubmissionEntity())->setSourceBlogId(1); + + self::assertSame($active, $mock->getProfileBySubmission($submission)); + } + + public function testGetProfileBySubmissionFallsBackWhenStoredProfileWasDeleted() + { + $active = $this->profileWithId(3); + $mock = $this->createPartialMock(SettingsManager::class, ['getSingleSettingsProfile', 'getEntityById', 'getLogger']); + $mock->method('getLogger')->willReturn(new NullLogger()); + $mock->expects(self::once())->method('getEntityById')->with(7)->willReturn([]); + $mock->expects(self::once())->method('getSingleSettingsProfile')->with(1)->willReturn($active); + + $submission = (new SubmissionEntity())->setSourceBlogId(1)->setConfigurationProfileId(7); + + self::assertSame($active, $mock->getProfileBySubmission($submission)); + } + public function testGetEntitiesQueries() { $db = $this->createMock(SmartlingToCMSDatabaseAccessWrapperInterface::class); diff --git a/tests/Smartling/Submissions/SubmissionManagerTest.php b/tests/Smartling/Submissions/SubmissionManagerTest.php index 21d61e496..325b9849c 100644 --- a/tests/Smartling/Submissions/SubmissionManagerTest.php +++ b/tests/Smartling/Submissions/SubmissionManagerTest.php @@ -14,6 +14,9 @@ use Smartling\Jobs\JobManager; use Smartling\Jobs\SubmissionsJobsManager; use Smartling\Submissions\SubmissionEntity; +use Smartling\Exception\SmartlingDbException; +use Smartling\Settings\ConfigurationProfileEntity; +use Smartling\Settings\SettingsManager; use Smartling\Submissions\SubmissionManager; class SubmissionManagerTest extends TestCase @@ -132,6 +135,64 @@ public function testStoreEntityQuery() $x->storeEntity($entity); } + private function profileWithId(int $id): ConfigurationProfileEntity + { + $profile = new ConfigurationProfileEntity(); + $profile->setId($id); + + return $profile; + } + + private function getManagerForProfileStamping(SettingsManager $settingsManager, array $found): SubmissionManager + { + $x = $this->getMockBuilder(SubmissionManager::class)->setConstructorArgs([ + $this->db, + 20, + $this->createMock(JobManager::class), + $this->createMock(LocalizationPluginProxyInterface::class), + $this->createMock(SiteHelper::class), + $this->createMock(SubmissionsJobsManager::class), + $settingsManager, + ])->onlyMethods(['find', 'getLogger'])->getMock(); + $x->method('getLogger')->willReturn(new \Smartling\Vendor\Psr\Log\NullLogger()); + $x->method('find')->willReturn($found); + + return $x; + } + + public function testGetSubmissionEntityStoresProfileOnNewSubmission() + { + $settingsManager = $this->createMock(SettingsManager::class); + $settingsManager->expects($this->once())->method('getSingleSettingsProfile')->with(1) + ->willReturn($this->profileWithId(9)); + + $entity = $this->getManagerForProfileStamping($settingsManager, [])->getSubmissionEntity('post', 1, 5, 2); + + $this->assertSame(9, $entity->getConfigurationProfileId()); + } + + public function testGetSubmissionEntityRefreshesProfileOnExistingSubmission() + { + $settingsManager = $this->createMock(SettingsManager::class); + $settingsManager->method('getSingleSettingsProfile')->willReturn($this->profileWithId(9)); + $existing = (new SubmissionEntity())->setSourceBlogId(1)->setConfigurationProfileId(4); + + $entity = $this->getManagerForProfileStamping($settingsManager, [$existing])->getSubmissionEntity('post', 1, 5, 2); + + $this->assertSame(9, $entity->getConfigurationProfileId()); + } + + public function testGetSubmissionEntityKeepsProfileWhenNoActiveProfile() + { + $settingsManager = $this->createMock(SettingsManager::class); + $settingsManager->method('getSingleSettingsProfile')->willThrowException(new SmartlingDbException('none')); + $existing = (new SubmissionEntity())->setSourceBlogId(1)->setConfigurationProfileId(4); + + $entity = $this->getManagerForProfileStamping($settingsManager, [$existing])->getSubmissionEntity('post', 1, 5, 2); + + $this->assertSame(4, $entity->getConfigurationProfileId()); + } + public function testUpdateEntityQuery() { $title = 'Test'; @@ -139,7 +200,7 @@ public function testUpdateEntityQuery() $submissionId = 17; $db = $this->db; $db->expects($this->once())->method('query') - ->with("UPDATE `wp_smartling_submissions` SET `source_title` = '$title', `source_blog_id` = '$sourceBlogId', `source_content_hash` = '', `content_type` = '', `source_id` = '', `file_uri` = '', `target_locale` = '', `target_blog_id` = '', `target_id` = '', `submitter` = '', `submission_date` = '', `applied_date` = '', `approved_string_count` = '', `completed_string_count` = '', `excluded_string_count` = '', `total_string_count` = '', `word_count` = '', `status` = '', `is_locked` = '', `is_cloned` = '', `last_modified` = '', `outdated` = '', `last_error` = '', `locked_fields` = '', `created_at` = '' WHERE ( `id` = '$submissionId' ) LIMIT 1") + ->with("UPDATE `wp_smartling_submissions` SET `source_title` = '$title', `source_blog_id` = '$sourceBlogId', `source_content_hash` = '', `content_type` = '', `source_id` = '', `file_uri` = '', `target_locale` = '', `target_blog_id` = '', `target_id` = '', `submitter` = '', `submission_date` = '', `applied_date` = '', `approved_string_count` = '', `completed_string_count` = '', `excluded_string_count` = '', `total_string_count` = '', `word_count` = '', `status` = '', `is_locked` = '', `is_cloned` = '', `last_modified` = '', `outdated` = '', `last_error` = '', `locked_fields` = '', `created_at` = '', `configuration_profile_id` = '' WHERE ( `id` = '$submissionId' ) LIMIT 1") ->willReturn(true); $x = $this->subject; $x->method('getDbal')->willReturn($db); @@ -172,9 +233,10 @@ public function testDeleteEntityQuery() $this->createMock(LocalizationPluginProxyInterface::class), $this->createMock(SiteHelper::class), $submissionsJobsManager, + $this->createMock(SettingsManager::class), ])->onlyMethods(['fetchData', 'getLogger'])->getMock(); $x->method('getLogger')->willReturn(new NullLogger()); - $x->expects($this->once())->method('fetchData')->with("SELECT s.id, s.source_title, s.source_blog_id, s.source_content_hash, s.content_type, s.source_id, s.file_uri, s.target_locale, s.target_blog_id, s.target_id, s.submitter, s.submission_date, s.applied_date, s.approved_string_count, s.completed_string_count, s.excluded_string_count, s.total_string_count, s.word_count, s.status, s.is_locked, s.is_cloned, s.last_modified, s.outdated, s.last_error, s.locked_fields, s.created_at, j.job_name, j.job_uid, j.project_uid, j.created, j.modified FROM wp_smartling_submissions AS s\n LEFT JOIN wp_smartling_submissions_jobs AS sj ON s.id = sj.submission_id\n LEFT JOIN wp_smartling_jobs AS j ON sj.job_id = j.id WHERE ( ( s.id IN('$submissionId') ) )")->willReturn([$entity]); + $x->expects($this->once())->method('fetchData')->with("SELECT s.id, s.source_title, s.source_blog_id, s.source_content_hash, s.content_type, s.source_id, s.file_uri, s.target_locale, s.target_blog_id, s.target_id, s.submitter, s.submission_date, s.applied_date, s.approved_string_count, s.completed_string_count, s.excluded_string_count, s.total_string_count, s.word_count, s.status, s.is_locked, s.is_cloned, s.last_modified, s.outdated, s.last_error, s.locked_fields, s.created_at, s.configuration_profile_id, j.job_name, j.job_uid, j.project_uid, j.created, j.modified FROM wp_smartling_submissions AS s\n LEFT JOIN wp_smartling_submissions_jobs AS sj ON s.id = sj.submission_id\n LEFT JOIN wp_smartling_jobs AS j ON sj.job_id = j.id WHERE ( ( s.id IN('$submissionId') ) )")->willReturn([$entity]); $x->delete($entity); } diff --git a/tests/Traits/SubmissionManagerMock.php b/tests/Traits/SubmissionManagerMock.php index b23edf335..3ebea3cf4 100644 --- a/tests/Traits/SubmissionManagerMock.php +++ b/tests/Traits/SubmissionManagerMock.php @@ -8,6 +8,7 @@ use Smartling\Helpers\SiteHelper; use Smartling\Jobs\JobManager; use Smartling\Jobs\SubmissionsJobsManager; +use Smartling\Settings\SettingsManager; use Smartling\Submissions\SubmissionManager; trait SubmissionManagerMock @@ -33,7 +34,8 @@ private function mockSubmissionManager(SmartlingToCMSDatabaseAccessWrapperInterf $this->createMock(JobManager::class), $this->createMock(LocalizationPluginProxyInterface::class), $this->createMock(SiteHelper::class), - $this->createMock(SubmissionsJobsManager::class) + $this->createMock(SubmissionsJobsManager::class), + $this->createMock(SettingsManager::class), ]) ->getMock(); } diff --git a/tests/playwright/bulk-submit.spec.js b/tests/playwright/bulk-submit.spec.js new file mode 100644 index 000000000..26beb58ce --- /dev/null +++ b/tests/playwright/bulk-submit.spec.js @@ -0,0 +1,103 @@ +/** + * Integration test for the bulk-submit "create submissions" flow. + * + * Regression coverage: bulk submit used to always fail with + * {"status":"FAILED","response":{"key":"content.submission.failed", + * "message":"Source content id is empty, please save content prior to uploading"}} + * because the bulk-submit UI sends an empty `source.id` array (the selected + * content ids travel in `ids` instead), while + * UserTranslationRequest::fromArray() unconditionally required `source.id[0]` + * before ever looking at `ids`/isBulk(). See UserTranslationRequest::fromArray() + * and UserCloneRequest::getSourceId(). + * + * This test drives the real bulk-submit page end to end (select a content row, + * create a job, submit) and inspects the actual admin-ajax.php network traffic, + * rather than asserting on rendered UI text — the front end swallows the + * server's error details (jQuery rejects on the AJAX call's HTTP 400 and the + * catch handler falls back to a generic message), so the network response body + * is the only place the regression signature is visible. + */ +const { test, expect } = require('@playwright/test'); + +// Real calls to the Smartling API (job creation, batch creation) plus a +// possibly-cold React mount (see job-wizard.spec.js) can comfortably exceed +// the default 120 s test timeout. +test.setTimeout(240000); + +test.describe('Bulk submit — create submissions', () => { + test('submitting a bulk selection does not fail with "Source content id is empty"', async ({ page }) => { + await page.goto('/wp-admin/admin.php?page=smartling-bulk-submit', { waitUntil: 'commit' }); + await page.waitForSelector('#smartling-app', { state: 'attached', timeout: 90000 }); + + const app = page.locator('#smartling-app'); + + // Wait for the React job wizard to mount (tabs rendered) and for the + // bulk-submit table rows (with their per-row checkboxes) to be present. + await page.waitForFunction( + () => { + const el = document.getElementById('smartling-app'); + return el && ( + el.querySelector('[role="tablist"]') !== null || + el.querySelector('.components-tab-panel__tabs') !== null + ); + }, + null, + { timeout: 90000 }, + ); + const rowCheckbox = page.locator('input.bulkaction[type="checkbox"]').first(); + await expect(rowCheckbox, 'Bulk submit table must list at least one content row to select').toBeVisible({ timeout: 30000 }); + + // Checkbox id is "{contentId}-{contentType}" (see BulkSubmitTableWidget::column_cb()). + const checkboxId = await rowCheckbox.getAttribute('id'); + const [expectedContentId] = checkboxId.split('-'); + await rowCheckbox.check(); + + // Default tab is "New Job" — fill the required Name field. + await app.getByLabel('Name', { exact: true }).fill(`Playwright bulk submit ${Date.now()}`); + + // Select a target locale (submit button stays disabled without one). + const targetLocalesFieldset = app.locator('fieldset', { hasText: 'Target Locales' }); + const localeCheckbox = targetLocalesFieldset.locator('input[type="checkbox"]').first(); + await expect(localeCheckbox, 'Profile must have at least one enabled target locale').toBeVisible({ timeout: 15000 }); + await localeCheckbox.check(); + + const isCreateSubmissions = (url, body) => + url.includes('admin-ajax.php') && url.includes('action=smartling-create-submissions'); + const isCreateJob = (url, body) => + url.includes('admin-ajax.php') && (body || '').includes('innerAction=create-job'); + + const [jobResponse, submissionResponse] = await Promise.all([ + page.waitForResponse((r) => isCreateJob(r.url(), r.request().postData()), { timeout: 120000 }), + page.waitForResponse((r) => isCreateSubmissions(r.url(), r.request().postData()), { timeout: 120000 }), + app.getByRole('button', { name: 'Create Job' }).click(), + ]); + + const jobBody = await jobResponse.json(); + expect(jobBody.status, `Job creation failed: ${JSON.stringify(jobBody)}`).toBe(200); + + const submissionRequestBody = submissionResponse.request().postData() || ''; + const submissionParams = new URLSearchParams(submissionRequestBody); + expect( + submissionParams.getAll('ids[]'), + 'Bulk submit must send the selected content id via `ids[]`', + ).toContain(expectedContentId); + expect( + submissionParams.has('source[id][]'), + 'Bulk submit must NOT send a populated source.id — bulk content ids travel in `ids` only', + ).toBe(false); + + const submissionBody = await submissionResponse.json(); + + // The exact regression: bulk submit must never fail because source + // content id is considered empty. + if (submissionBody.status === 'FAILED') { + expect( + submissionBody.response?.message, + `Regression: bulk submit failed with the "empty source id" error: ${JSON.stringify(submissionBody)}`, + ).not.toContain('Source content id is empty'); + } + + expect(submissionResponse.status(), `Unexpected AJAX status, body: ${JSON.stringify(submissionBody)}`).toBe(200); + expect(submissionBody.status, `Unexpected response body: ${JSON.stringify(submissionBody)}`).toBe('SUCCESS'); + }); +}); diff --git a/tests/setup-local-test-db.sh b/tests/setup-local-test-db.sh new file mode 100755 index 000000000..3af90ff99 --- /dev/null +++ b/tests/setup-local-test-db.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# +# Setup script for local integration test database. +# +# Run once (or when you want a clean slate). Creates the wordpress_test database +# by importing the production wordpress database schema and data, then renames +# all tables from the production prefix (wp_) to the test prefix (wptests_). +# +# Also creates tests/wp-test-install/ — a minimal WordPress directory with its +# own wp-config.php pointing to the test database, used by wp-cli during tests +# (for cron execution and db:query calls). +# +# Usage: +# cp tests/.env.local.example tests/.env.local +# # Fill in your values in tests/.env.local +# bash tests/setup-local-test-db.sh +# +# This is a personal convenience script for local development, not shared tooling: +# it dumps a developer's own "production" WordPress database into the test database. +# All paths are read from tests/.env.local; there is no expectation it works unmodified +# on another contributor's machine. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" +ENV_FILE="$SCRIPT_DIR/.env.local" + +if [ ! -f "$ENV_FILE" ]; then + echo "ERROR: $ENV_FILE not found." + echo "Copy tests/.env.local.example to tests/.env.local and fill in your values." + exit 1 +fi + +# Load env vars +set -a +source "$ENV_FILE" +set +a + +# Defaults +WP_DB_USER="${WP_DB_USER:-root}" +WP_DB_PASS="${WP_DB_PASS:-}" +WP_DB_HOST="${WP_DB_HOST:-127.0.0.1}" +WP_DB_NAME="${WP_DB_NAME:-wordpress_test}" +WP_DB_TABLE_PREFIX="${WP_DB_TABLE_PREFIX:-wptests_}" +if [ -z "$WP_INSTALL_DIR" ]; then + echo "ERROR: WP_INSTALL_DIR not set in $ENV_FILE." + echo "See tests/.env.local.example for the expected value." + exit 1 +fi +WPCLI_PATH="${WPCLI_PATH:-$SCRIPT_DIR/wp-test-install}" +SOURCE_DB="${SOURCE_DB:-wordpress}" +SOURCE_PREFIX="${SOURCE_PREFIX:-wp_}" + +MYSQL_ARGS="-u $WP_DB_USER -h $WP_DB_HOST" +if [ -n "$WP_DB_PASS" ]; then + MYSQL_ARGS="$MYSQL_ARGS -p$WP_DB_PASS" +fi + +echo "=== Step 1: Create test database '$WP_DB_NAME' ===" +mysql $MYSQL_ARGS -e "DROP DATABASE IF EXISTS \`$WP_DB_NAME\`; CREATE DATABASE \`$WP_DB_NAME\` CHARACTER SET utf8 COLLATE utf8_unicode_ci;" + +echo "=== Step 2: Import production schema and data from '$SOURCE_DB' ===" +# --set-gtid-purged=OFF: required for MySQL servers with GTID mode enabled (MySQL 8+) +# --single-transaction: consistent snapshot without locking tables +mysqldump $MYSQL_ARGS --set-gtid-purged=OFF --single-transaction "$SOURCE_DB" | mysql $MYSQL_ARGS "$WP_DB_NAME" + +echo "=== Step 3: Rename tables from '${SOURCE_PREFIX}' prefix to '${WP_DB_TABLE_PREFIX}' prefix ===" +# Build and execute RENAME TABLE statements dynamically +RENAME_SQL=$(mysql $MYSQL_ARGS -N "$WP_DB_NAME" -e " + SELECT CONCAT('RENAME TABLE \`', TABLE_NAME, '\` TO \`', REPLACE(TABLE_NAME, '${SOURCE_PREFIX}', '${WP_DB_TABLE_PREFIX}'), '\`;') + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = '${WP_DB_NAME}' + ORDER BY TABLE_NAME; +") + +if [ -z "$RENAME_SQL" ]; then + echo "ERROR: No tables found to rename in $WP_DB_NAME. Import may have failed." + exit 1 +fi + +echo "$RENAME_SQL" | mysql $MYSQL_ARGS "$WP_DB_NAME" + +echo "=== Step 4: Verify tables ===" +TABLE_COUNT=$(mysql $MYSQL_ARGS -N "$WP_DB_NAME" -e "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = '$WP_DB_NAME';") +echo "Tables in $WP_DB_NAME: $TABLE_COUNT" + +echo "=== Step 5: Create test WordPress install dir at '$WPCLI_PATH' ===" +mkdir -p "$WPCLI_PATH" + +# Create wp-config.php pointing to the test database +cat > "$WPCLI_PATH/wp-config.php" << WPCONFIG +