diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 1261a9dd6c76f..56ce65e95809f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -1552,6 +1552,7 @@ private function registerTranslatorConfiguration(array $config, ContainerBuilder TranslationBridge\Crowdin\CrowdinProviderFactory::class => 'translation.provider_factory.crowdin', TranslationBridge\Loco\LocoProviderFactory::class => 'translation.provider_factory.loco', TranslationBridge\Lokalise\LokaliseProviderFactory::class => 'translation.provider_factory.lokalise', + PhraseProviderFactory::class => 'translation.provider_factory.phrase', ]; $parentPackages = ['symfony/framework-bundle', 'symfony/translation', 'symfony/http-client']; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php index ccd7a69f5f9a9..ccb6820848d0c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/translation_providers.php @@ -14,6 +14,7 @@ use Symfony\Component\Translation\Bridge\Crowdin\CrowdinProviderFactory; use Symfony\Component\Translation\Bridge\Loco\LocoProviderFactory; use Symfony\Component\Translation\Bridge\Lokalise\LokaliseProviderFactory; +use Symfony\Component\Translation\Bridge\Phrase\PhraseProviderFactory; use Symfony\Component\Translation\Provider\NullProviderFactory; use Symfony\Component\Translation\Provider\TranslationProviderCollection; use Symfony\Component\Translation\Provider\TranslationProviderCollectionFactory; @@ -63,5 +64,16 @@ service('translation.loader.xliff'), ]) ->tag('translation.provider_factory') + + ->set('translation.provider_factory.phrase', PhraseProviderFactory::class) + ->args([ + service('http_client'), + service('logger'), + service('translation.loader.xliff'), + service('translation.dumper.xliff'), + service('cache.app'), + param('kernel.default_locale'), + ]) + ->tag('translation.provider_factory') ; }; diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/.gitattributes b/src/Symfony/Component/Translation/Bridge/Phrase/.gitattributes new file mode 100644 index 0000000000000..84c7add058fb5 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/.gitignore b/src/Symfony/Component/Translation/Bridge/Phrase/.gitignore new file mode 100644 index 0000000000000..d769eb51de25d --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/.gitignore @@ -0,0 +1,3 @@ +vendor +.phpunit.result.cache +composer.lock diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/CHANGELOG.md b/src/Symfony/Component/Translation/Bridge/Phrase/CHANGELOG.md new file mode 100644 index 0000000000000..13684fea9f21b --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +6.3 +--- + + * Create the bridge diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/Config/ReadConfig.php b/src/Symfony/Component/Translation/Bridge/Phrase/Config/ReadConfig.php new file mode 100644 index 0000000000000..b818116dbd920 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/Config/ReadConfig.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Phrase\Config; + +use Symfony\Component\Translation\Provider\Dsn; + +/** + * @author wicliff + */ +class ReadConfig +{ + private const DEFAULTS = [ + 'file_format' => 'symfony_xliff', + 'include_empty_translations' => '1', + 'tags' => [], + 'format_options' => [ + 'enclose_in_cdata' => '1', + ], + ]; + + private function __construct( + private array $options, + private readonly bool $fallbackEnabled + ) { + } + + /** + * @return $this + */ + public function setTag(string $tag): static + { + $this->options['tags'] = $tag; + + return $this; + } + + public function isFallbackLocaleEnabled(): bool + { + return $this->fallbackEnabled; + } + + /** + * @return $this + */ + public function setFallbackLocale(string $locale): static + { + $this->options['fallback_locale_id'] = $locale; + + return $this; + } + + public function getOptions(): array + { + return $this->options; + } + + /** + * @return $this + */ + public static function fromDsn(Dsn $dsn): static + { + $options = $dsn->getOptions()['read'] ?? []; + + // enforce empty translations when fallback locale is enabled + if (true === $fallbackLocale = filter_var($options['fallback_locale_enabled'] ?? false, \FILTER_VALIDATE_BOOL)) { + $options['include_empty_translations'] = '1'; + } + + unset($options['file_format'], $options['tags'], $options['tag'], $options['fallback_locale_id'], $options['fallback_locale_enabled']); + + $options['format_options'] = array_merge(self::DEFAULTS['format_options'], $options['format_options'] ?? []); + + $configOptions = array_merge(self::DEFAULTS, $options); + + return new self($configOptions, $fallbackLocale); + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/Config/WriteConfig.php b/src/Symfony/Component/Translation/Bridge/Phrase/Config/WriteConfig.php new file mode 100644 index 0000000000000..4cb9153fd5a30 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/Config/WriteConfig.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Phrase\Config; + +use Symfony\Component\Translation\Provider\Dsn; + +/** + * @author wicliff + */ +class WriteConfig +{ + private const DEFAULTS = [ + 'file_format' => 'symfony_xliff', + 'update_translations' => '1', + ]; + + private function __construct( + private array $options, + ) { + } + + /** + * @return $this + */ + public function setTag(string $tag): static + { + $this->options['tags'] = $tag; + + return $this; + } + + /** + * @return $this + */ + public function setLocale(string $locale): static + { + $this->options['locale_id'] = $locale; + + return $this; + } + + public function getOptions(): array + { + return $this->options; + } + + /** + * @return $this + */ + public static function fromDsn(Dsn $dsn): static + { + $options = $dsn->getOptions()['write'] ?? []; + + unset($options['file_format'], $options['tags'], $options['locale_id'], $options['file']); + + $configOptions = array_merge(self::DEFAULTS, $options); + + return new self($configOptions); + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/LICENSE b/src/Symfony/Component/Translation/Bridge/Phrase/LICENSE new file mode 100644 index 0000000000000..3ed9f412ce53d --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2023-present Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php b/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php new file mode 100644 index 0000000000000..dfc9c31d06a5a --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProvider.php @@ -0,0 +1,259 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Phrase; + +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Mime\Part\DataPart; +use Symfony\Component\Mime\Part\Multipart\FormDataPart; +use Symfony\Component\Translation\Bridge\Phrase\Config\ReadConfig; +use Symfony\Component\Translation\Bridge\Phrase\Config\WriteConfig; +use Symfony\Component\Translation\Dumper\XliffFileDumper; +use Symfony\Component\Translation\Exception\ProviderException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Component\Translation\TranslatorBagInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author wicliff + */ +class PhraseProvider implements ProviderInterface +{ + private array $phraseLocales = []; + + public function __construct( + private readonly HttpClientInterface $httpClient, + private readonly LoggerInterface $logger, + private readonly LoaderInterface $loader, + private readonly XliffFileDumper $xliffFileDumper, + private readonly CacheItemPoolInterface $cache, + private readonly string $defaultLocale, + private readonly string $endpoint, + private readonly ReadConfig $readConfig, + private readonly WriteConfig $writeConfig, + ) { + } + + public function __toString(): string + { + return sprintf('phrase://%s', $this->endpoint); + } + + public function write(TranslatorBagInterface $translatorBag): void + { + \assert($translatorBag instanceof TranslatorBag); + + foreach ($translatorBag->getCatalogues() as $catalogue) { + foreach ($catalogue->getDomains() as $domain) { + if (0 === \count($catalogue->all($domain))) { + continue; + } + + $phraseLocale = $this->getLocale($catalogue->getLocale()); + + $content = $this->xliffFileDumper->formatCatalogue($catalogue, $domain, ['default_locale' => $this->defaultLocale]); + $filename = sprintf('%d-%s-%s.xlf', date('YmdHis'), $domain, $catalogue->getLocale()); + + $fields = array_merge($this->writeConfig->setTag($domain)->setLocale($phraseLocale)->getOptions(), ['file' => new DataPart($content, $filename, 'application/xml')]); + + $formData = new FormDataPart($fields); + + $response = $this->httpClient->request('POST', 'uploads', [ + 'body' => $formData->bodyToIterable(), + 'headers' => $formData->getPreparedHeaders()->toArray(), + ]); + + if (201 !== $statusCode = $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to upload translations for domain "%s" to phrase: "%s".', $domain, $response->getContent(false))); + + $this->throwProviderException($statusCode, $response, 'Unable to upload translations to phrase.'); + } + } + } + } + + public function read(array $domains, array $locales): TranslatorBag + { + $translatorBag = new TranslatorBag(); + + foreach ($locales as $locale) { + $phraseLocale = $this->getLocale($locale); + + foreach ($domains as $domain) { + $this->readConfig->setTag($domain); + + if ($this->readConfig->isFallbackLocaleEnabled() && null !== $fallbackLocale = $this->getFallbackLocale($locale)) { + $this->readConfig->setFallbackLocale($fallbackLocale); + } + + $cacheKey = $this->generateCacheKey($locale, $domain, $this->readConfig->getOptions()); + $cacheItem = $this->cache->getItem($cacheKey); + + $headers = []; + $cachedResponse = null; + + if ($cacheItem->isHit() && null !== $cachedResponse = $cacheItem->get()) { + $headers = ['If-None-Match' => $cachedResponse['etag']]; + } + + $response = $this->httpClient->request('GET', 'locales/'.$phraseLocale.'/download', [ + 'query' => $this->readConfig->getOptions(), + 'headers' => $headers, + ]); + + if (200 !== ($statusCode = $response->getStatusCode()) && 304 !== $statusCode) { + $this->logger->error(sprintf('Unable to get translations for locale "%s" from phrase: "%s".', $locale, $response->getContent(false))); + + $this->throwProviderException($statusCode, $response, 'Unable to get translations from phrase.'); + } + + $content = 304 === $statusCode && null !== $cachedResponse ? $cachedResponse['content'] : $response->getContent(); + $translatorBag->addCatalogue($this->loader->load($content, $locale, $domain)); + + // using weak etags, responses for requests with fallback locale enabled can not be reliably cached... + if (false === $this->readConfig->isFallbackLocaleEnabled()) { + $headers = $response->getHeaders(false); + $cacheItem->set(['etag' => $headers['etag'][0], 'modified' => $headers['last-modified'][0], 'content' => $content]); + $this->cache->save($cacheItem); + } + } + } + + return $translatorBag; + } + + public function delete(TranslatorBagInterface $translatorBag): void + { + $keys = [[]]; + + foreach ($translatorBag->getCatalogues() as $catalogue) { + foreach ($catalogue->getDomains() as $domain) { + $keys[] = array_keys($catalogue->all($domain)); + } + } + + $keys = array_unique(array_merge(...$keys)); + $names = array_map(static fn ($v): ?string => preg_replace('/([\s:,])/', '\\\\\\\\$1', $v), $keys); + + foreach ($names as $name) { + $response = $this->httpClient->request('DELETE', 'keys', [ + 'query' => [ + 'q' => 'name:'.$name, + ], + ]); + + if (200 !== $statusCode = $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to delete key "%s" in phrase: "%s".', $name, $response->getContent(false))); + + $this->throwProviderException($statusCode, $response, 'Unable to delete key in phrase.'); + } + } + } + + private function generateCacheKey(string $locale, string $domain, array $options): string + { + array_multisort($options); + + return sprintf('%s.%s.%s', $locale, $domain, sha1(serialize($options))); + } + + private function getLocale(string $locale): string + { + if (!$this->phraseLocales) { + $this->initLocales(); + } + + $phraseCode = str_replace('_', '-', $locale); + + if (!\array_key_exists($phraseCode, $this->phraseLocales)) { + $this->createLocale($phraseCode); + } + + return $this->phraseLocales[$phraseCode]['id']; + } + + private function getFallbackLocale(string $locale): ?string + { + $phraseLocale = str_replace('_', '-', $locale); + + return $this->phraseLocales[$phraseLocale]['fallback_locale']['name'] ?? null; + } + + private function createLocale(string $locale): void + { + $response = $this->httpClient->request('POST', 'locales', [ + 'body' => [ + 'name' => $locale, + 'code' => $locale, + 'default' => $locale === str_replace('_', '-', $this->defaultLocale), + ], + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + ]); + + if (201 !== $statusCode = $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to create locale "%s" in phrase: "%s".', $locale, $response->getContent(false))); + + $this->throwProviderException($statusCode, $response, 'Unable to create locale phrase.'); + } + + $phraseLocale = $response->toArray(); + + $this->phraseLocales[$phraseLocale['name']] = $phraseLocale; + } + + private function initLocales(): void + { + $page = 1; + + do { + $response = $this->httpClient->request('GET', 'locales', [ + 'query' => [ + 'per_page' => 100, + 'page' => $page, + ], + ]); + + if (200 !== $statusCode = $response->getStatusCode()) { + $this->logger->error(sprintf('Unable to get locales from phrase: "%s".', $response->getContent(false))); + + $this->throwProviderException($statusCode, $response, 'Unable to get locales from phrase.'); + } + + foreach ($response->toArray() as $phraseLocale) { + $this->phraseLocales[$phraseLocale['name']] = $phraseLocale; + } + + $pagination = $response->getHeaders()['pagination'][0] ?? '{}'; + $page = json_decode($pagination, true)['next_page'] ?? null; + } while (null !== $page); + } + + private function throwProviderException(int $statusCode, ResponseInterface $response, string $message): void + { + $headers = $response->getHeaders(false); + + throw match (true) { + 429 === $statusCode => new ProviderException(sprintf('Rate limit exceeded (%s). please wait %s seconds.', + $headers['x-rate-limit-limit'][0], + $headers['x-rate-limit-reset'][0] + ), $response), + $statusCode <= 500 => new ProviderException($message, $response), + default => new ProviderException('Provider server error.', $response), + }; + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProviderFactory.php b/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProviderFactory.php new file mode 100644 index 0000000000000..75981e7e6fefe --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/PhraseProviderFactory.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Phrase; + +use Psr\Cache\CacheItemPoolInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\Translation\Bridge\Phrase\Config\ReadConfig; +use Symfony\Component\Translation\Bridge\Phrase\Config\WriteConfig; +use Symfony\Component\Translation\Dumper\XliffFileDumper; +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Provider\AbstractProviderFactory; +use Symfony\Component\Translation\Provider\Dsn; +use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author wicliff + */ +class PhraseProviderFactory extends AbstractProviderFactory +{ + private const HOST = 'api.phrase.com'; + + public function __construct( + private readonly HttpClientInterface $httpClient, + private readonly LoggerInterface $logger, + private readonly LoaderInterface $loader, + private readonly XliffFileDumper $xliffFileDumper, + private readonly CacheItemPoolInterface $cache, + private readonly string $defaultLocale, + ) { + } + + public function create(Dsn $dsn): ProviderInterface + { + if ('phrase' !== $dsn->getScheme()) { + throw new UnsupportedSchemeException($dsn, 'phrase', $this->getSupportedSchemes()); + } + + $endpoint = 'default' === $dsn->getHost() ? self::HOST : $dsn->getHost(); + + if (null !== $port = $dsn->getPort()) { + $endpoint .= ':'.$port; + } + + $client = $this->httpClient->withOptions([ + 'base_uri' => 'https://'.$endpoint.'/v2/projects/'.$this->getUser($dsn).'/', + 'headers' => [ + 'Authorization' => 'token '.$this->getPassword($dsn), + 'User-Agent' => $dsn->getRequiredOption('userAgent'), + ], + ]); + + $readConfig = ReadConfig::fromDsn($dsn); + $writeConfig = WriteConfig::fromDsn($dsn); + + return new PhraseProvider($client, $this->logger, $this->loader, $this->xliffFileDumper, $this->cache, $this->defaultLocale, $endpoint, $readConfig, $writeConfig); + } + + protected function getSupportedSchemes(): array + { + return ['phrase']; + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/README.md b/src/Symfony/Component/Translation/Bridge/Phrase/README.md new file mode 100644 index 0000000000000..077506373634b --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/README.md @@ -0,0 +1,90 @@ +Phrase Translation Provider +============================ + +Provides Phrase integration for Symfony Translation. + +DSN example +----------- + +``` +// .env file +PHRASE_DSN=phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject +``` + +**DSN elements** + +- `PROJECT_ID`: can be retrieved in Phrase from `project settings > API > Project ID` +- `API_TOKEN`: can be created in your [Phrase profile settings](https://app.phrase.com/settings/oauth_access_tokens) +- `default`: endpoint, defaults to `api.phrase.com` + +**Required DSN query parameters** + +- `userAgent`: please read [this](https://developers.phrase.com/api/#overview--identification-via-user-agent) for some examples. + +See [fine tuning your Phrase api calls](#fine-tuning-your-phrase-api-calls) for additional DSN options. + +Phrase locale names +------------------- + +Translations being imported using the Symfony XLIFF format in Phrase, locales are matched on locale name in Phrase. +Therefor it's necessary the locale names should be as defined in [RFC4646](https://www.ietf.org/rfc/rfc4646.txt) (e.g. pt-BR rather than pt_BR). +Not doing so will result in Phrase creating a new locale for the imported keys. + +Locale creation +--------------- + +If you define a locale in your `translation.yaml` which is not configured in your Phrase project, it will be automatically created. Deletion of locales however, is (currently) not managed by this provider. + +Domains as tags +--------------- + +Translations will be tagged in Phrase with the Symfony translation domain they belong to. +Check the [wickedone/phrase-translation-bundle](https://github.com/wickedOne/phrase-translation-bundle) if you need help managing your tags in Phrase. + +Cache +----- + +The read responses from Phrase are cached to speed up the read and delete methods of this provider and also to contribute to the rate limit as little as possible. +Therefor the factory should be initialised with a PSR-6 compatible cache adapter. + +Fine tuning your Phrase api calls +--------------------------------- + +You can fine tune the read and write methods of this provider by adding query parameters to your dsn configuration. +General usage is `read|write[option_name]=value` + +**example: +** `phrase://PROJECT_ID:API_TOKEN@default?read[encoding]=UTF-8&write[update_descriptions]=0` + +**Read** + +In order to read translations from Phrase the [download locale](https://developers.phrase.com/api/#get-/projects/-project_id-/locales/-id-/download) call is made to the Phrase API, supported parameters can be found in their documentation. + +One additional read parameter is `fallback_locale_enabled` (defaults to `0`). When set to `1`, this provider will use the fallback locales as they are configured in Phrase. + +> ❗enabling the fallback locale will disable the caching of the conditional get requests + +**Write** + +In order to write translations to Phrase the [upload](https://developers.phrase.com/api/#post-/projects/-project_id-/uploads) call is made to the Phrase API, supported parameters can be found in their documentation. + +**Default values** +This provider uses the following default values for read and write requests. All but `file_format` and `tags` can be overridden by configuring your DSN query parameters. + +| method(s) | name | type | default value | +|--------------|------------------------------|:------:|-----------------------------------------------| +| read & write | `file_format` | string | symfony_xliff | +| read & write | `tags` | string | dynamically set to symfony translation domain | +| read | `include_empty_translations` | bool | 1 | +| read | `format_options` | array | enclose_in_cdata | +| read | `fallback_locale_enabled` | bool | 0 | +| write | `update_translations` | bool | 1 | + +Resources +--------- + +* [Phrase strings API documentation](https://developers.phrase.com/api/#overview) +* [Contributing](https://symfony.com/doc/current/contributing/index.html) +* [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/Tests/Config/ReadConfigTest.php b/src/Symfony/Component/Translation/Bridge/Phrase/Tests/Config/ReadConfigTest.php new file mode 100644 index 0000000000000..90f53a2581999 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/Tests/Config/ReadConfigTest.php @@ -0,0 +1,168 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Phrase\Tests\Config; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Bridge\Phrase\Config\ReadConfig; +use Symfony\Component\Translation\Provider\Dsn; + +/** + * @author wicliff + */ +class ReadConfigTest extends TestCase +{ + /** + * @dataProvider dsnOptionsProvider + */ + public function testCreateFromDsn(string $dsn, array $expectedOptions) + { + $config = ReadConfig::fromDsn(new Dsn($dsn)); + + $this->assertSame($expectedOptions, $config->getOptions()); + } + + public function testWithTag() + { + $dsn = 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject'; + + $expectedOptions = [ + 'file_format' => 'symfony_xliff', + 'include_empty_translations' => '1', + 'tags' => 'messages', + 'format_options' => [ + 'enclose_in_cdata' => '1', + ], + ]; + + $config = ReadConfig::fromDsn(new Dsn($dsn)); + $config->setTag('messages'); + + $this->assertSame($expectedOptions, $config->getOptions()); + } + + public function testWithTagAndFallbackLocale() + { + $dsn = 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject'; + + $expectedOptions = [ + 'file_format' => 'symfony_xliff', + 'include_empty_translations' => '1', + 'tags' => 'messages', + 'format_options' => [ + 'enclose_in_cdata' => '1', + ], + 'fallback_locale_id' => 'en', + ]; + + $config = ReadConfig::fromDsn(new Dsn($dsn)); + $config->setTag('messages')->setFallbackLocale('en'); + + $this->assertSame($expectedOptions, $config->getOptions()); + } + + public function testFallbackLocaleEnabled() + { + $dsn = 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject&read[fallback_locale_enabled]=1'; + $config = ReadConfig::fromDsn(new Dsn($dsn)); + $this->assertTrue($config->isFallbackLocaleEnabled()); + } + + public function testFallbackLocaleDisabled() + { + $dsn = 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject'; + $config = ReadConfig::fromDsn(new Dsn($dsn)); + $this->assertFalse($config->isFallbackLocaleEnabled()); + } + + public static function dsnOptionsProvider(): \Generator + { + yield 'default options' => [ + 'dsn' => 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject', + 'expected_options' => [ + 'file_format' => 'symfony_xliff', + 'include_empty_translations' => '1', + 'tags' => [], + 'format_options' => [ + 'enclose_in_cdata' => '1', + ], + ], + ]; + + yield 'overwrite non protected options' => [ + 'dsn' => 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject&&read[format_options][enclose_in_cdata]=0&read[include_empty_translations]=0', + 'expected_options' => [ + 'file_format' => 'symfony_xliff', + 'include_empty_translations' => '0', + 'tags' => [], + 'format_options' => [ + 'enclose_in_cdata' => '0', + ], + ], + ]; + + yield 'every single option' => [ + 'dsn' => 'phrase://PROJECT_ID:API_TOKEN@default?read%5Binclude_empty_translations%5D=0&read%5Bformat_options%5D%5Binclude_translation_state%5D=1&read%5Bbranch%5D=foo&read%5Bexclude_empty_zero_forms%5D=1&read%5Binclude_translated_keys%5D=1&read%5Bkeep_notranslate_tags%5D=0&read%5Bencoding%5D=UTF-8&read%5Binclude_unverified_translations%5D=1&read%5Buse_last_reviewed_version%5D=1', + 'expected_options' => [ + 'file_format' => 'symfony_xliff', + 'include_empty_translations' => '0', + 'tags' => [], + 'format_options' => [ + 'enclose_in_cdata' => '1', + 'include_translation_state' => '1', + ], + 'branch' => 'foo', + 'exclude_empty_zero_forms' => '1', + 'include_translated_keys' => '1', + 'keep_notranslate_tags' => '0', + 'encoding' => 'UTF-8', + 'include_unverified_translations' => '1', + 'use_last_reviewed_version' => '1', + ], + ]; + + yield 'overwrite protected options' => [ + 'dsn' => 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject&&read[file_format]=yaml&read[tags][]=foo&read[tags][]=bar', + 'expected_options' => [ + 'file_format' => 'symfony_xliff', + 'include_empty_translations' => '1', + 'tags' => [], + 'format_options' => [ + 'enclose_in_cdata' => '1', + ], + ], + ]; + + yield 'fallback enabled empty translations disabled' => [ + 'dsn' => 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject&read[include_empty_translations]=0&read[fallback_locale_enabled]=1', + 'expected_options' => [ + 'file_format' => 'symfony_xliff', + 'include_empty_translations' => '1', + 'tags' => [], + 'format_options' => [ + 'enclose_in_cdata' => '1', + ], + ], + ]; + + yield 'fallback disabled empty translations disabled' => [ + 'dsn' => 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject&read[include_empty_translations]=0&read[fallback_locale_enabled]=0', + 'expected_options' => [ + 'file_format' => 'symfony_xliff', + 'include_empty_translations' => '0', + 'tags' => [], + 'format_options' => [ + 'enclose_in_cdata' => '1', + ], + ], + ]; + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/Tests/Config/WriteConfigTest.php b/src/Symfony/Component/Translation/Bridge/Phrase/Tests/Config/WriteConfigTest.php new file mode 100644 index 0000000000000..640818cf18221 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/Tests/Config/WriteConfigTest.php @@ -0,0 +1,108 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Phrase\Tests\Config; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Translation\Bridge\Phrase\Config\WriteConfig; +use Symfony\Component\Translation\Provider\Dsn; + +/** + * @author wicliff + */ +class WriteConfigTest extends TestCase +{ + /** + * @dataProvider dsnOptionsProvider + */ + public function testCreateFromDsn(string $dsn, array $expectedOptions) + { + $config = WriteConfig::fromDsn(new Dsn($dsn)); + + $this->assertSame($expectedOptions, $config->getOptions()); + } + + public function testWithTag() + { + $dsn = 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject'; + + $expectedOptions = [ + 'file_format' => 'symfony_xliff', + 'update_translations' => '1', + 'tags' => 'messages', + ]; + + $config = WriteConfig::fromDsn(new Dsn($dsn)); + $config->setTag('messages'); + + $this->assertSame($expectedOptions, $config->getOptions()); + } + + public function testWithTagAndLocale() + { + $dsn = 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject'; + + $expectedOptions = [ + 'file_format' => 'symfony_xliff', + 'update_translations' => '1', + 'tags' => 'messages', + 'locale_id' => 'foo', + ]; + + $config = WriteConfig::fromDsn(new Dsn($dsn)); + $config->setTag('messages')->setLocale('foo'); + + $this->assertSame($expectedOptions, $config->getOptions()); + } + + public static function dsnOptionsProvider(): \Generator + { + yield 'default options' => [ + 'dsn' => 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject', + 'expected_options' => [ + 'file_format' => 'symfony_xliff', + 'update_translations' => '1', + ], + ]; + + yield 'overwrite non protected options' => [ + 'dsn' => 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject&write[update_translations]=0', + 'expected_options' => [ + 'file_format' => 'symfony_xliff', + 'update_translations' => '0', + ], + ]; + + yield 'every single option' => [ + 'dsn' => 'phrase://PROJECT_ID:API_TOKEN@default?write%5Bupdate_translations%5D=1&write%5Bupdate_descriptions%5D=0&write%5Bskip_upload_tags%5D=1&write%5Bskip_unverification%5D=0&write%5Bfile_encoding%5D=UTF-8&write%5Blocale_mapping%5D%5Ben%5D=2&write%5Bformat_options%5D%5Bfoo%5D=bar&write%5Bautotranslate%5D=1&write%5Bmark_reviewed%5D=1', + 'expected_options' => [ + 'file_format' => 'symfony_xliff', + 'update_translations' => '1', + 'update_descriptions' => '0', + 'skip_upload_tags' => '1', + 'skip_unverification' => '0', + 'file_encoding' => 'UTF-8', + 'locale_mapping' => ['en' => '2'], + 'format_options' => ['foo' => 'bar'], + 'autotranslate' => '1', + 'mark_reviewed' => '1', + ], + ]; + + yield 'overwrite protected options' => [ + 'dsn' => 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject&write[file_format]=yaml', + 'expected_options' => [ + 'file_format' => 'symfony_xliff', + 'update_translations' => '1', + ], + ]; + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/Tests/PhraseProviderFactoryTest.php b/src/Symfony/Component/Translation/Bridge/Phrase/Tests/PhraseProviderFactoryTest.php new file mode 100644 index 0000000000000..5eac650385b5f --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/Tests/PhraseProviderFactoryTest.php @@ -0,0 +1,188 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Phrase\Tests; + +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Cache\CacheItemPoolInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\Translation\Bridge\Phrase\PhraseProviderFactory; +use Symfony\Component\Translation\Dumper\XliffFileDumper; +use Symfony\Component\Translation\Exception\IncompleteDsnException; +use Symfony\Component\Translation\Exception\MissingRequiredOptionException; +use Symfony\Component\Translation\Exception\UnsupportedSchemeException; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\Provider\Dsn; + +/** + * @author wicliff + */ +class PhraseProviderFactoryTest extends TestCase +{ + private MockObject&MockHttpClient $httpClient; + private MockObject&LoggerInterface $logger; + private MockObject&LoaderInterface $loader; + private MockObject&XliffFileDumper $xliffFileDumper; + private MockObject&CacheItemPoolInterface $cache; + private string $defaultLocale; + + /** + * @dataProvider supportsProvider + */ + public function testSupports(bool $expected, string $dsn) + { + $factory = $this->createFactory(); + + $this->assertSame($expected, $factory->supports(new Dsn($dsn))); + } + + /** + * @dataProvider createProvider + */ + public function testCreate(string $expected, string $dsn) + { + $factory = $this->createFactory(); + $provider = $factory->create(new Dsn($dsn)); + + $this->assertSame($expected, (string) $provider); + } + + /** + * @dataProvider unsupportedSchemeProvider + */ + public function testUnsupportedSchemeException(string $dsn, string $message) + { + $this->expectException(UnsupportedSchemeException::class); + $this->expectExceptionMessage($message); + + $dsn = new Dsn($dsn); + + $this->createFactory() + ->create($dsn); + } + + /** + * @dataProvider incompleteDsnProvider + */ + public function testIncompleteDsnException(string $dsn, string $message) + { + $this->expectException(IncompleteDsnException::class); + $this->expectExceptionMessage($message); + + $dsn = new Dsn($dsn); + + $this->createFactory() + ->create($dsn); + } + + public function testRequiredUserAgentOption() + { + $this->expectException(MissingRequiredOptionException::class); + $this->expectExceptionMessage('The option "userAgent" is required but missing.'); + + $dsn = new Dsn('phrase://PROJECT_ID:API_TOKEN@default'); + + $this->createFactory() + ->create($dsn); + } + + public function testHttpClientConfig() + { + $this->getHttpClient() + ->expects(self::once()) + ->method('withOptions') + ->with([ + 'base_uri' => 'https://api.us.app.phrase.com:8080/v2/projects/PROJECT_ID/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]); + + $dsn = new Dsn('phrase://PROJECT_ID:API_TOKEN@api.us.app.phrase.com:8080?userAgent=myProject'); + + $this->createFactory() + ->create($dsn); + } + + public static function createProvider(): \Generator + { + yield 'default datacenter' => [ + 'phrase://api.phrase.com', + 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject', + ]; + + yield 'us datacenter' => [ + 'phrase://api.us.app.phrase.com:8080', + 'phrase://PROJECT_ID:API_TOKEN@api.us.app.phrase.com:8080?userAgent=myProject', + ]; + } + + public static function incompleteDsnProvider(): \Generator + { + yield ['phrase://default', 'Invalid "phrase://default" provider DSN: User is not set.']; + } + + public static function unsupportedSchemeProvider(): \Generator + { + yield ['unsupported://API_TOKEN@default', 'The "unsupported" scheme is not supported; supported schemes for translation provider "phrase" are: "phrase".']; + } + + public static function supportsProvider(): \Generator + { + yield 'supported' => [true, 'phrase://PROJECT_ID:API_TOKEN@default?userAgent=myProject']; + yield 'not supported' => [false, 'unsupported://PROJECT_ID:API_TOKEN@default?userAgent=myProject']; + } + + private function createFactory(): PhraseProviderFactory + { + return new PhraseProviderFactory( + $this->getHttpClient(), + $this->getLogger(), + $this->getLoader(), + $this->getXliffFileDumper(), + $this->getCache(), + $this->getDefaultLocale() + ); + } + + private function getHttpClient(): MockObject&MockHttpClient + { + return $this->httpClient ??= $this->createMock(MockHttpClient::class); + } + + private function getLogger(): MockObject&LoggerInterface + { + return $this->logger ??= $this->createMock(LoggerInterface::class); + } + + private function getLoader(): MockObject&LoaderInterface + { + return $this->loader ??= $this->createMock(LoaderInterface::class); + } + + private function getXliffFileDumper(): XliffFileDumper&MockObject + { + return $this->xliffFileDumper ??= $this->createMock(XliffFileDumper::class); + } + + private function getCache(): MockObject&CacheItemPoolInterface + { + return $this->cache ??= $this->createMock(CacheItemPoolInterface::class); + } + + private function getDefaultLocale(): string + { + return $this->defaultLocale ??= 'en_GB'; + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/Tests/PhraseProviderTest.php b/src/Symfony/Component/Translation/Bridge/Phrase/Tests/PhraseProviderTest.php new file mode 100644 index 0000000000000..d13aca1f1aa7a --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/Tests/PhraseProviderTest.php @@ -0,0 +1,1262 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Translation\Bridge\Phrase\Tests; + +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpClient\HttpClientTrait; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Component\Translation\Bridge\Phrase\Config\ReadConfig; +use Symfony\Component\Translation\Bridge\Phrase\Config\WriteConfig; +use Symfony\Component\Translation\Bridge\Phrase\PhraseProvider; +use Symfony\Component\Translation\Dumper\XliffFileDumper; +use Symfony\Component\Translation\Exception\ProviderExceptionInterface; +use Symfony\Component\Translation\Loader\LoaderInterface; +use Symfony\Component\Translation\LoggingTranslator; +use Symfony\Component\Translation\MessageCatalogue; +use Symfony\Component\Translation\Provider\ProviderInterface; +use Symfony\Component\Translation\TranslatorBag; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author wicliff + */ +class PhraseProviderTest extends TestCase +{ + use HttpClientTrait { + mergeQueryString as public; + } + + private MockHttpClient $httpClient; + private MockObject&LoggerInterface $logger; + private MockObject&LoaderInterface $loader; + private MockObject&XliffFileDumper $xliffFileDumper; + private MockObject&CacheItemPoolInterface $cache; + private string $defaultLocale; + private string $endpoint; + private MockObject&ReadConfig $readConfig; + private MockObject&WriteConfig $writeConfig; + + /** + * @dataProvider toStringProvider + */ + public function testToString(ProviderInterface $provider, string $expected) + { + self::assertSame($expected, (string) $provider); + } + + /** + * @dataProvider readProvider + */ + public function testRead(string $locale, string $localeId, string $domain, string $responseContent, TranslatorBag $expectedTranslatorBag) + { + $item = $this->createMock(CacheItemInterface::class); + $item->expects(self::once())->method('isHit')->willReturn(false); + + $item + ->expects(self::once()) + ->method('set') + ->with(self::callback(function ($item) use ($responseContent) { + $this->assertSame('W/"625d11cf081b1697cbc216edf6ebb13c"', $item['etag']); + $this->assertSame('Wed, 28 Dec 2022 13:16:45 GMT', $item['modified']); + $this->assertSame($responseContent, $item['content']); + + return true; + })); + + $this->getCache() + ->expects(self::once()) + ->method('getItem') + ->with(self::callback(function ($v) use ($locale, $domain) { + $this->assertStringStartsWith($locale.'.'.$domain.'.', $v); + + return true; + })) + ->willReturn($item); + + $this->readConfigWithDefaultValues($domain); + + $responses = [ + 'init locales' => $this->getInitLocaleResponseMock(), + 'download locale' => $this->getDownloadLocaleResponseMock($domain, $localeId, $responseContent), + ]; + + $this->getLoader() + ->expects($this->once()) + ->method('load') + ->willReturn($expectedTranslatorBag->getCatalogue($locale)); + + $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/1/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.phrase.com/api/v2'); + + $translatorBag = $provider->read([$domain], [$locale]); + + $this->assertSame($expectedTranslatorBag->getCatalogues(), $translatorBag->getCatalogues()); + } + + /** + * @dataProvider readProvider + */ + public function testReadCached(string $locale, string $localeId, string $domain, string $responseContent, TranslatorBag $expectedTranslatorBag) + { + $item = $this->createMock(CacheItemInterface::class); + $item->expects(self::once())->method('isHit')->willReturn(true); + + $cachedResponse = ['etag' => 'W/"625d11cf081b1697cbc216edf6ebb13c"', 'modified' => 'Wed, 28 Dec 2022 13:16:45 GMT', 'content' => $responseContent]; + $item->expects(self::once())->method('get')->willReturn($cachedResponse); + + $item + ->expects(self::once()) + ->method('set') + ->with(self::callback(function ($item) use ($responseContent) { + $this->assertSame('W/"625d11cf081b1697cbc216edf6ebb13c"', $item['etag']); + $this->assertSame('Wed, 28 Dec 2022 13:16:45 GMT', $item['modified']); + $this->assertSame($responseContent, $item['content']); + + return true; + })); + + $this->getCache() + ->expects(self::once()) + ->method('getItem') + ->with(self::callback(function ($v) use ($locale, $domain) { + $this->assertStringStartsWith($locale.'.'.$domain.'.', $v); + + return true; + })) + ->willReturn($item); + + $this->getCache() + ->expects(self::once()) + ->method('save') + ->with($item); + + $this->readConfigWithDefaultValues($domain); + + $responses = [ + 'init locales' => $this->getInitLocaleResponseMock(), + 'download locale' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertContains('If-None-Match: W/"625d11cf081b1697cbc216edf6ebb13c"', $options['headers']); + + return new MockResponse('', ['http_code' => 304, 'response_headers' => [ + 'ETag' => 'W/"625d11cf081b1697cbc216edf6ebb13c"', + 'Last-Modified' => 'Wed, 28 Dec 2022 13:16:45 GMT', + ]]); + }, + ]; + + $this->getLoader() + ->expects($this->once()) + ->method('load') + ->willReturn($expectedTranslatorBag->getCatalogue($locale)); + + $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/1/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.phrase.com/api/v2'); + + $translatorBag = $provider->read([$domain], [$locale]); + + $this->assertSame($expectedTranslatorBag->getCatalogues(), $translatorBag->getCatalogues()); + } + + public function testReadFallbackLocale() + { + $locale = 'en_GB'; + $localeId = '13604ec993beefcdaba732812cdb828c'; + $domain = 'messages'; + + $bag = new TranslatorBag(); + $catalogue = new MessageCatalogue('en_GB', [ + 'general.back' => 'back {{ placeholder }} ', + 'general.cancel' => 'Cancel', + ]); + + $catalogue->setMetadata('general.back', [ + 'notes' => [ + 'this should have a cdata section', + ], + 'target-attributes' => [ + 'state' => 'signed-off', + ], + ]); + + $catalogue->setMetadata('general.cancel', [ + 'target-attributes' => [ + 'state' => 'translated', + ], + ]); + + $bag->addCatalogue($catalogue); + + $item = $this->createMock(CacheItemInterface::class); + $item->expects(self::once())->method('isHit')->willReturn(false); + $item->expects(self::never())->method('set'); + + $this->getCache() + ->expects(self::once()) + ->method('getItem') + ->with(self::callback(function ($v) use ($locale, $domain) { + $this->assertStringStartsWith($locale.'.'.$domain.'.', $v); + + return true; + })) + ->willReturn($item); + + $this->getCache()->expects(self::never())->method('save'); + + $this->getReadConfig() + ->method('getOptions') + ->willReturn([ + 'file_format' => 'symfony_xliff', + 'include_empty_translations' => '1', + 'tags' => $domain, + 'format_options' => [ + 'enclose_in_cdata' => '1', + ], + 'fallback_locale_id' => 'de', + ]); + + $this->getReadConfig()->expects(self::once())->method('setTag')->with($domain)->willReturnSelf(); + $this->getReadConfig()->expects(self::once())->method('setFallbackLocale')->with('de')->willReturnSelf(); + $this->getReadConfig()->expects(self::exactly(2))->method('isFallbackLocaleEnabled')->willReturn(true); + $this->getLoader()->expects($this->once())->method('load')->willReturn($bag->getCatalogue($locale)); + + $responses = [ + 'init locales' => $this->getInitLocaleResponseMock(), + 'download locale' => function (string $method, string $url, array $options) use ($localeId): ResponseInterface { + $query = [ + 'file_format' => 'symfony_xliff', + 'include_empty_translations' => '1', + 'tags' => 'messages', + 'format_options' => [ + 'enclose_in_cdata' => '1', + ], + 'fallback_locale_id' => 'de', + ]; + + $queryString = $this->mergeQueryString(null, $query, true); + + $this->assertSame('GET', $method); + $this->assertSame('https://api.phrase.com/api/v2/projects/1/locales/'.$localeId.'/download?'.$queryString, $url); + $this->assertNotContains('If-None-Match: W/"625d11cf081b1697cbc216edf6ebb13c"', $options['headers']); + $this->assertArrayHasKey('query', $options); + $this->assertSame($query, $options['query']); + + return new MockResponse(); + }, + ]; + + $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/1/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.phrase.com/api/v2'); + + $provider->read([$domain], [$locale]); + } + + /** + * @dataProvider cacheKeyProvider + */ + public function testCacheKeyOptionsSort(array $options, string $expectedKey) + { + $this->getCache()->expects(self::once())->method('getItem')->with($expectedKey); + $this->getReadConfig()->method('getOptions')->willReturn($options); + + $this->getReadConfig()->expects(self::once()) + ->method('setTag') + ->with('messages') + ->willReturnSelf(); + + $this->getLoader()->method('load')->willReturn(new MessageCatalogue('en')); + + $responses = [ + 'init locales' => $this->getInitLocaleResponseMock(), + 'download locale' => function (string $method): ResponseInterface { + $this->assertSame('GET', $method); + + return new MockResponse('', ['http_code' => 200, 'response_headers' => [ + 'ETag' => 'W/"625d11cf081b1697cbc216edf6ebb13c"', + 'Last-Modified' => 'Wed, 28 Dec 2022 13:16:45 GMT', + ]]); + }, + ]; + + $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/1/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.phrase.com/api/v2'); + + $provider->read(['messages'], ['en_GB']); + } + + /** + * @dataProvider cacheItemProvider + */ + public function testGetCacheItem(mixed $cachedValue, bool $hasMatchHeader) + { + $item = $this->createMock(CacheItemInterface::class); + $item->expects(self::once())->method('isHit')->willReturn(true); + $item->method('get')->willReturn($cachedValue); + + $this->getCache() + ->expects(self::once()) + ->method('getItem') + ->willReturn($item); + + $this->getLoader()->method('load')->willReturn(new MessageCatalogue('en')); + + $responses = [ + 'init locales' => $this->getInitLocaleResponseMock(), + 'download locale' => function ($method, $url, $options) use ($hasMatchHeader) { + if ($hasMatchHeader) { + $this->assertArrayHasKey('if-none-match', $options['normalized_headers']); + } else { + $this->assertArrayNotHasKey('if-none-match', $options['normalized_headers']); + } + + return new MockResponse('', ['http_code' => 200, 'response_headers' => [ + 'ETag' => 'W/"625d11cf081b1697cbc216edf6ebb13c"', + 'Last-Modified' => 'Wed, 28 Dec 2022 13:16:45 GMT', + ]]); + }, + ]; + + $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/1/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.phrase.com/api/v2'); + + $provider->read(['messages'], ['en_GB']); + } + + public function cacheItemProvider(): \Generator + { + yield 'null value' => [ + 'cached_value' => null, + 'has_header' => false, + ]; + + $item = ['etag' => 'W\Foo', 'modified' => 'foo', 'content' => 'bar']; + + yield 'correct value' => [ + 'cached_value' => $item, + 'has_header' => true, + ]; + } + + public function testTranslatorBagAssert() + { + $this->expectExceptionMessage('assert($translatorBag instanceof TranslatorBag)'); + + $trans = $this->createMock(LoggingTranslator::class); + $provider = $this->createProvider(); + + $provider->write($trans); + } + + public function cacheKeyProvider(): \Generator + { + yield 'sortorder one' => [ + 'options' => [ + 'file_format' => 'symfony_xliff', + 'include_empty_translations' => '1', + 'tags' => [], + 'format_options' => [ + 'enclose_in_cdata' => '1', + ], + ], + 'expected_key' => 'en_GB.messages.d8c311727922efc26536fc843bfee3e464850205', + ]; + + yield 'sortorder two' => [ + 'options' => [ + 'include_empty_translations' => '1', + 'file_format' => 'symfony_xliff', + 'format_options' => [ + 'enclose_in_cdata' => '1', + ], + 'tags' => [], + ], + 'expected_key' => 'en_GB.messages.d8c311727922efc26536fc843bfee3e464850205', + ]; + } + + /** + * @dataProvider readProviderExceptionsProvider + */ + public function testReadProviderExceptions(int $statusCode, string $expectedExceptionMessage, string $expectedLoggerMessage) + { + $this->expectException(ProviderExceptionInterface::class); + $this->expectExceptionCode(0); + $this->expectExceptionMessage($expectedExceptionMessage); + + $this->getLogger() + ->expects(self::once()) + ->method('error') + ->with($expectedLoggerMessage); + + $responses = [ + 'init locales' => $this->getInitLocaleResponseMock(), + 'provider error' => new MockResponse('provider error', [ + 'http_code' => $statusCode, + 'response_headers' => [ + 'x-rate-limit-limit' => ['1000'], + 'x-rate-limit-reset' => ['60'], + ], + ]), + ]; + + $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/1/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.phrase.com/api/v2'); + + $provider->read(['messages'], ['en_GB']); + } + + /** + * @dataProvider initLocalesExceptionsProvider + */ + public function testInitLocalesExceptions(int $statusCode, string $expectedExceptionMessage, string $expectedLoggerMessage) + { + $this->expectException(ProviderExceptionInterface::class); + $this->expectExceptionCode(0); + $this->expectExceptionMessage($expectedExceptionMessage); + + $this->getLogger() + ->expects(self::once()) + ->method('error') + ->with($expectedLoggerMessage); + + $responses = [ + 'init locales' => new MockResponse('provider error', [ + 'http_code' => $statusCode, + 'response_headers' => [ + 'x-rate-limit-limit' => ['1000'], + 'x-rate-limit-reset' => ['60'], + ], + ]), + ]; + + $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/1/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.phrase.com/api/v2'); + + $provider->read(['messages'], ['en_GB']); + } + + public function testInitLocalesPaginated() + { + $this->readConfigWithDefaultValues('messages'); + + $this->getLoader()->method('load')->willReturn(new MessageCatalogue('en')); + + $responses = [ + 'init locales page 1' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.phrase.com/api/v2/projects/1/locales?per_page=100&page=1', $url); + + return new MockResponse(json_encode([ + [ + 'id' => '5fea6ed5c21767730918a9400e420832', + 'name' => 'de', + 'code' => 'de', + 'fallback_locale' => null, + ], + ], \JSON_THROW_ON_ERROR), [ + 'http_code' => 200, + 'response_headers' => [ + 'pagination' => '{"total_count":31,"current_page":1,"current_per_page":25,"previous_page":null,"next_page":2}', + ], + ]); + }, + 'init locales page 2' => function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.phrase.com/api/v2/projects/1/locales?per_page=100&page=2', $url); + + return new MockResponse(json_encode([ + [ + 'id' => '5fea6ed5c21767730918a9400e420832', + 'name' => 'de', + 'code' => 'de', + 'fallback_locale' => null, + ], + ], \JSON_THROW_ON_ERROR), [ + 'http_code' => 200, + 'response_headers' => [ + 'pagination' => '{"total_count":31,"current_page":2,"current_per_page":25,"previous_page":null,"next_page":null}', + ], + ]); + }, + 'download locale' => $this->getDownloadLocaleResponseMock('messages', '5fea6ed5c21767730918a9400e420832', ''), + ]; + + $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/1/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.phrase.com/api/v2'); + + $provider->read(['messages'], ['de']); + } + + public function testCreateUnknownLocale() + { + $this->readConfigWithDefaultValues('messages'); + + $this->getLoader()->method('load')->willReturn(new MessageCatalogue('en')); + + $responses = [ + 'init locales' => $this->getInitLocaleResponseMock(), + 'create locale' => function (string $method, string $url, array $options = []): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.phrase.com/api/v2/projects/1/locales', $url); + $this->assertSame('Content-Type: application/x-www-form-urlencoded', $options['normalized_headers']['content-type'][0]); + $this->assertArrayHasKey('body', $options); + $this->assertSame('name=nl-NL&code=nl-NL&default=0', $options['body']); + + return new MockResponse(json_encode([ + 'id' => 'zWlsCvkeSK0EBgBVmGpZ4cySWbQ0s1Dk4', + 'name' => 'nl-NL', + 'code' => 'nl-NL', + 'fallback_locale' => null, + ], \JSON_THROW_ON_ERROR), ['http_code' => 201]); + }, + 'download locale' => $this->getDownloadLocaleResponseMock('messages', 'zWlsCvkeSK0EBgBVmGpZ4cySWbQ0s1Dk4', ''), + ]; + + $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/1/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.phrase.com/api/v2'); + + $provider->read(['messages'], ['nl_NL']); + } + + /** + * @dataProvider createLocalesExceptionsProvider + */ + public function testCreateLocaleExceptions(int $statusCode, string $expectedExceptionMessage, string $expectedLoggerMessage) + { + $this->expectException(ProviderExceptionInterface::class); + $this->expectExceptionCode(0); + $this->expectExceptionMessage($expectedExceptionMessage); + + $this->getLogger() + ->expects(self::once()) + ->method('error') + ->with($expectedLoggerMessage); + + $responses = [ + 'init locales' => $this->getInitLocaleResponseMock(), + 'provider error' => new MockResponse('provider error', [ + 'http_code' => $statusCode, + 'response_headers' => [ + 'x-rate-limit-limit' => ['1000'], + 'x-rate-limit-reset' => ['60'], + ], + ]), + ]; + + $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/1/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.phrase.com/api/v2'); + + $provider->read(['messages'], ['nl_NL']); + } + + public function testDelete() + { + $bag = new TranslatorBag(); + $bag->addCatalogue(new MessageCatalogue('en_GB', [ + 'validators' => [], + 'messages' => [ + 'delete this,erroneous:key' => 'translated value', + ], + ])); + + $bag->addCatalogue(new MessageCatalogue('de', [ + 'validators' => [], + 'messages' => [ + 'another:erroneous:key' => 'value to delete', + 'delete this,erroneous:key' => 'translated value', + ], + ])); + + $responses = [ + 'delete key one' => function (string $method, string $url): ResponseInterface { + $this->assertSame('DELETE', $method); + $queryString = $this->mergeQueryString(null, ['q' => 'name:delete\\\\ this\\\\,erroneous\\\\:key'], true); + + $this->assertSame('https://api.phrase.com/api/v2/projects/1/keys?'.$queryString, $url); + + return new MockResponse('', [ + 'http_code' => 200, + ]); + }, + 'delete key two' => function (string $method, string $url): ResponseInterface { + $this->assertSame('DELETE', $method); + $queryString = $this->mergeQueryString(null, ['q' => 'name:another\\\\:erroneous\\\\:key'], true); + + $this->assertSame('https://api.phrase.com/api/v2/projects/1/keys?'.$queryString, $url); + + return new MockResponse('', [ + 'http_code' => 200, + ]); + }, + ]; + + $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/1/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.phrase.com/api/v2'); + + $provider->delete($bag); + } + + /** + * @dataProvider deleteExceptionsProvider + */ + public function testDeleteProviderExceptions(int $statusCode, string $expectedExceptionMessage, string $expectedLoggerMessage) + { + $this->expectException(ProviderExceptionInterface::class); + $this->expectExceptionCode(0); + $this->expectExceptionMessage($expectedExceptionMessage); + + $this->getLogger() + ->expects(self::once()) + ->method('error') + ->with($expectedLoggerMessage); + + $responses = [ + 'provider error' => new MockResponse('provider error', [ + 'http_code' => $statusCode, + 'response_headers' => [ + 'x-rate-limit-limit' => ['1000'], + 'x-rate-limit-reset' => ['60'], + ], + ]), + ]; + + $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/1/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.phrase.com/api/v2'); + + $bag = new TranslatorBag(); + $bag->addCatalogue(new MessageCatalogue('en_GB', [ + 'messages' => [ + 'key.to.delete' => 'translated value', + ], + ])); + + $provider->delete($bag); + } + + /** + * @dataProvider writeProvider + */ + public function testWrite(string $locale, string $localeId, string $domain, string $content, TranslatorBag $bag) + { + $this->writeConfigWithDefaultValues($domain, $localeId); + + $responses = [ + 'init locales' => $this->getInitLocaleResponseMock(), + 'upload file' => function (string $method, string $url, array $options = []) use ($domain, $locale, $localeId, $content): ResponseInterface { + $this->assertSame('POST', $method); + $this->assertSame('https://api.phrase.com/api/v2/projects/1/uploads', $url); + + $testedFileFormat = $testedFileName = $testedContent = $testedLocaleId = $testedTags = $testedUpdateTranslations = false; + + do { + $part = $options['body'](); + + if (strpos($part, 'file_format')) { + $options['body'](); + $this->assertSame('symfony_xliff', $options['body']()); + $testedFileFormat = true; + } + if (preg_match('/filename="([^"]+)/', $part, $matches)) { + $this->assertStringEndsWith($domain.'-'.$locale.'.xlf', $matches[1]); + $testedFileName = true; + } + + if (str_starts_with($part, 'assertSame($content, $part); + $testedContent = true; + } + + if (strpos($part, 'locale_id')) { + $options['body'](); + $this->assertSame($localeId, $options['body']()); + $testedLocaleId = true; + } + + if (strpos($part, 'name="tags"')) { + $options['body'](); + $this->assertSame($domain, $options['body']()); + $testedTags = true; + } + + if (strpos($part, 'name="update_translations"')) { + $options['body'](); + $this->assertSame('1', $options['body']()); + $testedUpdateTranslations = true; + } + } while ('' !== $part); + + $this->assertTrue($testedFileFormat); + $this->assertTrue($testedFileName); + $this->assertTrue($testedContent); + $this->assertTrue($testedLocaleId); + $this->assertTrue($testedTags); + $this->assertTrue($testedUpdateTranslations); + + $this->assertStringStartsWith('Content-Type: multipart/form-data', $options['normalized_headers']['content-type'][0]); + + return new MockResponse('success', ['http_code' => 201]); + }, + ]; + + $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/1/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.phrase.com/api/v2', dumper: new XliffFileDumper()); + + $provider->write($bag); + } + + /** + * @dataProvider writeExceptionsProvider + */ + public function testWriteProviderExceptions(int $statusCode, string $expectedExceptionMessage, string $expectedLoggerMessage) + { + $this->expectException(ProviderExceptionInterface::class); + $this->expectExceptionCode(0); + $this->expectExceptionMessage($expectedExceptionMessage); + + $this->getLogger() + ->expects(self::once()) + ->method('error') + ->with($expectedLoggerMessage); + + $this->getXliffFileDumper() + ->method('formatCatalogue') + ->willReturn(''); + + $responses = [ + 'init locales' => $this->getInitLocaleResponseMock(), + 'provider error' => new MockResponse('provider error', [ + 'http_code' => $statusCode, + 'response_headers' => [ + 'x-rate-limit-limit' => ['1000'], + 'x-rate-limit-reset' => ['60'], + ], + ]), + ]; + + $provider = $this->createProvider(httpClient: (new MockHttpClient($responses))->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/1/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.phrase.com/api/v2'); + + $bag = new TranslatorBag(); + $bag->addCatalogue(new MessageCatalogue('en_GB', [ + 'messages' => [ + 'key.to.delete' => 'translated value', + ], + ])); + + $provider->write($bag); + } + + public function writeProvider(): \Generator + { + $expectedEnglishXliff = <<<'XLIFF' + + + +
+ +
+ + + general.back + + + + general.cancel + Cancel + + +
+
+ +XLIFF; + + $bag = new TranslatorBag(); + $bag->addCatalogue(new MessageCatalogue('en_GB', [ + 'validators' => [], + 'exceptions' => [], + 'messages' => [ + 'general.back' => 'back &!', + 'general.cancel' => 'Cancel', + ], + ])); + + yield 'english messages' => [ + 'locale' => 'en_GB', + 'localeId' => '13604ec993beefcdaba732812cdb828c', + 'domain' => 'messages', + 'responseContent' => $expectedEnglishXliff, + 'bag' => $bag, + ]; + + $expectedGermanXliff = <<<'XLIFF' + + + +
+ +
+ + + general.back + zurück + + + general.cancel + Abbrechen + + +
+
+ +XLIFF; + + $bag = new TranslatorBag(); + $bag->addCatalogue(new MessageCatalogue('de', [ + 'validators' => [ + 'general.back' => 'zurück', + 'general.cancel' => 'Abbrechen', + ], + 'messages' => [], + ])); + + yield 'german validators' => [ + 'locale' => 'de', + 'localeId' => '5fea6ed5c21767730918a9400e420832', + 'domain' => 'validators', + 'responseContent' => $expectedGermanXliff, + 'bag' => $bag, + ]; + } + + public function toStringProvider(): \Generator + { + yield 'default endpoint' => [ + 'provider' => $this->createProvider(httpClient: $this->getHttpClient()->withOptions([ + 'base_uri' => 'https://api.phrase.com/api/v2/projects/PROJECT_ID/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ])), + 'expected' => 'phrase://api.phrase.com', + ]; + + yield 'custom endpoint' => [ + 'provider' => $this->createProvider(httpClient: $this->getHttpClient()->withOptions([ + 'base_uri' => 'https://api.us.app.phrase.com/api/v2/projects/PROJECT_ID/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.us.app.phrase.com'), + 'expected' => 'phrase://api.us.app.phrase.com', + ]; + + yield 'custom endpoint with port' => [ + 'provider' => $this->createProvider(httpClient: $this->getHttpClient()->withOptions([ + 'base_uri' => 'https://api.us.app.phrase.com:8080/api/v2/projects/PROJECT_ID/', + 'headers' => [ + 'Authorization' => 'token API_TOKEN', + 'User-Agent' => 'myProject', + ], + ]), endpoint: 'api.us.app.phrase.com:8080'), + 'expected' => 'phrase://api.us.app.phrase.com:8080', + ]; + } + + public function deleteExceptionsProvider(): array + { + return $this->getExceptionResponses( + exceptionMessage: 'Unable to delete key in phrase.', + loggerMessage: 'Unable to delete key "key.to.delete" in phrase: "provider error".', + statusCode: 500 + ); + } + + public function writeExceptionsProvider(): array + { + return $this->getExceptionResponses( + exceptionMessage: 'Unable to upload translations to phrase.', + loggerMessage: 'Unable to upload translations for domain "messages" to phrase: "provider error".' + ); + } + + public function createLocalesExceptionsProvider(): array + { + return $this->getExceptionResponses( + exceptionMessage: 'Unable to create locale phrase.', + loggerMessage: 'Unable to create locale "nl-NL" in phrase: "provider error".' + ); + } + + public function initLocalesExceptionsProvider(): array + { + return $this->getExceptionResponses( + exceptionMessage: 'Unable to get locales from phrase.', + loggerMessage: 'Unable to get locales from phrase: "provider error".' + ); + } + + public function readProviderExceptionsProvider(): array + { + return $this->getExceptionResponses( + exceptionMessage: 'Unable to get translations from phrase.', + loggerMessage: 'Unable to get translations for locale "en_GB" from phrase: "provider error".' + ); + } + + public function readProvider(): \Generator + { + $bag = new TranslatorBag(); + $catalogue = new MessageCatalogue('en_GB', [ + 'general.back' => 'back {{ placeholder }} ', + 'general.cancel' => 'Cancel', + ]); + + $catalogue->setMetadata('general.back', [ + 'notes' => [ + 'this should have a cdata section', + ], + 'target-attributes' => [ + 'state' => 'signed-off', + ], + ]); + + $catalogue->setMetadata('general.cancel', [ + 'target-attributes' => [ + 'state' => 'translated', + ], + ]); + + $bag->addCatalogue($catalogue); + + yield [ + 'locale' => 'en_GB', + 'locale_id' => '13604ec993beefcdaba732812cdb828c', + 'domain' => 'messages', + 'content' => <<<'XLIFF' + + + + + + ]]> + ]]> + this should have a cdata section + + + Abbrechen + Cancel + + + + +XLIFF, + 'expected bag' => $bag, + ]; + + $bag = new TranslatorBag(); + $catalogue = new MessageCatalogue('de', [ + 'A PHP extension caused the upload to fail.' => 'Eine PHP-Erweiterung verhinderte den Upload.', + 'An empty file is not allowed.' => 'Eine leere Datei ist nicht erlaubt.', + ]); + + $catalogue->setMetadata('An empty file is not allowed.', [ + 'notes' => [ + 'be sure not to allow an empty file', + ], + 'target-attributes' => [ + 'state' => 'signed-off', + ], + ]); + + $catalogue->setMetadata('A PHP extension caused the upload to fail.', [ + 'target-attributes' => [ + 'state' => 'signed-off', + ], + ], 'validators'); + + $bag->addCatalogue($catalogue); + + yield [ + 'locale' => 'de', + 'locale_id' => '5fea6ed5c21767730918a9400e420832', + 'domain' => 'validators', + 'content' => <<<'XLIFF' + + + + + + Eine PHP-Erweiterung verhinderte den Upload. + Eine PHP-Erweiterung verhinderte den Upload. + + + Eine leere Datei ist nicht erlaubt. + Eine leere Datei ist nicht erlaubt. + be sure not to allow an empty file + + + + +XLIFF, + 'expected bag' => $bag, + ]; + } + + private function getExceptionResponses(string $exceptionMessage, string $loggerMessage, int $statusCode = 400): array + { + return [ + 'bad request' => [ + 'statusCode' => $statusCode, + 'exceptionMessage' => $exceptionMessage, + 'loggerMessage' => $loggerMessage, + ], + 'rate limit exceeded' => [ + 'statusCode' => 429, + 'exceptionMessage' => 'Rate limit exceeded (1000). please wait 60 seconds.', + 'loggerMessage' => $loggerMessage, + ], + 'server unavailable' => [ + 'statusCode' => 503, + 'exceptionMessage' => 'Provider server error.', + 'loggerMessage' => $loggerMessage, + ], + ]; + } + + private function getDownloadLocaleResponseMock(string $domain, string $localeId, string $responseContent): \Closure + { + return function (string $method, string $url, array $options) use ($domain, $localeId, $responseContent): ResponseInterface { + $query = [ + 'file_format' => 'symfony_xliff', + 'include_empty_translations' => '1', + 'tags' => $domain, + 'format_options' => [ + 'enclose_in_cdata' => '1', + ], + ]; + + $queryString = $this->mergeQueryString(null, $query, true); + + $this->assertSame('GET', $method); + $this->assertSame('https://api.phrase.com/api/v2/projects/1/locales/'.$localeId.'/download?'.$queryString, $url); + $this->assertArrayHasKey('query', $options); + $this->assertSame($query, $options['query']); + + return new MockResponse($responseContent, ['response_headers' => [ + 'ETag' => 'W/"625d11cf081b1697cbc216edf6ebb13c"', + 'Last-Modified' => 'Wed, 28 Dec 2022 13:16:45 GMT', + ]]); + }; + } + + private function getInitLocaleResponseMock(): \Closure + { + return function (string $method, string $url): ResponseInterface { + $this->assertSame('GET', $method); + $this->assertSame('https://api.phrase.com/api/v2/projects/1/locales?per_page=100&page=1', $url); + + return new MockResponse(json_encode([ + [ + 'id' => '5fea6ed5c21767730918a9400e420832', + 'name' => 'de', + 'code' => 'de', + 'fallback_locale' => null, + ], + [ + 'id' => '13604ec993beefcdaba732812cdb828c', + 'name' => 'en-GB', + 'code' => 'en-GB', + 'fallback_locale' => [ + 'id' => '5fea6ed5c21767730918a9400e420832', + 'name' => 'de', + 'code' => 'de', + ], + ], + ], \JSON_THROW_ON_ERROR)); + }; + } + + private function createProvider(MockHttpClient $httpClient = null, string $endpoint = null, XliffFileDumper $dumper = null): ProviderInterface + { + return new PhraseProvider( + $httpClient ?? $this->getHttpClient(), + $this->getLogger(), + $this->getLoader(), + $dumper ?? $this->getXliffFileDumper(), + $this->getCache(), + $this->getDefaultLocale(), + $endpoint ?? $this->getEndpoint(), + $this->getReadConfig(), + $this->getWriteConfig() + ); + } + + private function getHttpClient(): MockHttpClient + { + return $this->httpClient ??= new MockHttpClient(); + } + + private function getLogger(): MockObject&LoggerInterface + { + return $this->logger ??= $this->createMock(LoggerInterface::class); + } + + private function getLoader(): MockObject&LoaderInterface + { + return $this->loader ??= $this->createMock(LoaderInterface::class); + } + + private function getXliffFileDumper(): XliffFileDumper&MockObject + { + return $this->xliffFileDumper ??= $this->createMock(XliffFileDumper::class); + } + + private function getCache(): MockObject&CacheItemPoolInterface + { + return $this->cache ??= $this->createMock(CacheItemPoolInterface::class); + } + + private function getDefaultLocale(): string + { + return $this->defaultLocale ??= 'en_GB'; + } + + private function getEndpoint(): string + { + return $this->endpoint ??= 'api.phrase.com'; + } + + private function getReadConfig(): ReadConfig&MockObject + { + return $this->readConfig ??= $this->createMock(ReadConfig::class); + } + + private function getWriteConfig(): WriteConfig&MockObject + { + return $this->writeConfig ??= $this->createMock(WriteConfig::class); + } + + private function readConfigWithDefaultValues(string $domain): void + { + $this->getReadConfig() + ->method('getOptions') + ->willReturn([ + 'file_format' => 'symfony_xliff', + 'include_empty_translations' => '1', + 'tags' => $domain, + 'format_options' => [ + 'enclose_in_cdata' => '1', + ], + ]); + } + + private function writeConfigWithDefaultValues(string $domain, string $phraseLocale): void + { + $this->getWriteConfig() + ->method('getOptions') + ->willReturn([ + 'file_format' => 'symfony_xliff', + 'update_translations' => '1', + 'tags' => $domain, + 'locale_id' => $phraseLocale, + ]); + + $this->getWriteConfig() + ->expects(self::once()) + ->method('setTag') + ->with($domain) + ->willReturnSelf(); + + $this->getWriteConfig() + ->expects(self::once()) + ->method('setLocale') + ->with($phraseLocale) + ->willReturnSelf(); + } +} diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/composer.json b/src/Symfony/Component/Translation/Bridge/Phrase/composer.json new file mode 100644 index 0000000000000..222d63e3d0f2a --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/composer.json @@ -0,0 +1,32 @@ +{ + "name": "symfony/phrase-translation-provider", + "type": "symfony-translation-bridge", + "description": "Symfony Phrase Translation Provider Bridge", + "keywords": ["phrase", "translation", "provider"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "wicliff wolda", + "homepage": "https://github.com/wickedOne" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=8.1", + "psr/cache": "^3.0", + "symfony/http-client": "^5.4|^6.0", + "symfony/mime": "^5.4|^6.0", + "symfony/translation": "^5.4|^6.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Translation\\Bridge\\Phrase\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Translation/Bridge/Phrase/phpunit.xml.dist b/src/Symfony/Component/Translation/Bridge/Phrase/phpunit.xml.dist new file mode 100644 index 0000000000000..3923cac64a102 --- /dev/null +++ b/src/Symfony/Component/Translation/Bridge/Phrase/phpunit.xml.dist @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + ./Tests/ + + + + + + ./ + + + ./Tests + ./vendor + + + diff --git a/src/Symfony/Component/Translation/CHANGELOG.md b/src/Symfony/Component/Translation/CHANGELOG.md index 07ba0d031029e..70ca19d0dc31a 100644 --- a/src/Symfony/Component/Translation/CHANGELOG.md +++ b/src/Symfony/Component/Translation/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +6.3 +--- + + * Add `PhraseTranslationProvider` + 6.2.7 -----