diff --git a/inc/Smartling/Base/SmartlingEntityAbstract.php b/inc/Smartling/Base/SmartlingEntityAbstract.php index d63a53afc..270bd5897 100644 --- a/inc/Smartling/Base/SmartlingEntityAbstract.php +++ b/inc/Smartling/Base/SmartlingEntityAbstract.php @@ -14,6 +14,7 @@ abstract class SmartlingEntityAbstract implements SmartlingTableDefinitionInterf public const DB_TYPE_U_BIGINT = 'INT(20) UNSIGNED NOT NULL'; // BIGINT alias of INT(20) 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'; public const DB_TYPE_STRING_64 = 'VARCHAR(64) NOT NULL'; public const DB_TYPE_STRING_SMALL = 'VARCHAR(16) NOT NULL'; diff --git a/inc/Smartling/DbAl/Migrations/Migration260825.php b/inc/Smartling/DbAl/Migrations/Migration260825.php new file mode 100644 index 000000000..3fc70be58 --- /dev/null +++ b/inc/Smartling/DbAl/Migrations/Migration260825.php @@ -0,0 +1,50 @@ +completeTableName(UploadQueueEntity::getTableName()); + + // Migration240315 (re)creates this table with `CREATE TABLE IF NOT EXISTS` from the + // current, evolving UploadQueueEntity::getFieldDefinitions(). A site upgrading from a + // schema version older than 240315 runs that migration first, and it already creates + // the table with the columns below, so blindly adding them here would fail with a + // duplicate column error. Only add what isn't already there. + $existingColumns = $db->getColumnArray("SHOW COLUMNS FROM `$tableName`"); + + $columns = [ + UploadQueueEntity::FIELD_CLAIMED => SmartlingEntityAbstract::DB_TYPE_DATETIME_NULL, + UploadQueueEntity::FIELD_ATTEMPTS => SmartlingEntityAbstract::DB_TYPE_U_BIGINT . ' ' . SmartlingEntityAbstract::DB_TYPE_DEFAULT_ZERO, + ]; + + $queries = []; + foreach ($columns as $column => $definition) { + if (!in_array($column, $existingColumns, true)) { + $queries[] = sprintf('ALTER TABLE `%s` ADD COLUMN `%s` %s', $tableName, $column, $definition); + } + } + + return $queries; + } +} diff --git a/inc/Smartling/DbAl/UploadQueueManager.php b/inc/Smartling/DbAl/UploadQueueManager.php index f0e49d94d..5e393a9c5 100644 --- a/inc/Smartling/DbAl/UploadQueueManager.php +++ b/inc/Smartling/DbAl/UploadQueueManager.php @@ -23,6 +23,11 @@ class UploadQueueManager { use LoggerSafeTrait; + + public const MAX_ATTEMPTS = 3; + + public const STALE_CLAIM_SECONDS = 900; + private string $tableName; public function __construct( private ApiWrapperInterface $api, @@ -42,13 +47,27 @@ public function count(): int public function dequeue(int $blogId): ?UploadQueueItem { - // Get queue items with first submission having its source blog id = $blogId. - // It's impossible to create a single queue item with submissions from multiple source blog ids, + // Get queue items with the first submission having its source blog id = $blogId. + // It should be impossible to create a single queue item with submissions from multiple source blog ids, // so only checking one is enough. + $staleClaimCondition = new ConditionBlock(ConditionBuilder::CONDITION_BLOCK_LEVEL_OPERATOR_OR); + $staleClaimCondition->addCondition(new Condition( + ConditionBuilder::CONDITION_IS_NULL, + 'q.' . UploadQueueEntity::FIELD_CLAIMED, + [], + false, + )); + $staleClaimCondition->addCondition(new Condition( + ConditionBuilder::CONDITION_SIGN_LESS, + 'q.' . UploadQueueEntity::FIELD_CLAIMED, + $this->getStaleClaimThreshold(), + false, + )); + $query = sprintf(<<<'SQL' -select q.%1$s, q.%2$s, q.%3$s from %7$s q left join %8$s s +select q.%1$s, q.%2$s, q.%3$s, q.%9$s, q.%10$s from %7$s q left join %8$s s on if(locate(',', q.%2$s), left(%2$s, locate(',', %2$s) - 1), %2$s) = s.%4$s - where s.%5$s = %6$d + where s.%5$s = %6$d and %11$s SQL, UploadQueueEntity::FIELD_ID, UploadQueueEntity::FIELD_SUBMISSION_IDS, @@ -58,32 +77,122 @@ public function dequeue(int $blogId): ?UploadQueueItem $blogId, $this->db->completeTableName(UploadQueueEntity::getTableName()), $this->db->completeTableName(SubmissionEntity::getTableName()), + UploadQueueEntity::FIELD_CLAIMED, + UploadQueueEntity::FIELD_ATTEMPTS, + $staleClaimCondition, ); while (($row = $this->db->getRowArray($query)) !== null) { - $this->delete($row[UploadQueueEntity::FIELD_ID]); + $queueId = (int)$row[UploadQueueEntity::FIELD_ID]; + $attempts = (int)($row[UploadQueueEntity::FIELD_ATTEMPTS] ?? 0); $locales = new IntStringPairCollection(); - $submissions = []; + $existingSubmissions = []; + $unprocessable = false; foreach (IntegerIterator::fromString($row[UploadQueueEntity::FIELD_SUBMISSION_IDS]) as $submissionId) { - $submission = $this->submissionManager->getEntityById($submissionId); - if ($submission === null) { - continue 2; + try { + $submission = $this->submissionManager->getEntityById($submissionId); + if ($submission === null) { + $this->getLogger()->warning("Discarding upload queue item id=$queueId: submissionId=$submissionId no longer exists"); + $unprocessable = true; + continue; + } + + $existingSubmissions[] = $submission; + + $locale = $this->getSmartlingLocale($submission); + if ($locale === null) { + $this->getLogger()->warning("Discarding upload queue item id=$queueId: unable to resolve target locale for submissionId=$submissionId, targetBlogId={$submission->getTargetBlogId()}"); + $unprocessable = true; + continue; + } + + $locales = $locales->add([new IntStringPair($submission->getId(), $locale)]); + } catch (\Throwable $e) { + $this->getLogger()->warning("Discarding upload queue item id=$queueId: failed to resolve submissionId=$submissionId: {$e->getMessage()}"); + $unprocessable = true; } + } - $locale = $this->getSmartlingLocale($submission); - if ($locale === null) { - continue 2; + if ($unprocessable) { + if (!$this->discardQueueItem( + $queueId, + $existingSubmissions, + 'Upload queue item discarded: unable to resolve one or more submissions grouped with this item, see log for details.', + )) { + return null; } + continue; + } - $locales = $locales->add([new IntStringPair($submission->getId(), $locale)]); - $submissions[] = $submission; + if ($attempts >= self::MAX_ATTEMPTS) { + $message = sprintf( + 'Upload abandoned after %d attempts. The upload process most likely terminated unexpectedly (fatal error, timeout or out of memory) while handling this content.', + $attempts, + ); + $this->getLogger()->error("Failing upload queue item id=$queueId: $message"); + if (!$this->discardQueueItem($queueId, $existingSubmissions, $message)) { + return null; + } + continue; } - return new UploadQueueItem($submissions, $row[UploadQueueEntity::FIELD_BATCH_UID], $locales); + if (!$this->claim($queueId, $attempts)) { + $this->getLogger()->error("Failed to claim upload queue item id=$queueId"); + return null; + } + + return new UploadQueueItem($existingSubmissions, $row[UploadQueueEntity::FIELD_BATCH_UID], $locales, $queueId); } return null; } + /** + * @param SubmissionEntity[] $submissions + * @return bool Whether the row was actually removed from the queue. + */ + private function discardQueueItem(int $queueId, array $submissions, string $errorMessage): bool + { + foreach ($submissions as $submission) { + $this->submissionManager->setErrorMessage($submission, $errorMessage); + } + if (!$this->delete($queueId)) { + $this->getLogger()->error("Failed to delete upload queue item id=$queueId after discarding it"); + return false; + } + + return true; + } + + private function getStaleClaimThreshold(): string + { + return DateTimeHelper::dateTimeToString( + (new \DateTime('now', DateTimeHelper::getDefaultTimezone())) + ->modify('-' . self::STALE_CLAIM_SECONDS . ' seconds') + ); + } + + public function complete(UploadQueueItem $item): void + { + if (!$this->delete($item->getId())) { + $this->getLogger()->error("Failed to delete completed upload queue item id={$item->getId()}"); + } + } + + /** + * @return bool Whether the row was actually claimed. + */ + private function claim(int $id, int $attempts): bool + { + return $this->db->query(QueryBuilder::buildUpdateQuery( + $this->tableName, + [ + UploadQueueEntity::FIELD_CLAIMED => DateTimeHelper::nowAsString(), + UploadQueueEntity::FIELD_ATTEMPTS => $attempts + 1, + ], + $this->idCondition($id), + )) !== false; + } + public function enqueue(IntegerIterator $submissionIds, string $batchUid): void { $this->db->withTransaction(function () use ($batchUid, $submissionIds) { @@ -167,12 +276,20 @@ private function getSmartlingLocale(SubmissionEntity $submission): ?string return null; } - private function delete(int $id): void + /** + * @return bool Whether the row was actually removed. + */ + private function delete(int $id): bool + { + return $this->db->query(QueryBuilder::buildDeleteQuery($this->tableName, $this->idCondition($id))) !== false; + } + + private function idCondition(int $id): ConditionBlock { $block = new ConditionBlock(ConditionBuilder::CONDITION_BLOCK_LEVEL_OPERATOR_AND); $block->addCondition(new Condition(ConditionBuilder::CONDITION_SIGN_EQ, UploadQueueEntity::FIELD_ID, $id)); - $this->db->query(QueryBuilder::buildDeleteQuery($this->tableName, $block)); + return $block; } } diff --git a/inc/Smartling/DebugTrait.php b/inc/Smartling/DebugTrait.php index 11d7ff363..db88f85b0 100644 --- a/inc/Smartling/DebugTrait.php +++ b/inc/Smartling/DebugTrait.php @@ -88,28 +88,71 @@ public static function BacktracePrint() } /** - * Last chance to know what had happened if Wordpress is down. + * Error types that actually terminate the request. Anything else - notices, + * warnings, and in particular deprecations - is left alone: error_get_last() + * returns the last error of *any* severity, so treating non-fatal types as + * fatal reports an emergency on every otherwise healthy request and buries + * the real crashes. */ - public function shutdownHandler() + private static function fatalErrorTypes(): int { - $logger = Bootstrap::getLogger(); + return E_ERROR + | E_PARSE + | E_CORE_ERROR + | E_COMPILE_ERROR + | E_USER_ERROR + | E_RECOVERABLE_ERROR; + } - $skipLogging = E_NOTICE | E_WARNING | E_USER_NOTICE | E_USER_WARNING | E_STRICT | E_DEPRECATED; + private static function errorTypeNames(): array + { + return [ + E_ERROR => 'E_ERROR', + E_WARNING => 'E_WARNING', + E_PARSE => 'E_PARSE', + E_NOTICE => 'E_NOTICE', + E_CORE_ERROR => 'E_CORE_ERROR', + E_CORE_WARNING => 'E_CORE_WARNING', + E_COMPILE_ERROR => 'E_COMPILE_ERROR', + E_COMPILE_WARNING => 'E_COMPILE_WARNING', + E_USER_ERROR => 'E_USER_ERROR', + E_USER_WARNING => 'E_USER_WARNING', + E_USER_NOTICE => 'E_USER_NOTICE', + E_RECOVERABLE_ERROR => 'E_RECOVERABLE_ERROR', + E_DEPRECATED => 'E_DEPRECATED', + E_USER_DEPRECATED => 'E_USER_DEPRECATED', + ]; + } - $loggingPattern = E_ALL ^ $skipLogging; + public static function isFatalError(?int $errorType): bool + { + return $errorType !== null && ($errorType & self::fatalErrorTypes()) !== 0; + } - $data = error_get_last(); + public static function getErrorTypeName(int $errorType): string + { + return self::errorTypeNames()[$errorType] ?? "UNKNOWN($errorType)"; + } - /** - * @var int $errorType - */ - $errorType = &$data['type']; + /** + * Last chance to know what had happened if Wordpress is down. + */ + public function shutdownHandler() + { + $data = error_get_last(); - if ($errorType & $loggingPattern) { - $message = "An Error (0x{$data['type']}) occurred and Wordpress is down.\n"; - $message .= "Message: '{$data['message']}'\n"; - $message .= "Location: '{$data['file']}:{$data['line']}'\n"; - $logger->emergency($message); + if (!self::isFatalError($data['type'] ?? null)) { + return; } + + $message = sprintf( + "A fatal error (%s) occurred and Wordpress is down.\nMessage: '%s'\nLocation: '%s:%s'\n", + self::getErrorTypeName($data['type']), + $data['message'], + $data['file'], + $data['line'], + ); + + Bootstrap::getLogger()->emergency($message); } } diff --git a/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php b/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php index 414087633..e3aa90277 100644 --- a/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php +++ b/inc/Smartling/Helpers/WordpressFunctionProxyHelper.php @@ -114,6 +114,11 @@ public function apply_filters() return apply_filters(...func_get_args()); } + public function do_action() + { + return do_action(...func_get_args()); + } + public function delete_post_meta() { return delete_post_meta(...func_get_args()); diff --git a/inc/Smartling/Jobs/JobAbstract.php b/inc/Smartling/Jobs/JobAbstract.php index deb17f306..8f1ce0569 100644 --- a/inc/Smartling/Jobs/JobAbstract.php +++ b/inc/Smartling/Jobs/JobAbstract.php @@ -173,6 +173,7 @@ public function runCronJob(string $source = ''): void } catch (\RuntimeException $e) { if ($e->getMessage() === self::THROTTLED_MESSAGE) { $message = self::THROTTLED_MESSAGE; + $this->getLogger()->debug("Skipping {$this->getJobHookName()} run: throttled"); } else { throw $e; } diff --git a/inc/Smartling/Jobs/UploadJob.php b/inc/Smartling/Jobs/UploadJob.php index c3e38901b..c903cf36b 100644 --- a/inc/Smartling/Jobs/UploadJob.php +++ b/inc/Smartling/Jobs/UploadJob.php @@ -9,6 +9,7 @@ use Smartling\Helpers\Cache; use Smartling\Helpers\FileUriHelper; use Smartling\Helpers\WordpressFunctionProxyHelper; +use Smartling\Models\UploadQueueItem; use Smartling\Settings\SettingsManager; use Smartling\Submissions\SubmissionManager; @@ -63,8 +64,11 @@ private function processUploadQueue(int $blogId): void break; } $submission = $item->getSubmissions()[0]; + $this->getLogger()->debug("Retrieved upload queue item for submissionId={$submission->getId()}"); if ($submission->isCloned()) { $this->getLogger()->debug("Skipping processing queue for submissionId={$submission->getId()}: was cloned"); + $this->uploadQueueManager->complete($item); + continue; } if ($submission->getFileUri() === '') { $submission->setFileUri($this->fileUriHelper->generateFileUri($submission)); @@ -74,13 +78,20 @@ private function processUploadQueue(int $blogId): void try { $profiles[$submission->getSourceBlogId()] = $this->settingsManager->getSingleSettingsProfile($submission->getSourceBlogId()); } catch (SmartlingDbException) { - $this->getLogger()->notice("Skipping upload of submissionId={$submission->getId()}: no active profile found for blogId={$submission->getSourceBlogId()}"); + $this->failItem($item, 'Skipping upload of', "No active profile found for blogId={$submission->getSourceBlogId()}"); + $this->uploadQueueManager->complete($item); continue; } } $profile = $profiles[$submission->getSourceBlogId()]; if ($item->getBatchUid() === '') { - $item = $item->setBatchUid($this->api->getOrCreateJobInfoForDailyBucketJob($profile, [$submission->getFileUri()])->getBatchUid()); + try { + $item = $item->setBatchUid($this->api->getOrCreateJobInfoForDailyBucketJob($profile, [$submission->getFileUri()])->getBatchUid()); + } catch (\Throwable $e) { + $this->failItem($item, 'Skipping upload of', $e->getMessage(), "failed to get or create daily bucket job: {$e->getMessage()}"); + $this->uploadQueueManager->complete($item); + continue; + } } $this->getLogger()->info(sprintf( @@ -96,22 +107,29 @@ private function processUploadQueue(int $blogId): void )); try { - do_action(ExportedAPI::ACTION_SMARTLING_SEND_FOR_TRANSLATION, $item); - } catch (\Exception $e) { - foreach ($item->getSubmissions() as $submission) { - $this->getLogger()->notice(sprintf('Failing submissionId=%s: %s', $submission->getId(), $e->getMessage())); - $this->submissionManager->setErrorMessage($submission, $e->getMessage()); - } + $this->wpProxy->do_action(ExportedAPI::ACTION_SMARTLING_SEND_FOR_TRANSLATION, $item); + } catch (\Throwable $e) { + $this->failItem($item, 'Failing', $e->getMessage()); } + $this->uploadQueueManager->complete($item); $this->placeLockFlag(true); } } + private function failItem(UploadQueueItem $item, string $logVerb, string $errorMessage, ?string $logMessage = null): void + { + $logMessage ??= $errorMessage; + foreach ($item->getSubmissions() as $submission) { + $this->getLogger()->notice("$logVerb submissionId={$submission->getId()}: $logMessage"); + $this->submissionManager->setErrorMessage($submission, $errorMessage); + } + } + private function processCloning(int $blogId): void { while (($submission = $this->submissionManager->findSubmissionForCloning($blogId)) !== null) { try { - do_action(ExportedAPI::ACTION_SMARTLING_CLONE_CONTENT, $submission); + $this->wpProxy->do_action(ExportedAPI::ACTION_SMARTLING_CLONE_CONTENT, $submission); } catch (\Throwable $e) { $this->submissionManager->setErrorMessage($submission, $e->getMessage()); continue; diff --git a/inc/Smartling/Models/UploadQueueEntity.php b/inc/Smartling/Models/UploadQueueEntity.php index 4c72ac4d6..c8ee8c08f 100644 --- a/inc/Smartling/Models/UploadQueueEntity.php +++ b/inc/Smartling/Models/UploadQueueEntity.php @@ -8,7 +8,9 @@ class UploadQueueEntity implements SmartlingTableDefinitionInterface { public const FIELD_ID = 'id'; + public const FIELD_ATTEMPTS = 'attempts'; public const FIELD_BATCH_UID = 'batch_uid'; + public const FIELD_CLAIMED = 'claimed'; public const FIELD_CREATED = 'created'; public const FIELD_SUBMISSION_IDS = 'submission_ids'; public const TABLE_NAME = 'smartling_upload_queue'; @@ -39,6 +41,8 @@ public static function getFieldDefinitions(): array self::FIELD_SUBMISSION_IDS => SmartlingEntityAbstract::DB_TYPE_STRING_TEXT, self::FIELD_BATCH_UID => SmartlingEntityAbstract::DB_TYPE_STRING_64 . ' ' . SmartlingEntityAbstract::DB_TYPE_DEFAULT_EMPTYSTRING, self::FIELD_CREATED => SmartlingEntityAbstract::DB_TYPE_DATETIME, + self::FIELD_CLAIMED => SmartlingEntityAbstract::DB_TYPE_DATETIME_NULL, + self::FIELD_ATTEMPTS => SmartlingEntityAbstract::DB_TYPE_U_BIGINT . ' ' . SmartlingEntityAbstract::DB_TYPE_DEFAULT_ZERO, ]; } diff --git a/inc/Smartling/Models/UploadQueueItem.php b/inc/Smartling/Models/UploadQueueItem.php index 2f5db01e4..5806d85e9 100644 --- a/inc/Smartling/Models/UploadQueueItem.php +++ b/inc/Smartling/Models/UploadQueueItem.php @@ -9,8 +9,12 @@ class UploadQueueItem { /** * @param SubmissionEntity[] $submissions */ - public function __construct(private array $submissions, private string $batchUid, private IntStringPairCollection $smartlingLocales) - { + public function __construct( + private array $submissions, + private string $batchUid, + private IntStringPairCollection $smartlingLocales, + private int $id, + ) { $contentTypes = []; $sourceBlogIds = []; $sourceIds = []; @@ -42,6 +46,11 @@ public function getBatchUid(): string return $this->batchUid; } + public function getId(): int + { + return $this->id; + } + public function getSmartlingLocales(): IntStringPairCollection { return $this->smartlingLocales; @@ -64,7 +73,7 @@ public function removeSubmission(SubmissionEntity $submission): self return $item->getKey() !== $submission->getId(); }))); - return new self($submissions, $this->batchUid, $locales); + return new self($submissions, $this->batchUid, $locales, $this->id); } #[Pure] diff --git a/inc/config/migrations.yml b/inc/config/migrations.yml index 8ad6287ce..a536f8102 100644 --- a/inc/config/migrations.yml +++ b/inc/config/migrations.yml @@ -70,6 +70,9 @@ services: migration.240315: class: Smartling\DbAl\Migrations\Migration240315 + migration.260825: + class: Smartling\DbAl\Migrations\Migration260825 + manager.db.migrations: class: Smartling\DbAl\Migrations\DbMigrationManager calls: @@ -96,3 +99,4 @@ services: - [ "registerMigration", [ "@migration.210825" ]] - [ "registerMigration", [ "@migration.220701" ]] - [ "registerMigration", [ "@migration.240315" ]] + - [ "registerMigration", [ "@migration.260825" ]] diff --git a/tests/IntegrationTests/tests/SubmissionUploadTest.php b/tests/IntegrationTests/tests/SubmissionUploadTest.php index a3f753bbb..e503ea1e4 100644 --- a/tests/IntegrationTests/tests/SubmissionUploadTest.php +++ b/tests/IntegrationTests/tests/SubmissionUploadTest.php @@ -104,6 +104,7 @@ public function testUploadAttachment() $submissionsToUpload += count($uploadQueueItem->getSubmissions()); $batchUid = $uploadQueueItem->getBatchUid(); $this->assertNotEquals('', $batchUid); + $uploadQueueManager->complete($uploadQueueItem); } while ($uploadQueueManager->count() > 0); $this->assertEquals(2, $submissionsToUpload); $uploadQueueManager->enqueue( diff --git a/tests/Smartling/DbAl/UploadQueueManagerTest.php b/tests/Smartling/DbAl/UploadQueueManagerTest.php index c320a05b3..db75522d4 100644 --- a/tests/Smartling/DbAl/UploadQueueManagerTest.php +++ b/tests/Smartling/DbAl/UploadQueueManagerTest.php @@ -5,7 +5,9 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Smartling\ApiWrapperInterface; +use Smartling\Exception\SmartlingDbException; use Smartling\Models\IntegerIterator; +use Smartling\Models\UploadQueueEntity; use Smartling\Settings\SettingsManager; use Smartling\Submissions\SubmissionEntity; use Smartling\Submissions\SubmissionManager; @@ -147,11 +149,16 @@ public function query() {} $matcherGetRowArray = $this->exactly(3); $db->expects($matcherGetRowArray)->method('getRowArray')->willReturnCallback(function ($query) use ($matcherGetRowArray) { - $this->assertEquals(<<assertStringContainsString( + 'from smartling_upload_queue q left join smartling_submissions s', + $query, + ); + $this->assertStringContainsString( + "on if(locate(',', q.submission_ids), left(submission_ids, locate(',', submission_ids) - 1), submission_ids) = s.id", + $query, + 'Expected the join to extract the first submission id from the comma-separated group', + ); + $this->assertStringContainsString('where s.source_blog_id = 1', $query); return match ($matcherGetRowArray->getInvocationCount()) { 1 => ['id' => 1, 'batch_uid' => '', 'submission_ids' => '1,2'], @@ -160,7 +167,7 @@ public function query() {} }; }); $db->expects($this->exactly(2))->method('query')->willReturnCallback(function ($query) { - $this->assertStringStartsWith('DELETE', $query); + $this->assertStringStartsWith('UPDATE', $query); return true; }); @@ -194,4 +201,357 @@ public function query() {} $this->assertNull($uploadQueueManager->dequeue(1)); } + + public function testDequeueClaimsRowInsteadOfDeletingIt() + { + $queries = []; + $manager = $this->buildManager( + [['id' => 7, 'batch_uid' => '', 'submission_ids' => '1', 'claimed' => null, 'attempts' => 0], null], + [1 => 1], + $queries, + ); + + $item = $manager->dequeue(1); + + $this->assertNotNull($item, 'Expected an unclaimed row to be dequeued'); + $this->assertCount(1, $queries, 'Expected exactly one write while claiming a row'); + $this->assertStringStartsWith('UPDATE', $queries[0], 'Dequeue must claim the row, not delete it'); + $this->assertStringContainsString(UploadQueueEntity::FIELD_CLAIMED, $queries[0]); + $this->assertStringNotContainsStringIgnoringCase('DELETE', $queries[0]); + } + + /** + * $wpdb->query() returns false on failure without throwing. If dequeue() trusted an + * unconfirmed claim, a second dequeue($blogId) call could claim (or have already + * claimed) the same row, dispatching the same content for translation twice. + */ + public function testDequeueDoesNotHandOutItemWhenClaimFails() + { + $this->mockDbAl(); + $db = $this->getMockBuilder(DB::class) + ->setConstructorArgs([new class { + public string $base_prefix = ''; + public function getRowArray() {} + public function query() {} + }]) + ->onlyMethods(['getRowArray', 'query']) + ->getMock(); + $db->method('getRowArray')->willReturnOnConsecutiveCalls( + ['id' => 7, 'batch_uid' => '', 'submission_ids' => '1', 'claimed' => null, 'attempts' => 0], + null, + ); + $db->method('query')->willReturn(false); + + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(1); + $submission->method('getSourceId')->willReturn(1); + $submission->method('getSourceBlogId')->willReturn(1); + $submissionManager = $this->createMock(SubmissionManager::class); + $submissionManager->method('getEntityById')->willReturn($submission); + + $settingsManager = $this->createMock(SettingsManager::class); + $settingsManager->method('getSmartlingLocaleBySubmission')->willReturn('de-DE'); + + $uploadQueueManager = new UploadQueueManager( + $this->createMock(ApiWrapperInterface::class), + $settingsManager, + $db, + $submissionManager, + ); + + $this->assertNull($uploadQueueManager->dequeue(1), 'Must not hand out an item whose claim could not be confirmed'); + } + + public function testDequeueOnlyConsidersUnclaimedOrStaleRows() + { + $queries = []; + $selects = []; + $manager = $this->buildManager( + [['id' => 7, 'batch_uid' => '', 'submission_ids' => '1', 'claimed' => null, 'attempts' => 0], null], + [1 => 1], + $queries, + $selects, + ); + + $manager->dequeue(1); + + $this->assertStringContainsString(UploadQueueEntity::FIELD_CLAIMED, $selects[0]); + $this->assertStringContainsString( + 'is null', + strtolower($selects[0]), + 'Expected unclaimed rows to be eligible', + ); + $this->assertMatchesRegularExpression( + '/claimed`? <|<.*claimed/i', + $selects[0], + 'Expected a staleness comparison so abandoned claims are retried', + ); + } + + /** + * A queue row groups submissions that share the same content, so one submission + * with an unresolvable locale takes the whole row down. Every submission that + * still exists - the one that failed to resolve and any sibling that resolved + * just fine - must not just vanish: each needs a visible error instead of being + * left in New status with no queue row and no explanation. + */ + public function testDequeueSetsErrorOnResolvedSiblingsWhenGroupIsUnprocessable() + { + $resolvableSubmission = $this->createMock(SubmissionEntity::class); + $resolvableSubmission->method('getId')->willReturn(1); + $resolvableSubmission->method('getSourceId')->willReturn(1); + $resolvableSubmission->method('getSourceBlogId')->willReturn(1); + + $unresolvableSubmission = $this->createMock(SubmissionEntity::class); + $unresolvableSubmission->method('getId')->willReturn(2); + $unresolvableSubmission->method('getSourceId')->willReturn(1); + $unresolvableSubmission->method('getSourceBlogId')->willReturn(1); + $unresolvableSubmission->method('getTargetBlogId')->willReturn(3); + + $this->mockDbAl(); + $db = $this->getMockBuilder(DB::class) + ->setConstructorArgs([new class { + public string $base_prefix = ''; + public function getRowArray() {} + public function query() {} + }]) + ->onlyMethods(['getRowArray', 'query']) + ->getMock(); + $db->method('getRowArray')->willReturnOnConsecutiveCalls( + ['id' => 7, 'batch_uid' => '', 'submission_ids' => '1,2', 'claimed' => null, 'attempts' => 0], + null, + ); + $queries = []; + $db->method('query')->willReturnCallback(function ($query) use (&$queries) { + $queries[] = $query; + return true; + }); + + $submissionManager = $this->createMock(SubmissionManager::class); + $submissionManager->method('getEntityById')->willReturnCallback( + function ($id) use ($resolvableSubmission, $unresolvableSubmission) { + return match ($id) { + 1 => $resolvableSubmission, + 2 => $unresolvableSubmission, + default => null, + }; + }, + ); + $failed = []; + $submissionManager->method('setErrorMessage')->willReturnCallback( + function (SubmissionEntity $submission, string $message) use (&$failed) { + $failed[] = $submission; + return $submission; + }, + ); + + $settingsManager = $this->createMock(SettingsManager::class); + $settingsManager->method('getSmartlingLocaleBySubmission')->willReturnCallback( + function (SubmissionEntity $submission) use ($resolvableSubmission) { + if ($submission === $resolvableSubmission) { + return 'de-DE'; + } + throw new SmartlingDbException('profile not found'); + }, + ); + + $uploadQueueManager = new UploadQueueManager( + $this->createMock(ApiWrapperInterface::class), + $settingsManager, + $db, + $submissionManager, + ); + + $this->assertNull($uploadQueueManager->dequeue(1), 'Unprocessable groups must not be handed out'); + $this->assertSame( + [$resolvableSubmission, $unresolvableSubmission], + $failed, + 'Expected every existing submission in the discarded group to be failed visibly', + ); + $this->assertNotEmpty( + array_filter($queries, static fn(string $q) => str_starts_with($q, 'DELETE')), + 'Expected the unprocessable row to be removed from the queue', + ); + } + + public function testDequeueFailsSubmissionsOnceAttemptsAreExhausted() + { + $queries = []; + $selects = []; + $failed = []; + $manager = $this->buildManager( + [ + ['id' => 7, 'batch_uid' => '', 'submission_ids' => '1', 'claimed' => '2020-01-01 00:00:00', 'attempts' => UploadQueueManager::MAX_ATTEMPTS], + null, + ], + [1 => 1], + $queries, + $selects, + $failed, + ); + + $this->assertNull($manager->dequeue(1), 'Exhausted rows must not be handed out again'); + $this->assertCount(1, $failed, 'Expected the submission to be failed visibly'); + $this->assertStringContainsString('attempt', strtolower($failed[0])); + $this->assertNotEmpty( + array_filter($queries, static fn(string $q) => str_starts_with($q, 'DELETE')), + 'Expected the exhausted row to be removed from the queue', + ); + } + + /** + * $wpdb->query() returns false on failure (deadlock, lock-wait timeout, connection + * blip) without throwing. If discardQueueItem()'s delete() silently fails, dequeue() + * must not treat the row as gone and re-select: the row comes back unchanged, so + * continuing the while loop would spin on it forever inside a single dequeue() call. + */ + public function testDequeueStopsInsteadOfSpinningWhenDiscardFailsToDelete() + { + $this->mockDbAl(); + $db = $this->getMockBuilder(DB::class) + ->setConstructorArgs([new class { + public string $base_prefix = ''; + public function getRowArray() {} + public function query() {} + }]) + ->onlyMethods(['getRowArray', 'query']) + ->getMock(); + $selectCalls = 0; + $db->method('getRowArray')->willReturnCallback(function () use (&$selectCalls) { + $selectCalls++; + // The same unprocessable row every time, as it would be in reality if the + // DELETE below kept failing and never actually removed it. + return ['id' => 7, 'batch_uid' => '', 'submission_ids' => '1', 'claimed' => null, 'attempts' => 0]; + }); + $db->method('query')->willReturn(false); + + $submissionManager = $this->createMock(SubmissionManager::class); + $submissionManager->method('getEntityById')->willReturn(null); + + $uploadQueueManager = new UploadQueueManager( + $this->createMock(ApiWrapperInterface::class), + $this->createMock(SettingsManager::class), + $db, + $submissionManager, + ); + + $this->assertNull($uploadQueueManager->dequeue(1)); + $this->assertSame( + 1, + $selectCalls, + 'Expected dequeue() to stop after the first failed delete rather than re-selecting the same row forever', + ); + } + + /** + * dequeue() claims a row only after resolving every submission in it. If that + * resolution throws anything unexpected, the row must still end up discarded + * rather than left permanently unclaimed - otherwise a single misbehaving + * submission blocks the entire per-blog queue forever, since every future + * dequeue() call would hit the same exception before ever reaching claim(). + */ + public function testDequeueDiscardsItemWhenResolvingASubmissionThrowsUnexpectedException() + { + $this->mockDbAl(); + $db = $this->getMockBuilder(DB::class) + ->setConstructorArgs([new class { + public string $base_prefix = ''; + public function getRowArray() {} + public function query() {} + }]) + ->onlyMethods(['getRowArray', 'query']) + ->getMock(); + $db->method('getRowArray')->willReturnOnConsecutiveCalls( + ['id' => 7, 'batch_uid' => '', 'submission_ids' => '1', 'claimed' => null, 'attempts' => 0], + null, + ); + $queries = []; + $db->method('query')->willReturnCallback(function ($query) use (&$queries) { + $queries[] = $query; + return true; + }); + + $submissionManager = $this->createMock(SubmissionManager::class); + $submissionManager->method('getEntityById')->willThrowException(new \RuntimeException('DB connection lost')); + + $uploadQueueManager = new UploadQueueManager( + $this->createMock(ApiWrapperInterface::class), + $this->createMock(SettingsManager::class), + $db, + $submissionManager, + ); + + $this->assertNull($uploadQueueManager->dequeue(1), 'An unresolvable row must not be handed out, but must not throw either'); + $this->assertNotEmpty( + array_filter($queries, static fn(string $q) => str_starts_with($q, 'DELETE')), + 'Expected the unresolvable row to be removed from the queue rather than left claimed forever', + ); + } + + /** + * @param array $rows sequential getRowArray() return values + * @param int[] $submissions map of submission id => source blog id that exist + */ + private function buildManager( + array $rows, + array $submissions, + array &$queries, + array &$selects = [], + array &$failed = [], + ): UploadQueueManager { + $stored = []; + foreach ($submissions as $id => $sourceBlogId) { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn($id); + $submission->method('getSourceId')->willReturn(1); + $submission->method('getSourceBlogId')->willReturn($sourceBlogId); + $stored[] = $submission; + } + + $this->mockDbAl(); + $db = $this->getMockBuilder(DB::class) + ->setConstructorArgs([new class { + public string $base_prefix = ''; + public function getRowArray() {} + public function query() {} + }]) + ->onlyMethods(['getRowArray', 'query']) + ->getMock(); + + $index = 0; + $db->method('getRowArray')->willReturnCallback(function ($query) use ($rows, &$index, &$selects) { + $selects[] = $query; + return $rows[$index++] ?? null; + }); + $db->method('query')->willReturnCallback(function ($query) use (&$queries) { + $queries[] = $query; + return true; + }); + + $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; + }); + $submissionManager->method('setErrorMessage')->willReturnCallback( + function (SubmissionEntity $submission, string $message) use (&$failed) { + $failed[] = $message; + return $submission; + } + ); + + $settingsManager = $this->createMock(SettingsManager::class); + $settingsManager->method('getSmartlingLocaleBySubmission')->willReturn('de-DE'); + + return new UploadQueueManager( + $this->createMock(ApiWrapperInterface::class), + $settingsManager, + $db, + $submissionManager, + ); + } } diff --git a/tests/Smartling/DebugTraitTest.php b/tests/Smartling/DebugTraitTest.php new file mode 100644 index 000000000..bc8e4aebd --- /dev/null +++ b/tests/Smartling/DebugTraitTest.php @@ -0,0 +1,58 @@ +assertSame($expected, $subject::isFatalError($errorType), "$label was classified incorrectly"); + } + + public function fatalErrorTypeProvider(): array + { + return [ + // These end the request and are worth an emergency. + [E_ERROR, true, 'E_ERROR'], + [E_PARSE, true, 'E_PARSE'], + [E_CORE_ERROR, true, 'E_CORE_ERROR'], + [E_COMPILE_ERROR, true, 'E_COMPILE_ERROR'], + [E_USER_ERROR, true, 'E_USER_ERROR'], + [E_RECOVERABLE_ERROR, true, 'E_RECOVERABLE_ERROR'], + // These do not, and previously flooded the log as false emergencies. + [E_USER_DEPRECATED, false, 'E_USER_DEPRECATED'], + [E_DEPRECATED, false, 'E_DEPRECATED'], + [E_WARNING, false, 'E_WARNING'], + [E_NOTICE, false, 'E_NOTICE'], + [E_USER_WARNING, false, 'E_USER_WARNING'], + [E_USER_NOTICE, false, 'E_USER_NOTICE'], + [E_CORE_WARNING, false, 'E_CORE_WARNING'], + [E_COMPILE_WARNING, false, 'E_COMPILE_WARNING'], + ]; + } + + /** + * The old message rendered the decimal error type behind an "0x" prefix, so + * E_USER_DEPRECATED showed up as the meaningless "0x16384". + */ + public function testErrorTypeIsNamedRatherThanMislabelledAsHex() + { + $subject = new class { + use DebugTrait; + }; + + $this->assertSame('E_PARSE', $subject::getErrorTypeName(E_PARSE)); + $this->assertSame('E_USER_DEPRECATED', $subject::getErrorTypeName(E_USER_DEPRECATED)); + $this->assertStringContainsString('12345', $subject::getErrorTypeName(12345), 'Unknown types should still be reported'); + } +} diff --git a/tests/Smartling/Jobs/UploadJobTest.php b/tests/Smartling/Jobs/UploadJobTest.php new file mode 100644 index 000000000..aca6ae284 --- /dev/null +++ b/tests/Smartling/Jobs/UploadJobTest.php @@ -0,0 +1,317 @@ +buildItem(); + $uploadQueueManager = $this->buildQueueManager($item); + $uploadQueueManager->expects($this->once())->method('complete')->with($item); + + $this->buildJob($uploadQueueManager)->run(''); + } + + /** + * A queue row must only be removed once the upload has been accounted for, so + * that a process death mid-upload leaves the work recoverable. + */ + public function testFailedUploadStillRemovesItemButRecordsError() + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(1); + $submission->method('getFileUri')->willReturn('file.xml'); + $submission->method('getSourceBlogId')->willReturn(1); + $item = $this->buildItem($submission); + + $uploadQueueManager = $this->buildQueueManager($item); + $uploadQueueManager->expects($this->once())->method('complete')->with($item); + + $submissionManager = $this->createMock(SubmissionManager::class); + $submissionManager->expects($this->once())->method('setErrorMessage') + ->with($submission, $this->stringContains('boom')); + + $this->buildJob($uploadQueueManager, $submissionManager, static function () { + throw new \RuntimeException('boom'); + })->run(''); + } + + /** + * error_get_last()-style crashes surface as \Error, not \Exception. The dispatch + * must be resilient to those too, or a single bad hook aborts the whole cron run + * without ever completing the claimed queue item. + */ + public function testUploadThrowableFromHookStillRemovesItemButRecordsError() + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(1); + $submission->method('getFileUri')->willReturn('file.xml'); + $submission->method('getSourceBlogId')->willReturn(1); + $item = $this->buildItem($submission); + + $uploadQueueManager = $this->buildQueueManager($item); + $uploadQueueManager->expects($this->once())->method('complete')->with($item); + + $submissionManager = $this->createMock(SubmissionManager::class); + $submissionManager->expects($this->once())->method('setErrorMessage') + ->with($submission, $this->stringContains('boom')); + + $this->buildJob($uploadQueueManager, $submissionManager, static function () { + throw new \Error('boom'); + })->run(''); + } + + /** + * A row is claimed (and its attempt counter incremented) by dequeue() before this + * check runs. If the row isn't completed here, it stays claimed until the stale + * claim window passes and is retried needlessly, eventually failing with a + * misleading "terminated unexpectedly" message instead of the real cause. + */ + public function testSkipsUploadAndCompletesQueueItemWhenNoActiveProfileFound() + { + $item = $this->buildItem(); + $uploadQueueManager = $this->buildQueueManager($item); + $uploadQueueManager->expects($this->once())->method('complete')->with($item); + + $this->buildJob($uploadQueueManager, null, null, static function () { + throw new SmartlingDbException('no profile'); + })->run(''); + } + + /** + * A cloned submission's content was already uploaded at clone time; re-uploading + * it would be a duplicate. Before this fix, isCloned() only logged that the item + * was "being skipped" without actually skipping it. + */ + public function testClonedSubmissionIsSkippedAndCompletesQueueItemWithoutUploading() + { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(1); + $submission->method('isCloned')->willReturn(true); + $item = $this->buildItem($submission); + + $uploadQueueManager = $this->buildQueueManager($item); + $uploadQueueManager->expects($this->once())->method('complete')->with($item); + + $uploaded = false; + $this->buildJob($uploadQueueManager, null, static function () use (&$uploaded) { + $uploaded = true; + })->run(''); + + $this->assertFalse($uploaded, 'Cloned submissions must not be uploaded'); + } + + /** + * 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 + * lookup fails, every submission in the group must be failed visibly, not just the + * first, or siblings silently vanish with the row while staying in "New" forever. + */ + public function testFailsEverySubmissionWhenNoActiveProfileFound() + { + [$item, $submission1, $submission2] = $this->buildTwoSubmissionItem(); + $uploadQueueManager = $this->buildQueueManager($item); + $uploadQueueManager->expects($this->once())->method('complete')->with($item); + + $submissionManager = $this->createMock(SubmissionManager::class); + $failed = []; + $submissionManager->method('setErrorMessage')->willReturnCallback( + static function (SubmissionEntity $submission, string $message) use (&$failed) { + $failed[] = $submission; + return $submission; + }, + ); + + $this->buildJob($uploadQueueManager, $submissionManager, null, static function () { + throw new SmartlingDbException('no profile'); + })->run(''); + + $this->assertSame([$submission1, $submission2], $failed, 'Expected every submission in the group to be failed visibly'); + } + + /** + * Same as above, but for the daily-bucket-job lookup failing instead of the profile + * lookup. + */ + public function testFailsEverySubmissionWhenDailyBucketJobCannotBeCreated() + { + [$item, $submission1, $submission2] = $this->buildTwoSubmissionItem(); + $uploadQueueManager = $this->buildQueueManager($item); + $uploadQueueManager->expects($this->once())->method('complete')->with($item); + + $submissionManager = $this->createMock(SubmissionManager::class); + $failed = []; + $submissionManager->method('setErrorMessage')->willReturnCallback( + static function (SubmissionEntity $submission, string $message) use (&$failed) { + $failed[] = $submission; + return $submission; + }, + ); + + $api = $this->createMock(ApiWrapperInterface::class); + $api->method('getOrCreateJobInfoForDailyBucketJob')->willThrowException(new \RuntimeException('boom')); + + $this->buildJob($uploadQueueManager, $submissionManager, null, null, $api)->run(''); + + $this->assertSame([$submission1, $submission2], $failed, 'Expected every submission in the group to be failed visibly'); + } + + /** + * @return array{0: UploadQueueItem, 1: SubmissionEntity, 2: SubmissionEntity} + */ + private function buildTwoSubmissionItem(): array + { + $submission1 = $this->createMock(SubmissionEntity::class); + $submission1->method('getId')->willReturn(1); + $submission1->method('getFileUri')->willReturn('file.xml'); + $submission1->method('getSourceBlogId')->willReturn(1); + $submission2 = $this->createMock(SubmissionEntity::class); + $submission2->method('getId')->willReturn(2); + $submission2->method('getFileUri')->willReturn('file.xml'); + $submission2->method('getSourceBlogId')->willReturn(1); + + $item = new UploadQueueItem( + [$submission1, $submission2], + '', + new IntStringPairCollection([new IntStringPair(1, 'de-DE'), new IntStringPair(2, 'fr-FR')]), + 42, + ); + + return [$item, $submission1, $submission2]; + } + + /** + * processUploadQueue() dispatches through the WordPress function proxy so the hook + * call can be mocked in tests; processCloning() must do the same, or bugs in the + * cloning dispatch have no unit-test coverage. + */ + public function testCloningDispatchesThroughWordpressProxy() + { + $uploadQueueManager = $this->createMock(UploadQueueManager::class); + $uploadQueueManager->method('length')->willReturn(0); + $uploadQueueManager->method('dequeue')->willReturn(null); + + $submission = $this->createMock(SubmissionEntity::class); + $submissionManager = $this->createMock(SubmissionManager::class); + $calls = 0; + $submissionManager->method('findSubmissionForCloning')->willReturnCallback( + function () use ($submission, &$calls) { + return $calls++ === 0 ? $submission : null; + }, + ); + + $settingsManager = $this->createMock(SettingsManager::class); + $settingsManager->method('getActiveProfile') + ->willReturn($this->createMock(ConfigurationProfileEntity::class)); + + $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + $wpProxy->method('get_current_blog_id')->willReturn(1); + $wpProxy->expects($this->once())->method('do_action') + ->with(ExportedAPI::ACTION_SMARTLING_CLONE_CONTENT, $submission); + + (new UploadJob( + $this->createMock(ApiWrapperInterface::class), + $this->createMock(Cache::class), + $this->createMock(FileUriHelper::class), + $settingsManager, + $submissionManager, + $uploadQueueManager, + $wpProxy, + 0, + 'hourly', + ))->run(''); + } + + private function buildItem(?SubmissionEntity $submission = null): UploadQueueItem + { + if ($submission === null) { + $submission = $this->createMock(SubmissionEntity::class); + $submission->method('getId')->willReturn(1); + $submission->method('getFileUri')->willReturn('file.xml'); + $submission->method('getSourceBlogId')->willReturn(1); + } + + return new UploadQueueItem( + [$submission], + 'batchUid', + new IntStringPairCollection([new IntStringPair(1, 'de-DE')]), + 42, + ); + } + + private function buildQueueManager(UploadQueueItem $item): UploadQueueManager|MockObject + { + $uploadQueueManager = $this->createMock(UploadQueueManager::class); + $uploadQueueManager->method('length')->willReturn(1); + $calls = 0; + $uploadQueueManager->method('dequeue')->willReturnCallback(function () use ($item, &$calls) { + return $calls++ === 0 ? $item : null; + }); + + return $uploadQueueManager; + } + + private function buildJob( + UploadQueueManager $uploadQueueManager, + ?SubmissionManager $submissionManager = null, + ?callable $onSendForTranslation = null, + ?callable $onGetSingleSettingsProfile = null, + ?ApiWrapperInterface $api = null, + ): UploadJob { + $settingsManager = $this->createMock(SettingsManager::class); + if ($onGetSingleSettingsProfile !== null) { + $settingsManager->method('getSingleSettingsProfile')->willReturnCallback($onGetSingleSettingsProfile); + } else { + $settingsManager->method('getSingleSettingsProfile') + ->willReturn($this->createMock(ConfigurationProfileEntity::class)); + } + $settingsManager->method('getActiveProfile') + ->willReturn($this->createMock(ConfigurationProfileEntity::class)); + + $submissionManager ??= $this->createMock(SubmissionManager::class); + $submissionManager->method('findSubmissionForCloning')->willReturn(null); + + $wpProxy = $this->createMock(WordpressFunctionProxyHelper::class); + $wpProxy->method('get_current_blog_id')->willReturn(1); + if ($onSendForTranslation !== null) { + $wpProxy->method('do_action')->willReturnCallback($onSendForTranslation); + } + + return new UploadJob( + $api ?? $this->createMock(ApiWrapperInterface::class), + $this->createMock(Cache::class), + $this->createMock(FileUriHelper::class), + $settingsManager, + $submissionManager, + $uploadQueueManager, + $wpProxy, + 0, + 'hourly', + ); + } +} diff --git a/tests/Smartling/Models/UploadQueueItemTest.php b/tests/Smartling/Models/UploadQueueItemTest.php index fe68b3f56..f4486ed34 100644 --- a/tests/Smartling/Models/UploadQueueItemTest.php +++ b/tests/Smartling/Models/UploadQueueItemTest.php @@ -13,7 +13,7 @@ public function testRemoveSubmission() $s1->setId(1); $s2 = new SubmissionEntity(); $s2->setId(2); - $x = new UploadQueueItem([$s1, $s2], '', new IntStringPairCollection([new IntStringPair(1, 'a'), new IntStringPair(2, 'b')])); + $x = new UploadQueueItem([$s1, $s2], '', new IntStringPairCollection([new IntStringPair(1, 'a'), new IntStringPair(2, 'b')]), 1); foreach ($x->getSubmissions() as $submission) { if ($submission->getId() === 1) { $x = $x->removeSubmission($submission); @@ -31,7 +31,7 @@ public function testSubmissionsAltered() $s1->setId(1); $s2 = new SubmissionEntity(); $s2->setId(2); - $x = new UploadQueueItem([$s1, $s2], '', new IntStringPairCollection([new IntStringPair(1, 'a'), new IntStringPair(2, 'b')])); + $x = new UploadQueueItem([$s1, $s2], '', new IntStringPairCollection([new IntStringPair(1, 'a'), new IntStringPair(2, 'b')]), 1); foreach ($x->getSubmissions() as $submission) { $submission->setStatus(SubmissionEntity::SUBMISSION_STATUS_COMPLETED); }