From 3db1be002d572eb8ccaa28577751d1a08f96e214 Mon Sep 17 00:00:00 2001 From: Amoifr Date: Sat, 12 Sep 2026 09:22:07 +0200 Subject: [PATCH] [Translation] Make CrowdinProvider::write() add the locales missing from the project Loco, Lokalise and Phrase create the locales a project does not have yet, while Crowdin logged them as ignored and skipped them. The project is now patched with the languages it is missing before the upload. Crowdin spells its language IDs its own way, so the list of the languages it supports is fetched to turn a locale into an ID: "fr_FR" is the "fr" language and "es_ES" is the "es-ES" one. A locale Crowdin does not know is left out of the patch, so the other ones are still added, and it keeps falling back to the warning write() already logged. Editing a project needs a token with a read and write "project.settings" scope, so a failure is logged and those locales are skipped as they were before, rather than throwing. A server error still throws, like the rest of the bridge. --- UPGRADE-8.2.md | 3 + .../Translation/Bridge/Crowdin/CHANGELOG.md | 1 + .../Bridge/Crowdin/CrowdinProvider.php | 116 ++++- .../Crowdin/Tests/CrowdinProviderTest.php | 411 ++++++++++++++++++ 4 files changed, 527 insertions(+), 4 deletions(-) diff --git a/UPGRADE-8.2.md b/UPGRADE-8.2.md index 6cfd4fcf63153..c5f12a2561fe6 100644 --- a/UPGRADE-8.2.md +++ b/UPGRADE-8.2.md @@ -357,6 +357,9 @@ Translation * `TranslationBundle` provides the `translation` configuration and the services `FrameworkBundle` used to provide under `framework.translator` * `FilteringProvider::read()` now returns an empty `TranslatorBag` when none of the requested locales match the configured ones, and a bag of empty catalogues when no requested domain matches, instead of delegating to the wrapped provider + * `CrowdinProvider::write()` now adds the locales missing from the project before uploading, which needs an API + token with a read and write `project.settings` scope. With a narrower token the failure is logged and those + locales are skipped, as they were before Tui --- diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CHANGELOG.md b/src/Symfony/Component/Translation/Bridge/Crowdin/CHANGELOG.md index 1e88b2626c695..6322eaecdac41 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/CHANGELOG.md +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * Add `$projectId` constructor parameter to `CrowdinProvider` * Make `CrowdinProvider::read()` fetch every locale when passed none * Make `CrowdinProvider::read()` fetch every domain when passed none + * Make `CrowdinProvider::write()` add the locales missing from the project 5.4 --- diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php index 10d1554801bd0..5a17043c0dd76 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/CrowdinProvider.php @@ -65,7 +65,8 @@ public function __toString(): string public function write(TranslatorBagInterface $translatorBag): void { $fileIds = $this->getFileIds(); - [$languageIds, $sourceLanguageId] = $this->getLanguageIds(); + [$languageIds, $sourceLanguageId, $targetLanguageIds] = $this->getLanguageIds(); + $languageIds = $this->addMissingLanguages($translatorBag, $languageIds, $targetLanguageIds); $defaultLocaleCatalogue = $translatorBag->getCatalogue($this->defaultLocale); foreach ($defaultLocaleCatalogue->getDomains() as $domain) { @@ -448,7 +449,7 @@ private function getFileIds(): array } /** - * @return array{array, string} The language IDs indexed by locale, then the source language ID + * @return array{array, string, string[]} The language IDs indexed by locale, then the source language ID, then the target language IDs as the project spells them * * @see https://support.crowdin.com/developer/api/v2/#tag/Projects/operation/api.projects.get (Crowdin API) * @see https://support.crowdin.com/developer/enterprise/api/v2/#tag/Projects-and-Groups/operation/api.projects.get (Crowdin Enterprise API) @@ -472,7 +473,7 @@ private function getLanguageIds(): array if (!isset($projectInfo['languageMapping'])) { $this->logger->warning('API key does not allow to access language mapping.'); - return [$languageIds, $projectInfo['sourceLanguageId']]; + return [$languageIds, $projectInfo['sourceLanguageId'], $projectInfo['targetLanguageIds']]; } foreach ($projectInfo['languageMapping'] as $languageId => $mapping) { @@ -484,7 +485,114 @@ private function getLanguageIds(): array } } - return [$languageIds, $projectInfo['sourceLanguageId']]; + return [$languageIds, $projectInfo['sourceLanguageId'], $projectInfo['targetLanguageIds']]; + } + + /** + * Adds the locales of the bag that the project does not have as target languages yet. + * + * Editing a project requires a token with a read and write "project.settings" scope. A narrower + * token cannot add anything: the failure is logged, and write() then skips those locales as before. + * A locale Crowdin does not know is left out, so that the other ones are still added. + * + * @param array $languageIds The language IDs indexed by locale + * @param string[] $targetLanguageIds The target language IDs as the project spells them + * + * @return array The language IDs indexed by locale, including the added ones + * + * @see https://support.crowdin.com/developer/api/v2/#tag/Projects/operation/api.projects.patch (Crowdin API) + * @see https://support.crowdin.com/developer/enterprise/api/v2/#tag/Projects-and-Groups/operation/api.projects.patch (Crowdin Enterprise API) + */ + private function addMissingLanguages(TranslatorBagInterface $translatorBag, array $languageIds, array $targetLanguageIds): array + { + $missingLocales = []; + + foreach ($translatorBag->getCatalogues() as $catalogue) { + $locale = $catalogue->getLocale(); + + if ($locale !== $this->defaultLocale && !isset($languageIds[$locale])) { + $missingLocales[] = $locale; + } + } + + if (!$missingLocales) { + return $languageIds; + } + + $supportedLanguageIds = $this->getSupportedLanguageIds(); + $knownLanguageIds = $newLanguageIds = []; + + foreach ($missingLocales as $locale) { + if (!$languageId = $supportedLanguageIds[$locale] ?? null) { + continue; + } + + if (\in_array($languageId, $languageIds, true)) { + $knownLanguageIds[$locale] = $languageId; + } else { + $newLanguageIds[$locale] = $languageId; + } + } + + if (!$newLanguageIds) { + return $languageIds + $knownLanguageIds; + } + + $response = $this->client->request('PATCH', $this->getProjectEndpoint(), [ + 'json' => [[ + 'op' => 'replace', + 'path' => '/targetLanguageIds', + 'value' => [...$targetLanguageIds, ...array_values(array_unique($newLanguageIds))], + ]], + ]); + + if (200 !== $statusCode = $response->getStatusCode()) { + $this->logger->error(\sprintf('Unable to add the "%s" locale(s) to the Crowdin project: "%s".', implode('", "', array_keys($newLanguageIds)), $response->getContent(false))); + + if (500 <= $statusCode) { + throw new ProviderException('Unable to add the missing locales to the Crowdin project.', $response); + } + + return $languageIds + $knownLanguageIds; + } + + return $languageIds + $knownLanguageIds + $newLanguageIds; + } + + /** + * @return array The IDs of the languages Crowdin supports, indexed by their ID and by their locale, both written with underscores + * + * @see https://support.crowdin.com/developer/api/v2/#tag/Languages/operation/api.languages.getMany (Crowdin API) + * @see https://support.crowdin.com/developer/enterprise/api/v2/#tag/Languages/operation/api.languages.getMany (Crowdin Enterprise API) + */ + private function getSupportedLanguageIds(): array + { + $response = $this->client->request('GET', \sprintf('%slanguages', $this->projectId ? '' : '../../'), [ + // Crowdin supports a few hundred languages and caps a page at 500 of them + 'query' => ['limit' => 500], + ]); + + if (200 !== $statusCode = $response->getStatusCode()) { + $this->logger->error(\sprintf('Unable to list the languages Crowdin supports: "%s".', $response->getContent(false))); + + if (500 <= $statusCode) { + throw new ProviderException('Unable to list the languages Crowdin supports.', $response); + } + + return []; + } + + $languageIds = []; + + foreach ($response->toArray()['data'] as $language) { + $id = (string) $language['data']['id']; + + // several languages can share a locale, so an exact ID match wins: "en_US" is the "en-US" language, not the "en" one + $languageIds[str_replace('-', '_', (string) $language['data']['locale'])] ??= $id; + $languageIds[str_replace('-', '_', $id)] = $id; + } + + return $languageIds; } private function getProjectEndpoint(string $endpoint = ''): string diff --git a/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php b/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php index c4052ecd7207f..31dd3fbf88207 100644 --- a/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php +++ b/src/Symfony/Component/Translation/Bridge/Crowdin/Tests/CrowdinProviderTest.php @@ -1153,6 +1153,403 @@ public function testFilesAreAddedBeforeTranslations() $this->assertSame(\count($responses), $httpClient->getRequestsCount()); } + public function testWriteAddsTheLocalesMissingFromTheProject() + { + $arrayLoader = new ArrayLoader(); + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue($arrayLoader->load(['a' => 'trans_en_a'], 'en')); + $translatorBag->addCatalogue($arrayLoader->load(['a' => 'trans_fr_a'], 'fr_FR')); + + $responses = [ + 'listFiles' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new JsonMockResponse(['data' => []]); + }, + 'getProject' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1', $url); + + return new JsonMockResponse(['data' => [ + 'sourceLanguageId' => 'en', + 'targetLanguageIds' => ['de'], + 'languageMapping' => [], + ]]); + }, + 'listLanguages' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/languages?limit=500', $url); + + return self::supportedLanguagesResponse(); + }, + 'addLanguage' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('PATCH', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1', $url); + // the languages the project already has are kept, and "fr_FR" is spelled the way Crowdin does + $this->assertSame('[{"op":"replace","path":"\/targetLanguageIds","value":["de","fr"]}]', $options['body']); + + return new JsonMockResponse(['data' => [ + 'sourceLanguageId' => 'en', + 'targetLanguageIds' => ['de', 'fr'], + 'languageMapping' => [], + ]]); + }, + 'addStorage' => function (string $method, string $url): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + + return new JsonMockResponse(['data' => ['id' => 19]], ['http_code' => 201]); + }, + 'addFile' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + $this->assertSame('{"storageId":19,"name":"messages.xlf"}', $options['body']); + + return new JsonMockResponse(['data' => ['id' => 199, 'name' => 'messages.xlf']], ['http_code' => 201]); + }, + 'addStorage2' => function (string $method, string $url): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + + return new JsonMockResponse(['data' => ['id' => 20]], ['http_code' => 201]); + }, + 'importTranslations' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/translations/imports', $url); + // the locale is uploaded under the language that was just added + $this->assertSame('{"storageId":20,"languageIds":["fr"],"fileId":199}', $options['body']); + + return new JsonMockResponse(['data' => ['identifier' => 'b8bfd653-2882-4b81-b74f-edae8b492b11']], ['http_code' => 202]); + }, + 'checkImport' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/translations/imports/b8bfd653-2882-4b81-b74f-edae8b492b11', $url); + + return new JsonMockResponse(['data' => ['status' => 'finished']]); + }, + ]; + + $httpClient = (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/', + 'auth_bearer' => 'API_TOKEN', + ]); + + $provider = self::createProvider($httpClient, new XliffFileLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com', '1'); + + $provider->write($translatorBag); + + $this->assertSame(\count($responses), $httpClient->getRequestsCount()); + } + + public function testWriteAddsTheSupportedLocalesEvenWhenAnotherOneIsUnsupported() + { + $arrayLoader = new ArrayLoader(); + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue($arrayLoader->load(['a' => 'trans_en_a'], 'en')); + $translatorBag->addCatalogue($arrayLoader->load(['a' => 'trans_de_a'], 'de')); + $translatorBag->addCatalogue($arrayLoader->load(['a' => 'trans_xx_a'], 'xx_XX')); + + $responses = [ + 'listFiles' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new JsonMockResponse(['data' => []]); + }, + 'getProject' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1', $url); + + return new JsonMockResponse(['data' => [ + 'sourceLanguageId' => 'en', + 'targetLanguageIds' => [], + 'languageMapping' => [], + ]]); + }, + 'listLanguages' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/languages?limit=500', $url); + + return self::supportedLanguagesResponse(); + }, + 'addLanguage' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('PATCH', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1', $url); + // "xx_XX" is left out instead of making the whole request fail + $this->assertSame('[{"op":"replace","path":"\/targetLanguageIds","value":["de"]}]', $options['body']); + + return new JsonMockResponse(['data' => [ + 'sourceLanguageId' => 'en', + 'targetLanguageIds' => ['de'], + 'languageMapping' => [], + ]]); + }, + 'addStorage' => function (string $method, string $url): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + + return new JsonMockResponse(['data' => ['id' => 19]], ['http_code' => 201]); + }, + 'addFile' => function (string $method, string $url): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new JsonMockResponse(['data' => ['id' => 199, 'name' => 'messages.xlf']], ['http_code' => 201]); + }, + 'addStorage2' => function (string $method, string $url): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + + return new JsonMockResponse(['data' => ['id' => 20]], ['http_code' => 201]); + }, + 'importTranslations' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/translations/imports', $url); + $this->assertSame('{"storageId":20,"languageIds":["de"],"fileId":199}', $options['body']); + + return new JsonMockResponse(['data' => ['identifier' => 'b8bfd653-2882-4b81-b74f-edae8b492b11']], ['http_code' => 202]); + }, + 'checkImport' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/translations/imports/b8bfd653-2882-4b81-b74f-edae8b492b11', $url); + + return new JsonMockResponse(['data' => ['status' => 'finished']]); + }, + ]; + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once()) + ->method('warning') + ->with('Ignored "xx_XX" locale because it is not configured or mapped in the project.'); + + $httpClient = (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/', + 'auth_bearer' => 'API_TOKEN', + ]); + + $provider = self::createProvider($httpClient, new XliffFileLoader(), $logger, $this->getDefaultLocale(), 'api.crowdin.com', '1'); + + $provider->write($translatorBag); + + $this->assertSame(\count($responses), $httpClient->getRequestsCount()); + } + + public function testWriteDoesNotPatchTheProjectWhenTheMissingLocalesResolveToOneOfItsLanguages() + { + $arrayLoader = new ArrayLoader(); + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue($arrayLoader->load(['a' => 'trans_en_a'], 'en')); + $translatorBag->addCatalogue($arrayLoader->load(['a' => 'trans_fr_a'], 'fr_FR')); + + $responses = [ + 'listFiles' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new JsonMockResponse(['data' => []]); + }, + 'getProject' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1', $url); + + return new JsonMockResponse(['data' => [ + 'sourceLanguageId' => 'en', + 'targetLanguageIds' => ['fr'], + 'languageMapping' => [], + ]]); + }, + 'listLanguages' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/languages?limit=500', $url); + + return self::supportedLanguagesResponse(); + }, + 'addStorage' => function (string $method, string $url): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + + return new JsonMockResponse(['data' => ['id' => 19]], ['http_code' => 201]); + }, + 'addFile' => function (string $method, string $url): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new JsonMockResponse(['data' => ['id' => 199, 'name' => 'messages.xlf']], ['http_code' => 201]); + }, + 'addStorage2' => function (string $method, string $url): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + + return new JsonMockResponse(['data' => ['id' => 20]], ['http_code' => 201]); + }, + 'importTranslations' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/translations/imports', $url); + // the project already has the language "fr_FR" resolves to, so it is used as is + $this->assertSame('{"storageId":20,"languageIds":["fr"],"fileId":199}', $options['body']); + + return new JsonMockResponse(['data' => ['identifier' => 'b8bfd653-2882-4b81-b74f-edae8b492b11']], ['http_code' => 202]); + }, + 'checkImport' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/translations/imports/b8bfd653-2882-4b81-b74f-edae8b492b11', $url); + + return new JsonMockResponse(['data' => ['status' => 'finished']]); + }, + ]; + + $httpClient = (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/', + 'auth_bearer' => 'API_TOKEN', + ]); + + $provider = self::createProvider($httpClient, new XliffFileLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com', '1'); + + $provider->write($translatorBag); + + $this->assertSame(\count($responses), $httpClient->getRequestsCount()); + } + + public function testWriteLogsWhenTheMissingLocalesCannotBeAdded() + { + $arrayLoader = new ArrayLoader(); + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue($arrayLoader->load(['a' => 'trans_en_a'], 'en')); + $translatorBag->addCatalogue($arrayLoader->load(['a' => 'trans_de_a'], 'de')); + + $responses = [ + 'listFiles' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new JsonMockResponse(['data' => []]); + }, + 'getProject' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1', $url); + + return new JsonMockResponse(['data' => [ + 'sourceLanguageId' => 'en', + 'targetLanguageIds' => [], + 'languageMapping' => [], + ]]); + }, + 'listLanguages' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.crowdin.com/api/v2/languages?limit=500', $url); + + return self::supportedLanguagesResponse(); + }, + 'addLanguage' => function (string $method, string $url): ResponseInterface { + $this->assertSame('PATCH', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1', $url); + + // a token without a read and write "project.settings" scope cannot edit the project + return new JsonMockResponse(['error' => ['message' => 'Forbidden']], ['http_code' => 403]); + }, + 'addStorage' => function (string $method, string $url): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/storages', $url); + + return new JsonMockResponse(['data' => ['id' => 19]], ['http_code' => 201]); + }, + 'addFile' => function (string $method, string $url): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.crowdin.com/api/v2/projects/1/files', $url); + + return new JsonMockResponse(['data' => ['id' => 199, 'name' => 'messages.xlf']], ['http_code' => 201]); + }, + ]; + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once()) + ->method('error') + ->with('Unable to add the "de" locale(s) to the Crowdin project: "{"error":{"message":"Forbidden"}}".'); + $logger->expects(self::once()) + ->method('warning') + ->with('Ignored "de" locale because it is not configured or mapped in the project.'); + + $httpClient = (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/', + 'auth_bearer' => 'API_TOKEN', + ]); + + $provider = self::createProvider($httpClient, new XliffFileLoader(), $logger, $this->getDefaultLocale(), 'api.crowdin.com', '1'); + + // the source file is still written, the locale is only skipped + $provider->write($translatorBag); + + $this->assertSame(\count($responses), $httpClient->getRequestsCount()); + } + + public function testWriteThrowsWhenTheProjectCannotBePatched() + { + $arrayLoader = new ArrayLoader(); + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue($arrayLoader->load(['a' => 'trans_en_a'], 'en')); + $translatorBag->addCatalogue($arrayLoader->load(['a' => 'trans_de_a'], 'de')); + + $responses = [ + 'listFiles' => static fn (): ResponseInterface => new JsonMockResponse(['data' => []]), + 'getProject' => static fn (): ResponseInterface => new JsonMockResponse(['data' => [ + 'sourceLanguageId' => 'en', + 'targetLanguageIds' => [], + 'languageMapping' => [], + ]]), + 'listLanguages' => static fn (): ResponseInterface => self::supportedLanguagesResponse(), + 'addLanguage' => static fn (): ResponseInterface => new JsonMockResponse(['error' => ['message' => 'Internal Server Error']], ['http_code' => 500]), + ]; + + $httpClient = (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/', + 'auth_bearer' => 'API_TOKEN', + ]); + + $provider = self::createProvider($httpClient, new XliffFileLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com', '1'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to add the missing locales to the Crowdin project.'); + + $provider->write($translatorBag); + } + + public function testWriteThrowsWhenTheSupportedLanguagesCannotBeListed() + { + $arrayLoader = new ArrayLoader(); + + $translatorBag = new TranslatorBag(); + $translatorBag->addCatalogue($arrayLoader->load(['a' => 'trans_en_a'], 'en')); + $translatorBag->addCatalogue($arrayLoader->load(['a' => 'trans_de_a'], 'de')); + + $responses = [ + 'listFiles' => static fn (): ResponseInterface => new JsonMockResponse(['data' => []]), + 'getProject' => static fn (): ResponseInterface => new JsonMockResponse(['data' => [ + 'sourceLanguageId' => 'en', + 'targetLanguageIds' => [], + 'languageMapping' => [], + ]]), + 'listLanguages' => static fn (): ResponseInterface => new JsonMockResponse(['error' => ['message' => 'Internal Server Error']], ['http_code' => 500]), + ]; + + $httpClient = (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.crowdin.com/api/v2/', + 'auth_bearer' => 'API_TOKEN', + ]); + + $provider = self::createProvider($httpClient, new XliffFileLoader(), $this->getLogger(), $this->getDefaultLocale(), 'api.crowdin.com', '1'); + + $this->expectException(ProviderException::class); + $this->expectExceptionMessage('Unable to list the languages Crowdin supports.'); + + $provider->write($translatorBag); + } + #[DataProvider('getResponsesForOneLocaleAndOneDomain')] public function testReadForOneLocaleAndOneDomain(string $locale, string $domain, string $responseContent, TranslatorBag $expectedTranslatorBag, string $expectedTargetLanguageId) { @@ -2128,4 +2525,18 @@ public function testDeleteUpdateFileServerException() $provider->delete($deletedStrings); } + + /** + * An excerpt of the list Crowdin answers on "GET /languages". + */ + private static function supportedLanguagesResponse(): JsonMockResponse + { + return new JsonMockResponse(['data' => [ + ['data' => ['id' => 'de', 'locale' => 'de-DE']], + ['data' => ['id' => 'en', 'locale' => 'en-US']], + ['data' => ['id' => 'en-US', 'locale' => 'en-US']], + ['data' => ['id' => 'fr', 'locale' => 'fr-FR']], + ['data' => ['id' => 'pt-BR', 'locale' => 'pt-BR']], + ]]); + } }