From 675c316c297faf17e455ab29950930df48474e0a Mon Sep 17 00:00:00 2001 From: Loparev Date: Sun, 1 Feb 2026 12:14:36 +0200 Subject: [PATCH 1/3] Add FileTranslations API client for instant translation feature Implements account-level File Translations API with machine translation support: - FileTranslationsApi with 6 methods (upload, translate, progress, download, cancel) - TranslateFileParameters and UploadFileParameters classes - Comprehensive unit tests (8 tests, all passing) - Functional tests for integration testing - Usage example with complete workflow Co-Authored-By: Claude Sonnet 4.5 --- CLAUDE.md | 144 ++++++++ examples/file-translations-example.php | 190 +++++++++++ src/FileTranslations/FileTranslationsApi.php | 256 ++++++++++++++ .../Params/TranslateFileParameters.php | 60 ++++ .../Params/UploadFileParameters.php | 20 ++ .../FileTranslationsApiFunctionalTest.php | 202 +++++++++++ tests/resources/test-fts.json | 5 + tests/unit/FileTranslationsApiTest.php | 319 ++++++++++++++++++ 8 files changed, 1196 insertions(+) create mode 100644 CLAUDE.md create mode 100644 examples/file-translations-example.php create mode 100644 src/FileTranslations/FileTranslationsApi.php create mode 100644 src/FileTranslations/Params/TranslateFileParameters.php create mode 100644 src/FileTranslations/Params/UploadFileParameters.php create mode 100644 tests/functional/FileTranslationsApiFunctionalTest.php create mode 100644 tests/resources/test-fts.json create mode 100644 tests/unit/FileTranslationsApiTest.php diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8f4ce36 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,144 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository Overview + +This is the PHP SDK for the Smartling Translation API. It provides a PHP client library for interacting with Smartling's translation management platform, allowing developers to upload files for translation, download translated content, manage translation jobs, and access other Smartling API features. + +## Development Commands + +### Install Dependencies +```bash +composer install +``` + +### Run All Tests +Tests require Smartling API credentials as environment variables: +```bash +account_uid= project_id= user_id= user_key= ./vendor/bin/phpunit +``` + +### Run Specific Test Suites +```bash +# Unit tests only +./vendor/bin/phpunit --testsuite unit + +# Functional tests only (requires API credentials) +account_uid= project_id= user_id= user_key= ./vendor/bin/phpunit --testsuite functional +``` + +### Run Individual Test Files +```bash +./vendor/bin/phpunit tests/unit/SomeTest.php +``` + +## Architecture + +### Core Components + +**BaseApiAbstract** (`src/BaseApiAbstract.php`) +- Abstract base class for all API clients +- Handles HTTP communication via Guzzle +- Manages authentication via `AuthApiInterface` +- Implements automatic token refresh on 401 errors +- Provides logging support via PSR-3 `LoggerInterface` +- Standardizes error handling and response processing +- All API classes extend this base + +**Authentication Flow** +- `AuthTokenProvider` (`src/AuthApi/`) implements `AuthApiInterface` +- Manages OAuth access tokens and refresh tokens with automatic expiration handling +- Token refresh happens automatically when expired or on 401 responses +- All API clients receive an auth provider instance and use it to authenticate requests + +**API Client Pattern** +Each Smartling API endpoint has a dedicated API class: +- `FileApi` - File upload, download, and management +- `JobsApi` - Translation job management +- `BatchApi` / `BatchApiV2` - Batch operations +- `ContextApi` - Visual context and screenshots +- `ProjectApi` - Project information +- `AuditLogApi` - Audit logs +- `ProgressTrackerApi` - Translation progress tracking +- `TranslationRequestsApi` - Translation request management +- `DistributedLockServiceApi` - Distributed locking + +**Parameter Objects** +- Located in `*/Params/` directories under each API module +- Implement `ParameterInterface` or extend `BaseParameters` +- Provide type-safe parameter building for API methods +- Example: `UploadFileParameters`, `CreateJobParameters`, `DownloadFileParameters` + +### Directory Structure + +``` +src/ +├── BaseApiAbstract.php # Base class for all API clients +├── AuthApi/ # Authentication provider +├── File/ # File API (upload/download) +├── Jobs/ # Jobs API +├── Batch/ # Batch operations (v1 and v2) +├── Context/ # Visual context API +├── Project/ # Project information API +├── AuditLog/ # Audit logging API +├── ProgressTracker/ # Progress tracking API +├── TranslationRequests/ # Translation requests API +├── DistributedLockService/ # Distributed lock service +├── Parameters/ # Base parameter classes +└── Exceptions/ # Custom exceptions + +tests/ +├── unit/ # Unit tests (no API calls) +└── functional/ # Functional tests (require API credentials) + +examples/ # Usage examples for each API +``` + +### API Client Instantiation Pattern + +All API clients follow this factory pattern: +```php +$authProvider = AuthTokenProvider::create($userIdentifier, $userSecretKey); +$api = SomeApi::create($authProvider, $projectId, $logger); +``` + +Each API class: +1. Defines its own `ENDPOINT_URL` constant +2. Provides a static `create()` factory method +3. Initializes an HTTP client via `BaseApiAbstract::initializeHttpClient()` +4. Sets the auth provider via `setAuth()` + +### Error Handling + +- `SmartlingApiException` is thrown for all API errors +- Errors contain structured error information from the API response +- 401 errors trigger automatic token refresh and retry +- Sensitive data (tokens, credentials) is redacted from logs + +### Logging + +- All API clients accept an optional PSR-3 `LoggerInterface` +- Requests and responses are logged for debugging +- Sensitive data is automatically redacted in logs + +## PHP Version Support + +- PHP 7.3+ and PHP 8.x +- Uses PSR-4 autoloading +- Requires Guzzle 6.x or 7.x for HTTP client +- Requires PSR Log 1.x or 3.x for logging + +## Testing Strategy + +- **Unit tests** (`tests/unit/`) - Test logic without making actual API calls +- **Functional tests** (`tests/functional/`) - Integration tests that call the real Smartling API +- Functional tests require valid Smartling credentials passed as environment variables +- Test resources are stored in `tests/resources/` + +## Examples + +The `examples/` directory contains working examples for each API: +- Run with: `php file-example.php --project-id=XXX --user-id=XXX --secret-key=XXX` +- Each example demonstrates common operations for that API +- Examples are self-contained and include inline documentation diff --git a/examples/file-translations-example.php b/examples/file-translations-example.php new file mode 100644 index 0000000..71b063c --- /dev/null +++ b/examples/file-translations-example.php @@ -0,0 +1,190 @@ +uploadFile($filePath, $fileName, $fileType, $uploadParams); + $fileUid = $uploadResult['fileUid']; + echo " ✓ File uploaded successfully\n"; + echo " File UID: {$fileUid}\n\n"; + + // Step 3: Initiate translation + echo "3. Initiating translation to Spanish, French, and German...\n"; + $translateParams = new TranslateFileParameters(); + $translateParams + ->setSourceLocaleId('en') + ->setTargetLocaleIds(['es', 'fr', 'de']); + + $translateResult = $api->translateFile($fileUid, $translateParams); + $mtUid = $translateResult['mtUid']; + echo " ✓ Translation initiated\n"; + echo " MT UID: {$mtUid}\n\n"; + + // Step 4: Poll translation progress + echo "4. Polling translation progress...\n"; + $maxAttempts = 60; + $pollInterval = 5; // seconds + $completed = false; + + for ($i = 0; $i < $maxAttempts; $i++) { + $progress = $api->getTranslationProgress($fileUid, $mtUid); + $status = $progress['status']; + + echo " Status: {$status}"; + + if (isset($progress['completedLocales'])) { + $completedCount = count($progress['completedLocales']); + $totalCount = count($translateParams->exportToArray()['targetLocaleIds']); + echo " ({$completedCount}/{$totalCount} locales completed)"; + } + + echo "\n"; + + if ($status === 'COMPLETED') { + $completed = true; + echo " ✓ Translation completed!\n\n"; + break; + } elseif ($status === 'FAILED') { + echo " ✗ Translation failed\n"; + print_r($progress); + exit(1); + } elseif ($status === 'CANCELLED') { + echo " ✗ Translation was cancelled\n"; + exit(1); + } + + if ($i < $maxAttempts - 1) { + sleep($pollInterval); + } + } + + if (!$completed) { + echo " ⚠ Translation not completed within expected time. Continuing anyway...\n\n"; + } + + // Step 5: Download translated files + echo "5. Downloading translated files...\n"; + $targetLocales = ['es', 'fr', 'de']; + + foreach ($targetLocales as $locale) { + try { + $translatedContent = $api->downloadTranslatedFile($fileUid, $mtUid, $locale); + $outputPath = "/tmp/translated-{$locale}-{$fileName}"; + file_put_contents($outputPath, $translatedContent); + echo " ✓ Downloaded {$locale}: {$outputPath}\n"; + + // Show first 100 chars of content + $preview = substr($translatedContent, 0, 100); + if (strlen($translatedContent) > 100) { + $preview .= '...'; + } + echo " Preview: {$preview}\n"; + } catch (Exception $e) { + echo " ✗ Failed to download {$locale}: {$e->getMessage()}\n"; + } + } + echo "\n"; + + // Step 6: Download all translations as ZIP + echo "6. Downloading all translations as ZIP...\n"; + try { + $zipContent = $api->downloadAllTranslationsZip($fileUid, $mtUid); + $zipPath = "/tmp/all-translations-{$fileUid}.zip"; + file_put_contents($zipPath, $zipContent); + echo " ✓ Downloaded ZIP: {$zipPath}\n"; + echo " Size: " . strlen($zipContent) . " bytes\n\n"; + } catch (Exception $e) { + echo " ✗ Failed to download ZIP: {$e->getMessage()}\n\n"; + } + + // Optional: Demonstrate cancellation with a new translation + echo "7. (Optional) Demonstrating translation cancellation...\n"; + echo " Uploading another file...\n"; + $uploadResult2 = $api->uploadFile($filePath, "cancel-demo-{$fileName}", $fileType, $uploadParams); + $fileUid2 = $uploadResult2['fileUid']; + + echo " Starting translation to many locales...\n"; + $translateParams2 = new TranslateFileParameters(); + $translateParams2 + ->setSourceLocaleId('en') + ->setTargetLocaleIds(['es', 'fr', 'de', 'it', 'pt', 'ja', 'zh', 'ru']); + + $translateResult2 = $api->translateFile($fileUid2, $translateParams2); + $mtUid2 = $translateResult2['mtUid']; + + echo " Cancelling translation...\n"; + sleep(1); // Give it a moment to start + $api->cancelFileTranslation($fileUid2, $mtUid2); + echo " ✓ Cancellation request sent\n"; + + sleep(2); + $progress = $api->getTranslationProgress($fileUid2, $mtUid2); + echo " Final status: {$progress['status']}\n\n"; + + echo "=== Example completed successfully ===\n"; + +} catch (Exception $e) { + echo "\nError: {$e->getMessage()}\n"; + if (method_exists($e, 'getTraceAsString')) { + echo $e->getTraceAsString() . "\n"; + } + exit(1); +} diff --git a/src/FileTranslations/FileTranslationsApi.php b/src/FileTranslations/FileTranslationsApi.php new file mode 100644 index 0000000..c824dc0 --- /dev/null +++ b/src/FileTranslations/FileTranslationsApi.php @@ -0,0 +1,256 @@ +accountUid = $accountUid; + } + + /** + * Factory method to create FileTranslationsApi instance. + * + * @param AuthApiInterface $authProvider + * Authentication provider + * @param string $accountUid + * Account UID in Smartling dashboard + * @param LoggerInterface $logger + * Logger instance + * + * @return FileTranslationsApi + */ + public static function create(AuthApiInterface $authProvider, $accountUid, $logger = null) + { + $client = self::initializeHttpClient(self::ENDPOINT_URL); + + $instance = new self($accountUid, $client, $logger, self::ENDPOINT_URL); + $instance->setAuth($authProvider); + + return $instance; + } + + /** + * {@inheritdoc} + * + * Overrides base implementation to handle file upload with request JSON part. + */ + protected function processBodyOptions($requestData = []) + { + $opts = parent::processBodyOptions($requestData); + + if (!empty($opts['multipart'])) { + foreach ($opts['multipart'] as &$data) { + if ($data['name'] === 'file') { + $data['contents'] = $this->readFile($data['contents']); + if (array_key_exists('filename', $opts)) { + $data['filename'] = $opts['filename']; + } + } + } + } + + return $opts; + } + + /** + * Get account UID. + * + * @return string + */ + protected function getAccountUid() + { + return $this->accountUid; + } + + /** + * Uploads a file for machine translation. + * + * @param string $realPath + * Real path to the file to read in into stream. + * @param string $fileName + * Logical filename for the file. + * @param string $fileType + * File type identifier (json, xml, html, etc.) + * @param UploadFileParameters $params + * Optional additional parameters + * + * @return array + * Response data containing fileUid + * + * @throws SmartlingApiException + */ + public function uploadFile($realPath, $fileName, $fileType, UploadFileParameters $params = null) + { + if (is_null($params)) { + $params = new UploadFileParameters(); + } + + // Build request JSON object + $requestJson = [ + 'fileType' => $fileType, + ]; + + // Merge any additional parameters + $additionalParams = $params->exportToArray(); + if (!empty($additionalParams)) { + $requestJson = array_merge($requestJson, $additionalParams); + } + + // Build multipart request + $multipartParams = [ + 'file' => $realPath, + 'request' => json_encode($requestJson), + ]; + + $requestData = $this->getDefaultRequestData('multipart', $multipartParams); + $requestData['filename'] = $fileName; + + return $this->sendRequest('files', $requestData, self::HTTP_METHOD_POST); + } + + /** + * Initiates machine translation for an uploaded file. + * + * @param string $fileUid + * File UID returned from uploadFile + * @param TranslateFileParameters $params + * Translation parameters (source locale, target locales, callback URL) + * + * @return array + * Response data containing mtUid (machine translation UID) + * + * @throws SmartlingApiException + */ + public function translateFile($fileUid, TranslateFileParameters $params) + { + $requestParams = $params->exportToArray(); + + $requestData = $this->getDefaultRequestData('json', $requestParams); + + return $this->sendRequest("files/{$fileUid}/mt", $requestData, self::HTTP_METHOD_POST); + } + + /** + * Gets the translation progress/status for a machine translation job. + * + * @param string $fileUid + * File UID + * @param string $mtUid + * Machine translation UID returned from translateFile + * + * @return array + * Response data containing status (IN_PROGRESS, COMPLETED, FAILED, CANCELLED) + * + * @throws SmartlingApiException + */ + public function getTranslationProgress($fileUid, $mtUid) + { + $requestData = $this->getDefaultRequestData('query', []); + + return $this->sendRequest("files/{$fileUid}/mt/{$mtUid}/status", $requestData, self::HTTP_METHOD_GET); + } + + /** + * Downloads a translated file for a specific locale. + * + * @param string $fileUid + * File UID + * @param string $mtUid + * Machine translation UID + * @param string $localeId + * Target locale ID (e.g., 'es', 'fr', 'de') + * + * @return string + * Raw file content + * + * @throws SmartlingApiException + */ + public function downloadTranslatedFile($fileUid, $mtUid, $localeId) + { + $requestData = $this->getDefaultRequestData('query', []); + unset($requestData['headers']['Accept']); + + return $this->sendRequest("files/{$fileUid}/mt/{$mtUid}/locales/{$localeId}/file", $requestData, self::HTTP_METHOD_GET, true); + } + + /** + * Downloads all translations as a ZIP archive. + * + * @param string $fileUid + * File UID + * @param string $mtUid + * Machine translation UID + * + * @return string + * Raw ZIP file content + * + * @throws SmartlingApiException + */ + public function downloadAllTranslationsZip($fileUid, $mtUid) + { + $requestData = $this->getDefaultRequestData('query', []); + unset($requestData['headers']['Accept']); + + return $this->sendRequest("files/{$fileUid}/mt/{$mtUid}/locales/all/file/zip", $requestData, self::HTTP_METHOD_GET, true); + } + + /** + * Cancels an in-progress machine translation job. + * + * @param string $fileUid + * File UID + * @param string $mtUid + * Machine translation UID + * + * @return bool + * True on success + * + * @throws SmartlingApiException + */ + public function cancelFileTranslation($fileUid, $mtUid) + { + $requestData = $this->getDefaultRequestData('json', []); + + return $this->sendRequest("files/{$fileUid}/mt/{$mtUid}/cancel", $requestData, self::HTTP_METHOD_POST); + } +} diff --git a/src/FileTranslations/Params/TranslateFileParameters.php b/src/FileTranslations/Params/TranslateFileParameters.php new file mode 100644 index 0000000..1de7dce --- /dev/null +++ b/src/FileTranslations/Params/TranslateFileParameters.php @@ -0,0 +1,60 @@ +set('sourceLocaleId', $sourceLocaleId); + + return $this; + } + + /** + * Set target locale IDs. + * + * @param array $targetLocaleIds + * Array of target language codes (e.g., ['es', 'fr', 'de']) + * + * @return TranslateFileParameters + */ + public function setTargetLocaleIds(array $targetLocaleIds) + { + $this->set('targetLocaleIds', $targetLocaleIds); + + return $this; + } + + /** + * Set callback URL for completion notification. + * + * @param string $callbackUrl + * Webhook URL to be called when translation completes + * + * @return TranslateFileParameters + */ + public function setCallbackUrl($callbackUrl) + { + $this->set('callbackUrl', $callbackUrl); + + return $this; + } +} diff --git a/src/FileTranslations/Params/UploadFileParameters.php b/src/FileTranslations/Params/UploadFileParameters.php new file mode 100644 index 0000000..345a5ff --- /dev/null +++ b/src/FileTranslations/Params/UploadFileParameters.php @@ -0,0 +1,20 @@ + user_id= user_key= ./vendor/bin/phpunit tests/functional/FileTranslationsApiFunctionalTest.php + */ +class FileTranslationsApiFunctionalTest extends TestCase +{ + /** + * @var FileTranslationsApi + */ + private $api; + + /** + * @var string + */ + private $testFilePath; + + /** + * Sets up the test environment. + */ + protected function setUp(): void + { + $accountUid = getenv('account_uid'); + $userId = getenv('user_id'); + $userKey = getenv('user_key'); + + if (empty($accountUid) || empty($userId) || empty($userKey)) { + $this->markTestSkipped( + 'Required environment variables not set: account_uid, user_id, user_key' + ); + } + + $authProvider = AuthTokenProvider::create($userId, $userKey); + $this->api = FileTranslationsApi::create($authProvider, $accountUid); + + $this->testFilePath = __DIR__ . '/../resources/test-fts.json'; + + if (!file_exists($this->testFilePath)) { + $this->markTestSkipped('Test file not found: ' . $this->testFilePath); + } + } + + /** + * Tests the complete file translation workflow: + * 1. Upload file + * 2. Initiate translation + * 3. Poll translation progress + * 4. Download translated file + * + * @covers \Smartling\FileTranslations\FileTranslationsApi::uploadFile + * @covers \Smartling\FileTranslations\FileTranslationsApi::translateFile + * @covers \Smartling\FileTranslations\FileTranslationsApi::getTranslationProgress + * @covers \Smartling\FileTranslations\FileTranslationsApi::downloadTranslatedFile + */ + public function testCompleteTranslationWorkflow() + { + // Step 1: Upload file + $uploadParams = new UploadFileParameters(); + $uploadResult = $this->api->uploadFile( + $this->testFilePath, + 'test-fts-' . time() . '.json', + 'json', + $uploadParams + ); + + $this->assertIsArray($uploadResult); + $this->assertArrayHasKey('fileUid', $uploadResult); + $fileUid = $uploadResult['fileUid']; + + // Step 2: Initiate translation + $translateParams = new TranslateFileParameters(); + $translateParams->setSourceLocaleId('en') + ->setTargetLocaleIds(['es']); + + $translateResult = $this->api->translateFile($fileUid, $translateParams); + + $this->assertIsArray($translateResult); + $this->assertArrayHasKey('mtUid', $translateResult); + $mtUid = $translateResult['mtUid']; + + // Step 3: Poll translation progress + $maxAttempts = 30; // Maximum number of polling attempts + $pollInterval = 2; // Seconds between polls + $completed = false; + + for ($i = 0; $i < $maxAttempts; $i++) { + sleep($pollInterval); + + $progress = $this->api->getTranslationProgress($fileUid, $mtUid); + + $this->assertIsArray($progress); + $this->assertArrayHasKey('status', $progress); + + $status = $progress['status']; + + if ($status === 'COMPLETED') { + $completed = true; + break; + } elseif ($status === 'FAILED') { + $this->fail('Translation failed: ' . json_encode($progress)); + } elseif ($status === 'CANCELLED') { + $this->fail('Translation was cancelled'); + } + + // Status should be IN_PROGRESS + $this->assertEquals('IN_PROGRESS', $status); + } + + if (!$completed) { + $this->markTestIncomplete('Translation did not complete within expected time'); + } + + // Step 4: Download translated file + $translatedContent = $this->api->downloadTranslatedFile($fileUid, $mtUid, 'es'); + + $this->assertIsString($translatedContent); + $this->assertNotEmpty($translatedContent); + + // Verify it's valid JSON + $translatedData = json_decode($translatedContent, true); + $this->assertIsArray($translatedData); + $this->assertNotNull($translatedData); + } + + /** + * Tests downloading all translations as ZIP. + * + * This test depends on having a completed translation from the previous test. + * In practice, you would run this after a successful translation. + * + * @covers \Smartling\FileTranslations\FileTranslationsApi::downloadAllTranslationsZip + */ + public function testDownloadAllTranslationsZip() + { + // This is a simplified test - in practice, you would need + // a fileUid and mtUid from a completed translation + $this->markTestIncomplete('Requires fileUid and mtUid from a completed translation'); + + // Example usage: + // $zipContent = $this->api->downloadAllTranslationsZip($fileUid, $mtUid); + // $this->assertIsString($zipContent); + // $this->assertNotEmpty($zipContent); + } + + /** + * Tests cancelling a translation. + * + * @covers \Smartling\FileTranslations\FileTranslationsApi::cancelFileTranslation + */ + public function testCancelFileTranslation() + { + // Upload file + $uploadParams = new UploadFileParameters(); + $uploadResult = $this->api->uploadFile( + $this->testFilePath, + 'test-cancel-' . time() . '.json', + 'json', + $uploadParams + ); + + $fileUid = $uploadResult['fileUid']; + + // Initiate translation with multiple locales to ensure it takes some time + $translateParams = new TranslateFileParameters(); + $translateParams->setSourceLocaleId('en') + ->setTargetLocaleIds(['es', 'fr', 'de', 'it', 'pt']); + + $translateResult = $this->api->translateFile($fileUid, $translateParams); + $mtUid = $translateResult['mtUid']; + + // Immediately cancel the translation + $cancelResult = $this->api->cancelFileTranslation($fileUid, $mtUid); + + $this->assertTrue($cancelResult); + + // Verify the translation was cancelled + sleep(2); // Give the system time to process the cancellation + + $progress = $this->api->getTranslationProgress($fileUid, $mtUid); + $this->assertIsArray($progress); + $this->assertArrayHasKey('status', $progress); + + // Status should be CANCELLED (or possibly still IN_PROGRESS if cancellation hasn't completed yet) + $this->assertContains($progress['status'], ['CANCELLED', 'IN_PROGRESS']); + } +} diff --git a/tests/resources/test-fts.json b/tests/resources/test-fts.json new file mode 100644 index 0000000..048b833 --- /dev/null +++ b/tests/resources/test-fts.json @@ -0,0 +1,5 @@ +{ + "hello": "Hello, world!", + "goodbye": "Goodbye!", + "welcome": "Welcome to our application" +} diff --git a/tests/unit/FileTranslationsApiTest.php b/tests/unit/FileTranslationsApiTest.php new file mode 100644 index 0000000..04174ee --- /dev/null +++ b/tests/unit/FileTranslationsApiTest.php @@ -0,0 +1,319 @@ +object = $this->getMockBuilder('Smartling\FileTranslations\FileTranslationsApi') + ->setMethods(['readFile']) + ->setConstructorArgs([ + $this->accountUid, + $this->client, + null, + FileTranslationsApi::ENDPOINT_URL, + ]) + ->getMock(); + + $this->object->expects($this->any()) + ->method('readFile') + ->willReturn($this->streamPlaceholder); + + $this->invokeMethod( + $this->object, + 'setAuth', + [ + $this->authProvider + ] + ); + } + + /** + * Sets up the fixture, for example, opens a network connection. + * This method is called before a test is executed. + */ + protected function setUp(): void + { + $this->prepareHttpClientMock(); + $this->prepareAuthProviderMock(); + $this->prepareFileTranslationsApiMock(); + } + + /** + * Tests constructor. + * + * @param string $accountUid + * Account UID string. + * @param \GuzzleHttp\ClientInterface $client + * Mock of Guzzle http client instance. + * @param string|null $expected_base_url + * Base Url string that will be used as based url. + * + * @covers \Smartling\FileTranslations\FileTranslationsApi::__construct + * + * @dataProvider constructorDataProvider + */ + public function testConstructor($accountUid, $client, $expected_base_url) + { + $this->prepareClientResponseMock(); + $api = new FileTranslationsApi($accountUid, $client, null, $expected_base_url); + + $this->assertEquals(\rtrim($expected_base_url, '/') . '/' . $accountUid, + $this->invokeMethod($api, 'getBaseUrl')); + $this->assertEquals($accountUid, $this->invokeMethod($api, 'getAccountUid')); + $this->assertEquals($client, $this->invokeMethod($api, 'getHttpClient')); + } + + /** + * Data provider for testConstructor method. + * + * @return array + */ + public function constructorDataProvider() + { + $this->prepareHttpClientMock(); + + $mockedClient = $this->client; + + return [ + ['account-uid-123', $mockedClient, FileTranslationsApi::ENDPOINT_URL], + ['account-uid-456', $mockedClient, FileTranslationsApi::ENDPOINT_URL . '/'], + ]; + } + + /** + * @covers \Smartling\FileTranslations\FileTranslationsApi::uploadFile + */ + public function testUploadFile() + { + $this->prepareClientResponseMock(); + + $this->client + ->expects($this->once()) + ->method('request') + ->willReturnCallback(function(string $method, string $uri, array $options) { + $this->assertEquals('post', $method); + $this->assertEquals(FileTranslationsApi::ENDPOINT_URL . '/' . $this->accountUid . '/files', $uri); + + // Verify headers + $this->assertEquals('application/json', $options['headers']['Accept']); + $this->assertStringStartsWith('Bearer', $options['headers']['Authorization']); + + // Verify multipart structure + $this->assertArrayHasKey('multipart', $options); + $this->assertIsArray($options['multipart']); + + // Find file and request parts + $filePart = null; + $requestPart = null; + foreach ($options['multipart'] as $part) { + if ($part['name'] === 'file') { + $filePart = $part; + } + if ($part['name'] === 'request') { + $requestPart = $part; + } + } + + $this->assertNotNull($filePart, 'File part should be present'); + $this->assertNotNull($requestPart, 'Request part should be present'); + + // Verify file part + $this->assertEquals($this->streamPlaceholder, $filePart['contents']); + $this->assertEquals('test.json', $filePart['filename']); + + // Verify request part + $requestData = json_decode($requestPart['contents'], true); + $this->assertEquals('json', $requestData['fileType']); + + return $this->responseMock; + }); + + $params = new UploadFileParameters(); + $this->object->uploadFile('tests/resources/test-fts.json', 'test.json', 'json', $params); + } + + /** + * @covers \Smartling\FileTranslations\FileTranslationsApi::translateFile + */ + public function testTranslateFile() + { + $this->prepareClientResponseMock(); + + $fileUid = 'file-uid-123'; + + $this->client + ->expects($this->once()) + ->method('request') + ->willReturnCallback(function(string $method, string $uri, array $options) use ($fileUid) { + $this->assertEquals('post', $method); + $this->assertEquals( + FileTranslationsApi::ENDPOINT_URL . '/' . $this->accountUid . '/files/' . $fileUid . '/mt', + $uri + ); + + // Verify JSON body + $this->assertArrayHasKey('json', $options); + $this->assertEquals('en', $options['json']['sourceLocaleId']); + $this->assertEquals(['es', 'fr', 'de'], $options['json']['targetLocaleIds']); + $this->assertEquals('https://example.com/callback', $options['json']['callbackUrl']); + + return $this->responseMock; + }); + + $params = new TranslateFileParameters(); + $params->setSourceLocaleId('en') + ->setTargetLocaleIds(['es', 'fr', 'de']) + ->setCallbackUrl('https://example.com/callback'); + + $this->object->translateFile($fileUid, $params); + } + + /** + * @covers \Smartling\FileTranslations\FileTranslationsApi::getTranslationProgress + */ + public function testGetTranslationProgress() + { + $this->prepareClientResponseMock(); + + $fileUid = 'file-uid-123'; + $mtUid = 'mt-uid-456'; + + $this->client + ->expects($this->once()) + ->method('request') + ->willReturnCallback(function(string $method, string $uri, array $options) use ($fileUid, $mtUid) { + $this->assertEquals('get', $method); + $this->assertEquals( + FileTranslationsApi::ENDPOINT_URL . '/' . $this->accountUid . '/files/' . $fileUid . '/mt/' . $mtUid . '/status', + $uri + ); + + return $this->responseMock; + }); + + $this->object->getTranslationProgress($fileUid, $mtUid); + } + + /** + * @covers \Smartling\FileTranslations\FileTranslationsApi::downloadTranslatedFile + */ + public function testDownloadTranslatedFile() + { + // Create a mock response with raw content + $rawContent = '{"translated": "content"}'; + $stream = $this->createMock(\Psr\Http\Message\StreamInterface::class); + $stream->method('__toString')->willReturn($rawContent); + + $responseMock = $this->createMock(\GuzzleHttp\Psr7\Response::class); + $responseMock->method('getBody')->willReturn($stream); + $responseMock->method('getStatusCode')->willReturn(200); + + $fileUid = 'file-uid-123'; + $mtUid = 'mt-uid-456'; + $localeId = 'es'; + + $this->client + ->expects($this->once()) + ->method('request') + ->willReturnCallback(function(string $method, string $uri, array $options) use ($fileUid, $mtUid, $localeId, $responseMock) { + $this->assertEquals('get', $method); + $this->assertEquals( + FileTranslationsApi::ENDPOINT_URL . '/' . $this->accountUid . '/files/' . $fileUid . '/mt/' . $mtUid . '/locales/' . $localeId . '/file', + $uri + ); + + // Verify Accept header is removed for raw download + $this->assertArrayNotHasKey('Accept', $options['headers']); + + return $responseMock; + }); + + $result = $this->object->downloadTranslatedFile($fileUid, $mtUid, $localeId); + $this->assertEquals($rawContent, $result); + } + + /** + * @covers \Smartling\FileTranslations\FileTranslationsApi::downloadAllTranslationsZip + */ + public function testDownloadAllTranslationsZip() + { + // Create a mock response with raw content + $rawContent = 'ZIP_BINARY_CONTENT'; + $stream = $this->createMock(\Psr\Http\Message\StreamInterface::class); + $stream->method('__toString')->willReturn($rawContent); + + $responseMock = $this->createMock(\GuzzleHttp\Psr7\Response::class); + $responseMock->method('getBody')->willReturn($stream); + $responseMock->method('getStatusCode')->willReturn(200); + + $fileUid = 'file-uid-123'; + $mtUid = 'mt-uid-456'; + + $this->client + ->expects($this->once()) + ->method('request') + ->willReturnCallback(function(string $method, string $uri, array $options) use ($fileUid, $mtUid, $responseMock) { + $this->assertEquals('get', $method); + $this->assertEquals( + FileTranslationsApi::ENDPOINT_URL . '/' . $this->accountUid . '/files/' . $fileUid . '/mt/' . $mtUid . '/locales/all/file/zip', + $uri + ); + + // Verify Accept header is removed for raw download + $this->assertArrayNotHasKey('Accept', $options['headers']); + + return $responseMock; + }); + + $result = $this->object->downloadAllTranslationsZip($fileUid, $mtUid); + $this->assertEquals($rawContent, $result); + } + + /** + * @covers \Smartling\FileTranslations\FileTranslationsApi::cancelFileTranslation + */ + public function testCancelFileTranslation() + { + $this->prepareClientResponseMock(); + + $fileUid = 'file-uid-123'; + $mtUid = 'mt-uid-456'; + + $this->client + ->expects($this->once()) + ->method('request') + ->willReturnCallback(function(string $method, string $uri, array $options) use ($fileUid, $mtUid) { + $this->assertEquals('post', $method); + $this->assertEquals( + FileTranslationsApi::ENDPOINT_URL . '/' . $this->accountUid . '/files/' . $fileUid . '/mt/' . $mtUid . '/cancel', + $uri + ); + + // Verify it's a JSON request + $this->assertArrayHasKey('json', $options); + + return $this->responseMock; + }); + + $result = $this->object->cancelFileTranslation($fileUid, $mtUid); + // Result can be either true (empty data) or an array (with data) + $this->assertTrue($result === true || is_array($result)); + } +} From 8baa4fbc3cba35bd12fe6e710ce0c547b303fea7 Mon Sep 17 00:00:00 2001 From: Loparev Date: Tue, 3 Feb 2026 10:25:22 +0200 Subject: [PATCH 2/3] Fixed file upload and example --- examples/file-translations-example.php | 11 +++----- src/FileTranslations/FileTranslationsApi.php | 27 +++---------------- .../Params/UploadFileParameters.php | 20 -------------- .../FileTranslationsApiFunctionalTest.php | 9 ++----- tests/unit/FileTranslationsApiTest.php | 5 +--- 5 files changed, 11 insertions(+), 61 deletions(-) delete mode 100644 src/FileTranslations/Params/UploadFileParameters.php diff --git a/examples/file-translations-example.php b/examples/file-translations-example.php index 71b063c..1918fd1 100644 --- a/examples/file-translations-example.php +++ b/examples/file-translations-example.php @@ -20,7 +20,6 @@ use Smartling\AuthApi\AuthTokenProvider; use Smartling\FileTranslations\FileTranslationsApi; use Smartling\FileTranslations\Params\TranslateFileParameters; -use Smartling\FileTranslations\Params\UploadFileParameters; // Parse command line arguments $options = getopt('', [ @@ -57,11 +56,10 @@ // Step 2: Upload file echo "2. Uploading file: {$filePath}\n"; - $uploadParams = new UploadFileParameters(); $fileName = basename($filePath); $fileType = pathinfo($filePath, PATHINFO_EXTENSION); - $uploadResult = $api->uploadFile($filePath, $fileName, $fileType, $uploadParams); + $uploadResult = $api->uploadFile($filePath, $fileName, $fileType); $fileUid = $uploadResult['fileUid']; echo " ✓ File uploaded successfully\n"; echo " File UID: {$fileUid}\n\n"; @@ -86,7 +84,7 @@ for ($i = 0; $i < $maxAttempts; $i++) { $progress = $api->getTranslationProgress($fileUid, $mtUid); - $status = $progress['status']; + $status = $progress['state']; echo " Status: {$status}"; @@ -158,7 +156,7 @@ // Optional: Demonstrate cancellation with a new translation echo "7. (Optional) Demonstrating translation cancellation...\n"; echo " Uploading another file...\n"; - $uploadResult2 = $api->uploadFile($filePath, "cancel-demo-{$fileName}", $fileType, $uploadParams); + $uploadResult2 = $api->uploadFile($filePath, "cancel-demo-{$fileName}", $fileType); $fileUid2 = $uploadResult2['fileUid']; echo " Starting translation to many locales...\n"; @@ -171,13 +169,12 @@ $mtUid2 = $translateResult2['mtUid']; echo " Cancelling translation...\n"; - sleep(1); // Give it a moment to start $api->cancelFileTranslation($fileUid2, $mtUid2); echo " ✓ Cancellation request sent\n"; sleep(2); $progress = $api->getTranslationProgress($fileUid2, $mtUid2); - echo " Final status: {$progress['status']}\n\n"; + echo " Final status: {$progress['state']}\n\n"; echo "=== Example completed successfully ===\n"; diff --git a/src/FileTranslations/FileTranslationsApi.php b/src/FileTranslations/FileTranslationsApi.php index c824dc0..2beb109 100644 --- a/src/FileTranslations/FileTranslationsApi.php +++ b/src/FileTranslations/FileTranslationsApi.php @@ -7,7 +7,6 @@ use Smartling\BaseApiAbstract; use Smartling\Exceptions\SmartlingApiException; use Smartling\FileTranslations\Params\TranslateFileParameters; -use Smartling\FileTranslations\Params\UploadFileParameters; /** * Class FileTranslationsApi @@ -85,6 +84,9 @@ protected function processBodyOptions($requestData = []) if (array_key_exists('filename', $opts)) { $data['filename'] = $opts['filename']; } + } elseif ($data['name'] === 'request') { + // Set Content-Type for the request JSON part + $data['headers'] = ['Content-Type' => 'application/json']; } } } @@ -92,15 +94,6 @@ protected function processBodyOptions($requestData = []) return $opts; } - /** - * Get account UID. - * - * @return string - */ - protected function getAccountUid() - { - return $this->accountUid; - } /** * Uploads a file for machine translation. @@ -111,31 +104,19 @@ protected function getAccountUid() * Logical filename for the file. * @param string $fileType * File type identifier (json, xml, html, etc.) - * @param UploadFileParameters $params - * Optional additional parameters * * @return array * Response data containing fileUid * * @throws SmartlingApiException */ - public function uploadFile($realPath, $fileName, $fileType, UploadFileParameters $params = null) + public function uploadFile($realPath, $fileName, $fileType) { - if (is_null($params)) { - $params = new UploadFileParameters(); - } - // Build request JSON object $requestJson = [ 'fileType' => $fileType, ]; - // Merge any additional parameters - $additionalParams = $params->exportToArray(); - if (!empty($additionalParams)) { - $requestJson = array_merge($requestJson, $additionalParams); - } - // Build multipart request $multipartParams = [ 'file' => $realPath, diff --git a/src/FileTranslations/Params/UploadFileParameters.php b/src/FileTranslations/Params/UploadFileParameters.php deleted file mode 100644 index 345a5ff..0000000 --- a/src/FileTranslations/Params/UploadFileParameters.php +++ /dev/null @@ -1,20 +0,0 @@ -api->uploadFile( $this->testFilePath, 'test-fts-' . time() . '.json', - 'json', - $uploadParams + 'json' ); $this->assertIsArray($uploadResult); @@ -166,12 +163,10 @@ public function testDownloadAllTranslationsZip() public function testCancelFileTranslation() { // Upload file - $uploadParams = new UploadFileParameters(); $uploadResult = $this->api->uploadFile( $this->testFilePath, 'test-cancel-' . time() . '.json', - 'json', - $uploadParams + 'json' ); $fileUid = $uploadResult['fileUid']; diff --git a/tests/unit/FileTranslationsApiTest.php b/tests/unit/FileTranslationsApiTest.php index 04174ee..5876866 100644 --- a/tests/unit/FileTranslationsApiTest.php +++ b/tests/unit/FileTranslationsApiTest.php @@ -4,7 +4,6 @@ use Smartling\FileTranslations\FileTranslationsApi; use Smartling\FileTranslations\Params\TranslateFileParameters; -use Smartling\FileTranslations\Params\UploadFileParameters; /** * Test class for Smartling\FileTranslations\FileTranslationsApi. @@ -76,7 +75,6 @@ public function testConstructor($accountUid, $client, $expected_base_url) $this->assertEquals(\rtrim($expected_base_url, '/') . '/' . $accountUid, $this->invokeMethod($api, 'getBaseUrl')); - $this->assertEquals($accountUid, $this->invokeMethod($api, 'getAccountUid')); $this->assertEquals($client, $this->invokeMethod($api, 'getHttpClient')); } @@ -145,8 +143,7 @@ public function testUploadFile() return $this->responseMock; }); - $params = new UploadFileParameters(); - $this->object->uploadFile('tests/resources/test-fts.json', 'test.json', 'json', $params); + $this->object->uploadFile('tests/resources/test-fts.json', 'test.json', 'json'); } /** From 487f0e3250c69935126a0099a58a0863c5cd5807 Mon Sep 17 00:00:00 2001 From: Loparev Date: Tue, 3 Feb 2026 10:36:31 +0200 Subject: [PATCH 3/3] Fixed functional test --- .../FileTranslationsApiFunctionalTest.php | 31 +++---------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/tests/functional/FileTranslationsApiFunctionalTest.php b/tests/functional/FileTranslationsApiFunctionalTest.php index 49715de..efc2105 100644 --- a/tests/functional/FileTranslationsApiFunctionalTest.php +++ b/tests/functional/FileTranslationsApiFunctionalTest.php @@ -102,9 +102,9 @@ public function testCompleteTranslationWorkflow() $progress = $this->api->getTranslationProgress($fileUid, $mtUid); $this->assertIsArray($progress); - $this->assertArrayHasKey('status', $progress); + $this->assertArrayHasKey('state', $progress); - $status = $progress['status']; + $status = $progress['state']; if ($status === 'COMPLETED') { $completed = true; @@ -126,7 +126,6 @@ public function testCompleteTranslationWorkflow() // Step 4: Download translated file $translatedContent = $this->api->downloadTranslatedFile($fileUid, $mtUid, 'es'); - $this->assertIsString($translatedContent); $this->assertNotEmpty($translatedContent); // Verify it's valid JSON @@ -135,26 +134,6 @@ public function testCompleteTranslationWorkflow() $this->assertNotNull($translatedData); } - /** - * Tests downloading all translations as ZIP. - * - * This test depends on having a completed translation from the previous test. - * In practice, you would run this after a successful translation. - * - * @covers \Smartling\FileTranslations\FileTranslationsApi::downloadAllTranslationsZip - */ - public function testDownloadAllTranslationsZip() - { - // This is a simplified test - in practice, you would need - // a fileUid and mtUid from a completed translation - $this->markTestIncomplete('Requires fileUid and mtUid from a completed translation'); - - // Example usage: - // $zipContent = $this->api->downloadAllTranslationsZip($fileUid, $mtUid); - // $this->assertIsString($zipContent); - // $this->assertNotEmpty($zipContent); - } - /** * Tests cancelling a translation. * @@ -189,9 +168,9 @@ public function testCancelFileTranslation() $progress = $this->api->getTranslationProgress($fileUid, $mtUid); $this->assertIsArray($progress); - $this->assertArrayHasKey('status', $progress); + $this->assertArrayHasKey('state', $progress); - // Status should be CANCELLED (or possibly still IN_PROGRESS if cancellation hasn't completed yet) - $this->assertContains($progress['status'], ['CANCELLED', 'IN_PROGRESS']); + // Status should be CANCELLED. + $this->assertEquals('CANCELED', $progress['state']); } }