diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a90d298..51258dfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog. +## [3.1.0] - TBD + +### Added +- Added adapter-based Lang provider support with `DeepL` and `Google Translate` adapters plus shared remote request/caching infrastructure (#533) + +### Changed +- Refactored the Lang package to resolve adapter instances through `LangFactory` configuration and load file translations lazily on first use instead of preloading them during web boot (#533) +- **BREAKING:** Reshaped Lang configuration so `lang.default` now selects the adapter, locale fallback moved to `lang.default_locale`, and the unused `lang.enabled` toggle was removed (#533) +- **BREAKING:** Removed `Lang::isEnabled()` from the public Lang API because it no longer affected runtime behavior (#533) + ## [3.0.3] - 2026-07-10 ### Changed diff --git a/src/App/Traits/WebAppTrait.php b/src/App/Traits/WebAppTrait.php index 5bd97a0d..8c88a2dc 100644 --- a/src/App/Traits/WebAppTrait.php +++ b/src/App/Traits/WebAppTrait.php @@ -53,15 +53,12 @@ private function resolveRoute(): ?MatchedRoute } /** - * @throws LangException|ConfigException|DiException|BaseException|ReflectionException + * Resolve lang config and current locale before controller hooks run. + * @throws LangException|ConfigException|LoaderException|DiException|ReflectionException */ private function loadLanguage(): void { - $lang = LangFactory::get(); - - if ($lang->isEnabled()) { - $lang->load(); - } + LangFactory::get(); } /** diff --git a/src/Lang/Adapters/DeepLAdapter.php b/src/Lang/Adapters/DeepLAdapter.php new file mode 100644 index 00000000..ff925f63 --- /dev/null +++ b/src/Lang/Adapters/DeepLAdapter.php @@ -0,0 +1,131 @@ + $params + */ + public function __construct(string $lang, array $params, ?HttpClient $httpClient = null) + { + $this->lang = $lang; + $this->params = $params; + $this->httpClient = $httpClient ?? new HttpClient(); + } + + public function setLang(string $lang): LangAdapterInterface + { + $this->lang = $lang; + return $this; + } + + /** + * @param array|string|null $params + * @throws BaseException|ReflectionException|ErrorException + */ + public function get(string $key, $params = null): string + { + $text = $this->buildSourceText($key, $params); + + if ($this->shouldBypassProvider($key, $text)) { + return $text; + } + + $cached = $this->getCachedTranslation('deepl', $text); + + if ($cached !== null) { + return $cached; + } + + $authKey = (string) ($this->params['auth_key'] ?? ''); + + if ($authKey === '') { + throw LangException::missingConfig('lang.deepl.auth_key'); + } + + $response = $this->sendRequest( + (string) ($this->params['api_url'] ?? self::API_URL), + $this->buildPayload($text), + [ + 'Authorization' => 'DeepL-Auth-Key ' . $authKey, + 'Content-Type' => 'application/json', + ], + [ + CURLOPT_TIMEOUT => self::TIMEOUT, + ] + ); + + $translation = $this->extractTranslation($response); + + $this->setCachedTranslation('deepl', $text, $translation); + + return $translation; + } + + /** + * @throws LangException + */ + private function buildPayload(string $text): string + { + $payload = [ + 'text' => [$text], + 'target_lang' => strtoupper($this->lang), + ]; + + if (!empty($this->params['source_locale'])) { + $payload['source_lang'] = strtoupper((string) $this->params['source_locale']); + } + + $payloadJson = json_encode($payload); + + if ($payloadJson === false) { + throw LangException::payloadEncodingFailed('DeepL'); + } + + return $payloadJson; + } + + /** + * @param mixed $response + * @throws LangException + */ + private function extractTranslation($response): string + { + if ( + !is_object($response) + || !isset($response->translations) + || !is_array($response->translations) + || !isset($response->translations[0]->text) + || !is_string($response->translations[0]->text) + ) { + throw LangException::invalidProviderResponse('DeepL'); + } + + return $response->translations[0]->text; + } +} diff --git a/src/Lang/Translator.php b/src/Lang/Adapters/FileAdapter.php similarity index 77% rename from src/Lang/Translator.php rename to src/Lang/Adapters/FileAdapter.php index aef2dd5c..e13a2152 100644 --- a/src/Lang/Translator.php +++ b/src/Lang/Adapters/FileAdapter.php @@ -8,8 +8,9 @@ * @link https://quantumphp.io */ -namespace Quantum\Lang; +namespace Quantum\Lang\Adapters; +use Quantum\Lang\Contracts\LangAdapterInterface; use Quantum\Config\Exceptions\ConfigException; use Quantum\Loader\Exceptions\LoaderException; use Quantum\Lang\Exceptions\LangException; @@ -18,23 +19,37 @@ use Dflydev\DotAccessData\Data; use ReflectionException; -/** - * Class Translator - * @package Quantum\Lang - */ -class Translator +class FileAdapter implements LangAdapterInterface { protected string $lang; + /** + * @var array + */ + protected array $params = []; + private ?Data $translations = null; - public function __construct(string $lang) + /** + * @param array $params + */ + public function __construct(string $lang, array $params = []) { $this->lang = $lang; + $this->params = $params; + } + + public function setLang(string $lang): LangAdapterInterface + { + if ($this->lang !== $lang) { + $this->lang = $lang; + $this->flush(); + } + + return $this; } /** - * Load translation files * @throws LangException|LoaderException|ConfigException|DiException|BaseException|ReflectionException */ public function loadTranslations(): void @@ -68,31 +83,14 @@ public function loadTranslations(): void } /** - * Load translations - * @param array $files - * @throws ConfigException|DiException|BaseException|ReflectionException + * @param array|string|null $params */ - private function loadFiles(array $files): void + public function get(string $key, array|string $params = null): string { if ($this->translations === null) { - return; + $this->loadTranslations(); } - foreach ($files as $file) { - $fileName = fs()->fileName($file); - - $this->translations->import([ - $fileName => fs()->require($file), - ]); - } - } - - /** - * Get translation by key - * @param array|string|null $params - */ - public function get(string $key, $params = null): string - { if ($this->translations && $this->translations->has($key)) { $message = $this->translations->get($key); return $params ? _message($message, $params) : $message; @@ -101,11 +99,27 @@ public function get(string $key, $params = null): string return $key; } - /** - * Reset translations - */ public function flush(): void { $this->translations = null; } + + /** + * @param array $files + * @throws ConfigException|DiException|BaseException|ReflectionException + */ + private function loadFiles(array $files): void + { + if ($this->translations === null) { + return; + } + + foreach ($files as $file) { + $fileName = fs()->fileName($file); + + $this->translations->import([ + $fileName => fs()->require($file), + ]); + } + } } diff --git a/src/Lang/Adapters/GoogleTranslateAdapter.php b/src/Lang/Adapters/GoogleTranslateAdapter.php new file mode 100644 index 00000000..42d6ff8d --- /dev/null +++ b/src/Lang/Adapters/GoogleTranslateAdapter.php @@ -0,0 +1,128 @@ + $params + */ + public function __construct(string $lang, array $params, ?HttpClient $httpClient = null) + { + $this->lang = $lang; + $this->params = $params; + $this->httpClient = $httpClient ?? new HttpClient(); + } + + public function setLang(string $lang): LangAdapterInterface + { + $this->lang = $lang; + return $this; + } + + /** + * @param array|string|null $params + * @throws LangException|BaseException|ReflectionException|ErrorException + */ + public function get(string $key, $params = null): string + { + $text = $this->buildSourceText($key, $params); + + if ($this->shouldBypassProvider($key, $text)) { + return $text; + } + + $cached = $this->getCachedTranslation('google_translate', $text); + + if ($cached !== null) { + return $cached; + } + + $apiKey = (string) ($this->params['api_key'] ?? ''); + + if ($apiKey === '') { + throw LangException::missingConfig('lang.google_translate.api_key'); + } + + $response = $this->sendRequest( + (string) ($this->params['api_url'] ?? self::API_URL) . '?key=' . urlencode($apiKey), + $this->buildPayload($text), + [ + 'Content-Type' => 'application/json', + ], + ); + + $translation = $this->extractTranslation($response); + + $this->setCachedTranslation('google_translate', $text, $translation); + + return $translation; + } + + /** + * @throws LangException + */ + private function buildPayload(string $text): string + { + $payload = [ + 'q' => $text, + 'target' => $this->lang, + 'format' => 'text', + ]; + + if (!empty($this->params['source_locale'])) { + $payload['source'] = (string) $this->params['source_locale']; + } + + $payloadJson = json_encode($payload); + + if ($payloadJson === false) { + throw LangException::payloadEncodingFailed('Google Translate'); + } + + return $payloadJson; + } + + /** + * @param mixed $response + * @throws LangException + */ + private function extractTranslation($response): string + { + if ( + !is_object($response) + || !isset($response->data) + || !is_object($response->data) + || !isset($response->data->translations) + || !is_array($response->data->translations) + || !isset($response->data->translations[0]->translatedText) + || !is_string($response->data->translations[0]->translatedText) + ) { + throw LangException::invalidProviderResponse('Google Translate'); + } + + return html_entity_decode($response->data->translations[0]->translatedText, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } +} diff --git a/src/Lang/Contracts/LangAdapterInterface.php b/src/Lang/Contracts/LangAdapterInterface.php new file mode 100644 index 00000000..d845eb1f --- /dev/null +++ b/src/Lang/Contracts/LangAdapterInterface.php @@ -0,0 +1,21 @@ +|null $params + */ + public function get(string $key, array|string $params = null): string; +} diff --git a/src/Lang/Enums/ExceptionMessages.php b/src/Lang/Enums/ExceptionMessages.php index a3e456ca..5a8b174e 100644 --- a/src/Lang/Enums/ExceptionMessages.php +++ b/src/Lang/Enums/ExceptionMessages.php @@ -21,4 +21,12 @@ final class ExceptionMessages extends BaseExceptionMessages public const TRANSLATION_FILES_NOT_FOUND = 'Translation files not found.'; public const MISCONFIGURED_DEFAULT_LANG = 'Misconfigured lang default config.'; + + public const MISCONFIGURED_DEFAULT_ADAPTER = 'Misconfigured lang default adapter config.'; + + public const PAYLOAD_ENCODING_FAILED = 'The translation payload could not be encoded for `{%1}`.'; + + public const INVALID_PROVIDER_RESPONSE = 'The provider `{%1}` returned an invalid translation response.'; + + public const PROVIDER_REQUEST_FAILED = 'The translation request to `{%1}` failed{%2}.'; } diff --git a/src/Lang/Enums/LangType.php b/src/Lang/Enums/LangType.php new file mode 100644 index 00000000..906f2d11 --- /dev/null +++ b/src/Lang/Enums/LangType.php @@ -0,0 +1,24 @@ + FileAdapter::class, + LangType::DEEPL => DeepLAdapter::class, + LangType::GOOGLE_TRANSLATE => GoogleTranslateAdapter::class, + ]; + + /** + * @var array + */ + private array $instances = []; /** * @throws LangException|ConfigException|LoaderException|DiException|ReflectionException */ - public static function get(): Lang + public static function get(?string $adapter = null): Lang { if (!Di::isRegistered(self::class)) { Di::register(self::class); } - return Di::get(self::class)->resolve(); + return Di::get(self::class)->resolve($adapter); } /** - * @throws LangException|ConfigException|DiException|ReflectionException|LoaderException + * @throws LangException|ConfigException|DiException|ReflectionException|LoaderException|BaseException */ - public function resolve(): Lang + public function resolve(?string $adapter = null): Lang { - if ($this->instance !== null) { - return $this->instance; + if (!config()->has('lang')) { + config()->import(new Setup('config', 'lang')); } - [$isEnabled, $supported, $default] = $this->loadLangConfig(); + $supported = (array) config()->get('lang.supported'); + $default = config()->get('lang.default_locale'); + $adapter ??= config()->get('lang.default'); + + if (!$adapter) { + throw LangException::misconfiguredDefaultAdapterConfig(); + } $lang = $this->detectLanguage($supported, $default); - $translator = new Translator($lang); + if (!isset($this->instances[$adapter])) { + $adapterClass = $this->getAdapterClass($adapter); + $this->instances[$adapter] = $this->createInstance($adapterClass, $adapter, $lang); + } - return $this->instance = new Lang($lang, $isEnabled, $translator); + return $this->instances[$adapter]; } /** - * @return array{0: bool, 1: array, 2: string} - * @throws LoaderException|ConfigException|DiException|ReflectionException + * @throws BaseException */ - private function loadLangConfig(): array + private function getAdapterClass(string $adapter): string { - if (!config()->has('lang')) { - config()->import(new Setup('config', 'lang')); + if (!array_key_exists($adapter, self::ADAPTERS)) { + throw LangException::adapterNotSupported($adapter); + } + + return self::ADAPTERS[$adapter]; + } + + /** + * @throws BaseException|ReflectionException + */ + private function createInstance(string $adapterClass, string $adapter, string $lang): Lang + { + $langConfig = (array) config()->get('lang.' . $adapter); + + $langAdapter = $adapter === LangType::FILE + ? new $adapterClass($lang, $langConfig) + : new $adapterClass($lang, $langConfig, new HttpClient()); + + if (!$langAdapter instanceof LangAdapterInterface) { + throw LangException::adapterNotSupported($adapter); } - return [ - filter_var(config()->get('lang.enabled'), FILTER_VALIDATE_BOOLEAN), - (array) config()->get('lang.supported'), - config()->get('lang.default'), - ]; + return new Lang($lang, $langAdapter); } /** diff --git a/src/Lang/Lang.php b/src/Lang/Lang.php index 84b6df12..dc566c1b 100644 --- a/src/Lang/Lang.php +++ b/src/Lang/Lang.php @@ -10,12 +10,9 @@ namespace Quantum\Lang; -use Quantum\Config\Exceptions\ConfigException; -use Quantum\Loader\Exceptions\LoaderException; +use Quantum\Lang\Contracts\LangAdapterInterface; use Quantum\Lang\Exceptions\LangException; use Quantum\App\Exceptions\BaseException; -use Quantum\Di\Exceptions\DiException; -use ReflectionException; /** * Class Lang @@ -25,14 +22,11 @@ class Lang { private ?string $currentLang = null; - private Translator $translator; + private LangAdapterInterface $adapter; - private bool $isEnabled; - - public function __construct(string $lang, bool $isEnabled, Translator $translator) + public function __construct(string $lang, LangAdapterInterface $adapter) { - $this->isEnabled = $isEnabled; - $this->translator = $translator; + $this->adapter = $adapter; $this->setLang($lang); } @@ -43,6 +37,7 @@ public function __construct(string $lang, bool $isEnabled, Translator $translato public function setLang(string $lang): self { $this->currentLang = $lang; + $this->adapter->setLang($lang); return $this; } @@ -54,37 +49,31 @@ public function getLang(): ?string return $this->currentLang; } - /** - * Is multilang enabled - */ - public function isEnabled(): bool + public function getAdapter(): LangAdapterInterface { - return $this->isEnabled; - } - - /** - * Load translations - * @throws LangException|LoaderException|ConfigException|DiException|BaseException|ReflectionException - */ - public function load(): void - { - $this->translator->loadTranslations(); + return $this->adapter; } /** * Get translation by key - * @param array|string|null $params + * @param string|array|null $params */ - public function getTranslation(string $key, $params = null): ?string + public function getTranslation(string $key, array|string $params = null): ?string { - return $this->translator->get($key, $params); + return $this->adapter->get($key, $params); } /** - * Flush loaded translations + * @param array $arguments + * @return mixed + * @throws BaseException */ - public function flush(): void + public function __call(string $method, ?array $arguments) { - $this->translator->flush(); + if (!method_exists($this->adapter, $method)) { + throw LangException::methodNotSupported($method, $this->adapter::class); + } + + return $this->adapter->$method(...$arguments); } } diff --git a/src/Lang/Traits/RemoteAdapterTrait.php b/src/Lang/Traits/RemoteAdapterTrait.php new file mode 100644 index 00000000..8942b09b --- /dev/null +++ b/src/Lang/Traits/RemoteAdapterTrait.php @@ -0,0 +1,193 @@ + + */ + protected array $params = []; + + /** + * @param array|string|null $params + * @throws LangException|LoaderException|ConfigException|DiException|BaseException|ReflectionException + */ + protected function buildSourceText(string $key, array|string $params = null): string + { + if ($this->usesSourceCatalog()) { + $sourceLocale = $this->getSourceLocale(); + + if ($sourceLocale === null || $sourceLocale === '') { + throw LangException::missingConfig($this->getSourceLocaleConfigPath()); + } + + return (new FileAdapter($sourceLocale))->get($key, $params); + } + + return $params ? _message($key, $params) : $key; + } + + protected function shouldBypassProvider(string $key, string $text): bool + { + if ($text === '') { + return true; + } + + if (!$this->usesSourceCatalog()) { + return false; + } + + $sourceLocale = $this->getSourceLocale(); + + return $text === $key + || ($sourceLocale !== null && strcasecmp($sourceLocale, $this->lang) === 0); + } + + /** + * @throws ConfigException|DiException|BaseException|ReflectionException + */ + protected function getCachedTranslation(string $adapter, string $text): ?string + { + if (!$this->isCacheEnabled()) { + return null; + } + + $cacheItem = cache($this->getCacheAdapter())->get($this->getCacheKey($adapter, $text)); + + return is_string($cacheItem) ? $cacheItem : null; + } + + /** + * @throws ConfigException|DiException|BaseException|ReflectionException + */ + protected function setCachedTranslation(string $adapter, string $text, string $translation): void + { + if (!$this->isCacheEnabled()) { + return; + } + + cache($this->getCacheAdapter())->set( + $this->getCacheKey($adapter, $text), + $translation, + $this->getCacheTtl() + ); + } + + /** + * @param array|string|null $data + * @param array $headers + * @param array $options + * @param string $method + * @return mixed + * @throws BaseException + * @throws ErrorException + * @throws LangException + * @throws HttpClientException + */ + protected function sendRequest(string $url, $data = null, array $headers = [], array $options = [], string $method = 'POST'): mixed + { + $request = $this->httpClient + ->createRequest($url) + ->setMethod($method); + + if ($data !== null) { + $request->setData($data); + } + + if ($headers) { + $request->setHeaders($headers); + } + + foreach ($options as $option => $value) { + $request->setOpt($option, $value); + } + + $request->start(); + + $errors = $this->httpClient->getErrors(); + $responseBody = $this->httpClient->getResponseBody(); + + if ($errors) { + throw LangException::providerRequestFailed( + static::class, + json_encode($responseBody ?? $errors) ?: null + ); + } + + return $responseBody; + } + + private function isCacheEnabled(): bool + { + return filter_var($this->params['cache']['enabled'] ?? false, FILTER_VALIDATE_BOOLEAN); + } + + private function getCacheAdapter(): string + { + return (string) ($this->params['cache']['default'] ?? ''); + } + + private function getCacheTtl(): ?int + { + if (!isset($this->params['cache']['ttl'])) { + return null; + } + + return (int) $this->params['cache']['ttl']; + } + + private function getCacheKey(string $adapter, string $text): string + { + $prefix = (string) ($this->params['cache']['prefix'] ?? ''); + $sourceLocale = (string) ($this->params['source_locale'] ?? 'auto'); + + return $prefix . sha1($adapter . '|' . $this->lang . '|' . $sourceLocale . '|' . $text); + } + + private function usesSourceCatalog(): bool + { + return filter_var($this->params['use_source_catalog'] ?? false, FILTER_VALIDATE_BOOLEAN); + } + + private function getSourceLocale(): ?string + { + if (!isset($this->params['source_locale']) || $this->params['source_locale'] === '') { + return null; + } + + return (string) $this->params['source_locale']; + } + + private function getSourceLocaleConfigPath(): string + { + return match (static::class) { + DeepLAdapter::class => 'lang.deepl.source_locale', + GoogleTranslateAdapter::class => 'lang.google_translate.source_locale', + default => 'lang.source_locale', + }; + } +} diff --git a/tests/Unit/App/Adapters/WebAppAdapterTest.php b/tests/Unit/App/Adapters/WebAppAdapterTest.php index 46686b0d..3e512ae3 100644 --- a/tests/Unit/App/Adapters/WebAppAdapterTest.php +++ b/tests/Unit/App/Adapters/WebAppAdapterTest.php @@ -26,12 +26,14 @@ public function tearDown(): void public function testWebAppAdapterStartSuccessfully(): void { request()->create('GET', '/test/am/tests'); + $this->assertFalse(config()->has('lang')); ob_start(); $result = $this->webAppAdapter->start(); ob_end_clean(); $this->assertEquals(0, $result); + $this->assertTrue(config()->has('lang')); $this->assertNull(request()->getMatchedRoute()); $this->assertNull(request()->getUri()); } diff --git a/tests/Unit/Lang/Adapters/DeepLAdapterTest.php b/tests/Unit/Lang/Adapters/DeepLAdapterTest.php new file mode 100644 index 00000000..e3ef1af1 --- /dev/null +++ b/tests/Unit/Lang/Adapters/DeepLAdapterTest.php @@ -0,0 +1,201 @@ +cachePrefix = 'lang_deepl_test_' . uniqid('', true) . ':'; + } + + public function tearDown(): void + { + cache('file')->getAdapter()->clear(); + Mockery::close(); + + parent::tearDown(); + } + + public function testDeepLAdapterReturnsProviderTranslationAndCachesIt(): void + { + $this->currentResponse = (object) [ + 'translations' => [ + (object) ['text' => 'Hola'], + ], + ]; + + $adapter = new DeepLAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->assertEquals('Hola', $adapter->get('Hello')); + $this->assertSame(DeepLAdapter::TIMEOUT, $this->options[$this->url][CURLOPT_TIMEOUT]); + } + + public function testDeepLAdapterReturnsCachedTranslationWithoutProviderCall(): void + { + $this->currentResponse = (object) [ + 'translations' => [ + (object) ['text' => 'Hola'], + ], + ]; + + $firstAdapter = new DeepLAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->assertEquals('Hola', $firstAdapter->get('Hello')); + + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $secondAdapter = new DeepLAdapter('es', $this->getParams(), $httpClientMock); + + $this->assertEquals('Hola', $secondAdapter->get('Hello')); + } + + public function testDeepLAdapterThrowsIfAuthKeyIsMissing(): void + { + $adapter = new DeepLAdapter('es', $this->getParams(['auth_key' => '']), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('Could not load config `lang.deepl.auth_key` properly.'); + + $adapter->get('Hello'); + } + + public function testDeepLAdapterThrowsIfProviderResponseIsInvalid(): void + { + $this->currentResponse = (object) []; + + $adapter = new DeepLAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('The provider `DeepL` returned an invalid translation response.'); + + $adapter->get('Hello'); + } + + public function testDeepLAdapterThrowsIfPayloadCannotBeEncoded(): void + { + $adapter = new DeepLAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('The translation payload could not be encoded for `DeepL`.'); + + $adapter->get("\xB1"); + } + + public function testDeepLAdapterReturnsEmptyStringWithoutProviderCall(): void + { + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $adapter = new DeepLAdapter('es', $this->getParams(), $httpClientMock); + + $this->assertSame('', $adapter->get('')); + } + + public function testDeepLAdapterTranslatesResolvedSourceCatalogText(): void + { + $this->currentResponse = (object) [ + 'translations' => [ + (object) ['text' => 'Hola'], + ], + ]; + + $adapter = new DeepLAdapter('es', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => 'en', + ]), $this->mockHttpClient()); + + $this->assertSame('Hola', $adapter->get('custom.test')); + $this->assertJsonStringEqualsJsonString( + json_encode([ + 'text' => ['Testing'], + 'target_lang' => 'ES', + 'source_lang' => 'EN', + ]), + (string) $this->data[$this->url] + ); + } + + public function testDeepLAdapterReturnsResolvedSourceTextWithoutProviderCallWhenTargetMatchesSourceLocale(): void + { + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $adapter = new DeepLAdapter('en', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => 'en', + ]), $httpClientMock); + + $this->assertSame('Testing', $adapter->get('custom.test')); + } + + public function testDeepLAdapterReturnsKeyWithoutProviderCallWhenSourceCatalogMisses(): void + { + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $adapter = new DeepLAdapter('es', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => 'en', + ]), $httpClientMock); + + $this->assertSame('custom.missing', $adapter->get('custom.missing')); + } + + public function testDeepLAdapterThrowsIfSourceCatalogLocaleIsMissing(): void + { + $adapter = new DeepLAdapter('es', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => null, + ]), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('Could not load config `lang.deepl.source_locale` properly.'); + + $adapter->get('custom.test'); + } + + public function testDeepLAdapterThrowsIfProviderRequestFails(): void + { + $this->currentErrors = ['timeout']; + + $adapter = new DeepLAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('The translation request to `Quantum\\Lang\\Adapters\\DeepLAdapter` failed'); + + $adapter->get('Hello'); + } + + /** + * @param array $overrides + * @return array + */ + private function getParams(array $overrides = []): array + { + return array_replace_recursive([ + 'auth_key' => 'test-auth-key', + 'source_locale' => 'en', + 'cache' => [ + 'enabled' => true, + 'default' => 'file', + 'ttl' => 3600, + 'prefix' => $this->cachePrefix, + ], + ], $overrides); + } +} diff --git a/tests/Unit/Lang/Adapters/FileAdapterTest.php b/tests/Unit/Lang/Adapters/FileAdapterTest.php new file mode 100644 index 00000000..c4e395e5 --- /dev/null +++ b/tests/Unit/Lang/Adapters/FileAdapterTest.php @@ -0,0 +1,48 @@ +assertInstanceOf(FileAdapter::class, $adapter); + } + + public function testFileAdapterLoadTranslations(): void + { + $adapter = new FileAdapter('en'); + + $adapter->loadTranslations(); + + $this->assertEquals('Testing', $adapter->get('custom.test')); + + $this->assertEquals('Information about the value feature', $adapter->get('custom.info', ['param' => 'value'])); + } + + public function testFileAdapterGetTranslation(): void + { + $adapter = new FileAdapter('en'); + + $adapter->loadTranslations(); + + $result = $adapter->get('custom.info', ['new']); + + $this->assertEquals('Information about the new feature', $result); + } + + public function testFileAdapterSetLangKeepsTranslationsWhenLangDoesNotChange(): void + { + $adapter = new FileAdapter('en'); + + $adapter->loadTranslations(); + $adapter->setLang('en'); + + $this->assertEquals('Testing', $adapter->get('custom.test')); + } +} diff --git a/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php b/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php new file mode 100644 index 00000000..1fac0fc0 --- /dev/null +++ b/tests/Unit/Lang/Adapters/GoogleTranslateAdapterTest.php @@ -0,0 +1,218 @@ +cachePrefix = 'lang_google_translate_test_' . uniqid('', true) . ':'; + } + + public function tearDown(): void + { + cache('file')->getAdapter()->clear(); + Mockery::close(); + + parent::tearDown(); + } + + public function testGoogleTranslateAdapterReturnsProviderTranslationAndCachesIt(): void + { + $this->currentResponse = (object) [ + 'data' => (object) [ + 'translations' => [ + (object) ['translatedText' => 'Hola'], + ], + ], + ]; + + $adapter = new GoogleTranslateAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->assertEquals('Hola', $adapter->get('Hello')); + $this->assertStringContainsString('?key=test-api-key', $this->url); + $this->assertStringNotContainsString('q=', $this->url); + $this->assertJsonStringEqualsJsonString( + json_encode([ + 'q' => 'Hello', + 'target' => 'es', + 'format' => 'text', + 'source' => 'en', + ]), + (string) $this->data[$this->url] + ); + } + + public function testGoogleTranslateAdapterReturnsCachedTranslationWithoutProviderCall(): void + { + $this->currentResponse = (object) [ + 'data' => (object) [ + 'translations' => [ + (object) ['translatedText' => 'Hola'], + ], + ], + ]; + + $firstAdapter = new GoogleTranslateAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->assertEquals('Hola', $firstAdapter->get('Hello')); + + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $secondAdapter = new GoogleTranslateAdapter('es', $this->getParams(), $httpClientMock); + + $this->assertEquals('Hola', $secondAdapter->get('Hello')); + } + + public function testGoogleTranslateAdapterThrowsIfApiKeyIsMissing(): void + { + $adapter = new GoogleTranslateAdapter('es', $this->getParams(['api_key' => '']), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('Could not load config `lang.google_translate.api_key` properly.'); + + $adapter->get('Hello'); + } + + public function testGoogleTranslateAdapterThrowsIfProviderResponseIsInvalid(): void + { + $this->currentResponse = (object) []; + + $adapter = new GoogleTranslateAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('The provider `Google Translate` returned an invalid translation response.'); + + $adapter->get('Hello'); + } + + public function testGoogleTranslateAdapterThrowsIfPayloadCannotBeEncoded(): void + { + $adapter = new GoogleTranslateAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('The translation payload could not be encoded for `Google Translate`.'); + + $adapter->get("\xB1"); + } + + public function testGoogleTranslateAdapterReturnsEmptyStringWithoutProviderCall(): void + { + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $adapter = new GoogleTranslateAdapter('es', $this->getParams(), $httpClientMock); + + $this->assertSame('', $adapter->get('')); + } + + public function testGoogleTranslateAdapterTranslatesResolvedSourceCatalogText(): void + { + $this->currentResponse = (object) [ + 'data' => (object) [ + 'translations' => [ + (object) ['translatedText' => 'Hola'], + ], + ], + ]; + + $adapter = new GoogleTranslateAdapter('es', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => 'en', + ]), $this->mockHttpClient()); + + $this->assertSame('Hola', $adapter->get('custom.test')); + $this->assertJsonStringEqualsJsonString( + json_encode([ + 'q' => 'Testing', + 'target' => 'es', + 'format' => 'text', + 'source' => 'en', + ]), + (string) $this->data[$this->url] + ); + } + + public function testGoogleTranslateAdapterReturnsResolvedSourceTextWithoutProviderCallWhenTargetMatchesSourceLocale(): void + { + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $adapter = new GoogleTranslateAdapter('en', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => 'en', + ]), $httpClientMock); + + $this->assertSame('Testing', $adapter->get('custom.test')); + } + + public function testGoogleTranslateAdapterReturnsKeyWithoutProviderCallWhenSourceCatalogMisses(): void + { + $httpClientMock = Mockery::mock(HttpClient::class); + $httpClientMock->shouldNotReceive('createRequest'); + + $adapter = new GoogleTranslateAdapter('es', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => 'en', + ]), $httpClientMock); + + $this->assertSame('custom.missing', $adapter->get('custom.missing')); + } + + public function testGoogleTranslateAdapterThrowsIfSourceCatalogLocaleIsMissing(): void + { + $adapter = new GoogleTranslateAdapter('es', $this->getParams([ + 'use_source_catalog' => true, + 'source_locale' => null, + ]), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('Could not load config `lang.google_translate.source_locale` properly.'); + + $adapter->get('custom.test'); + } + + public function testGoogleTranslateAdapterThrowsIfProviderRequestFails(): void + { + $this->currentErrors = ['timeout']; + + $adapter = new GoogleTranslateAdapter('es', $this->getParams(), $this->mockHttpClient()); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('The translation request to `Quantum\\Lang\\Adapters\\GoogleTranslateAdapter` failed'); + + $adapter->get('Hello'); + } + + /** + * @param array $overrides + * @return array + */ + private function getParams(array $overrides = []): array + { + return array_replace_recursive([ + 'api_key' => 'test-api-key', + 'source_locale' => 'en', + 'cache' => [ + 'enabled' => true, + 'default' => 'file', + 'ttl' => 3600, + 'prefix' => $this->cachePrefix, + ], + ], $overrides); + } +} diff --git a/tests/Unit/Lang/Exceptions/LangExceptionTest.php b/tests/Unit/Lang/Exceptions/LangExceptionTest.php index 62113ac5..7b8892bc 100644 --- a/tests/Unit/Lang/Exceptions/LangExceptionTest.php +++ b/tests/Unit/Lang/Exceptions/LangExceptionTest.php @@ -24,4 +24,49 @@ public function testMisconfiguredDefaultConfig(): void $this->assertSame('Misconfigured lang default config.', $exception->getMessage()); $this->assertSame(E_WARNING, $exception->getCode()); } + + public function testMisconfiguredDefaultAdapterConfig(): void + { + $exception = LangException::misconfiguredDefaultAdapterConfig(); + + $this->assertInstanceOf(LangException::class, $exception); + $this->assertSame('Misconfigured lang default adapter config.', $exception->getMessage()); + $this->assertSame(E_WARNING, $exception->getCode()); + } + + public function testInvalidProviderResponse(): void + { + $exception = LangException::invalidProviderResponse('DeepL'); + + $this->assertInstanceOf(LangException::class, $exception); + $this->assertSame('The provider `DeepL` returned an invalid translation response.', $exception->getMessage()); + $this->assertSame(E_WARNING, $exception->getCode()); + } + + public function testPayloadEncodingFailed(): void + { + $exception = LangException::payloadEncodingFailed('Google Translate'); + + $this->assertInstanceOf(LangException::class, $exception); + $this->assertSame('The translation payload could not be encoded for `Google Translate`.', $exception->getMessage()); + $this->assertSame(E_WARNING, $exception->getCode()); + } + + public function testProviderRequestFailedWithoutDetails(): void + { + $exception = LangException::providerRequestFailed('Google Translate'); + + $this->assertInstanceOf(LangException::class, $exception); + $this->assertSame('The translation request to `Google Translate` failed.', $exception->getMessage()); + $this->assertSame(E_WARNING, $exception->getCode()); + } + + public function testProviderRequestFailedWithDetails(): void + { + $exception = LangException::providerRequestFailed('Google Translate', 'timeout'); + + $this->assertInstanceOf(LangException::class, $exception); + $this->assertSame('The translation request to `Google Translate` failed: timeout.', $exception->getMessage()); + $this->assertSame(E_WARNING, $exception->getCode()); + } } diff --git a/tests/Unit/Lang/Factories/LangFactoryTest.php b/tests/Unit/Lang/Factories/LangFactoryTest.php index e003de59..422844e1 100644 --- a/tests/Unit/Lang/Factories/LangFactoryTest.php +++ b/tests/Unit/Lang/Factories/LangFactoryTest.php @@ -2,8 +2,11 @@ namespace Quantum\Tests\Unit\Lang\Factories; +use Quantum\Lang\Adapters\GoogleTranslateAdapter; use Quantum\Lang\Exceptions\LangException; use Quantum\Lang\Factories\LangFactory; +use Quantum\Lang\Adapters\DeepLAdapter; +use Quantum\Lang\Adapters\FileAdapter; use Quantum\Tests\Unit\AppTestCase; use Quantum\Lang\Lang; use Quantum\Di\Di; @@ -24,8 +27,6 @@ public function testLangFactoryGetLangInstance(): void $this->assertInstanceOf(Lang::class, $lang); $this->assertEquals('en', $lang->getLang()); - - $this->assertTrue($lang->isEnabled()); } public function testLangFactoryGetReturnsSameInstance(): void @@ -37,6 +38,59 @@ public function testLangFactoryGetReturnsSameInstance(): void $this->assertSame($first, $second); } + public function testLangFactoryResolvesFileAdapter(): void + { + $lang = LangFactory::get(); + + $this->assertInstanceOf(FileAdapter::class, $lang->getAdapter()); + } + + public function testLangFactoryResolvesDeepLAdapter(): void + { + config()->set('lang', [ + 'default' => 'deepl', + 'default_locale' => 'en', + 'file' => [], + 'deepl' => [ + 'auth_key' => 'test-auth-key', + ], + 'google_translate' => [ + 'api_key' => 'test-api-key', + ], + 'supported' => ['en', 'es'], + 'url_segment' => 1, + ]); + + $this->testRequest('http://127.0.0.1/api/rest'); + + $lang = LangFactory::get(); + + $this->assertInstanceOf(DeepLAdapter::class, $lang->getAdapter()); + } + + public function testLangFactoryResolvesGoogleTranslateAdapter(): void + { + config()->set('lang', [ + 'default' => 'google_translate', + 'default_locale' => 'en', + 'file' => [], + 'deepl' => [ + 'auth_key' => 'test-auth-key', + ], + 'google_translate' => [ + 'api_key' => 'test-api-key', + ], + 'supported' => ['en', 'es'], + 'url_segment' => 1, + ]); + + $this->testRequest('http://127.0.0.1/api/rest'); + + $lang = LangFactory::get(); + + $this->assertInstanceOf(GoogleTranslateAdapter::class, $lang->getAdapter()); + } + public function testLangFactoryDetectedFromRouteParameter(): void { $this->testRequest('http://127.0.0.1/es/api/rest'); @@ -76,8 +130,9 @@ public function testLangFactoryFallsBackToDefaultIfNoLangDetected(): void public function testLangFactoryFallsBackToDefaultIfProvidedLangIsNotSupported(): void { config()->set('lang', [ - 'enabled' => true, - 'default' => 'en', + 'default' => 'file', + 'default_locale' => 'en', + 'file' => [], 'supported' => ['en', 'es'], 'url_segment' => 1, ]); @@ -108,8 +163,9 @@ public function testLangFactoryFallsBackToDefaultIfProvidedLangIsNotSupported(): public function testLangFactoryThrowsErrorIfNoDefaultLangFound(): void { config()->set('lang', [ - 'enabled' => true, - 'default' => null, + 'default' => 'file', + 'default_locale' => null, + 'file' => [], 'supported' => ['en', 'es'], 'url_segment' => 1, ]); @@ -123,6 +179,38 @@ public function testLangFactoryThrowsErrorIfNoDefaultLangFound(): void LangFactory::get(); } + public function testLangFactoryThrowsErrorIfAdapterIsNotSupported(): void + { + config()->set('lang', [ + 'default' => 'unknown', + 'default_locale' => 'en', + 'file' => [], + 'supported' => ['en', 'es'], + 'url_segment' => 1, + ]); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('The adapter `unknown` is not supported.'); + + LangFactory::get(); + } + + public function testLangFactoryThrowsErrorIfNoDefaultAdapterFound(): void + { + config()->set('lang', [ + 'default' => null, + 'default_locale' => 'en', + 'file' => [], + 'supported' => ['en', 'es'], + 'url_segment' => 1, + ]); + + $this->expectException(LangException::class); + $this->expectExceptionMessage('Misconfigured lang default adapter config.'); + + LangFactory::get(); + } + private function resetLangFactory(): void { if (!Di::isRegistered(LangFactory::class)) { @@ -130,6 +218,6 @@ private function resetLangFactory(): void } $factory = Di::get(LangFactory::class); - $this->setPrivateProperty($factory, 'instance', null); + $this->setPrivateProperty($factory, 'instances', []); } } diff --git a/tests/Unit/Lang/Helpers/LangHelperFunctionsTest.php b/tests/Unit/Lang/Helpers/LangHelperFunctionsTest.php index 7ad31bd2..eb2797db 100644 --- a/tests/Unit/Lang/Helpers/LangHelperFunctionsTest.php +++ b/tests/Unit/Lang/Helpers/LangHelperFunctionsTest.php @@ -15,8 +15,6 @@ public function setUp(): void parent::setUp(); $this->lang = LangFactory::get(); - - $this->lang->load(); } public function testLangHelperCurrentLang(): void diff --git a/tests/Unit/Lang/LangTest.php b/tests/Unit/Lang/LangTest.php index 18ddf628..d6eaf2d3 100644 --- a/tests/Unit/Lang/LangTest.php +++ b/tests/Unit/Lang/LangTest.php @@ -2,9 +2,10 @@ namespace Quantum\Tests\Unit\Lang; +use Quantum\Lang\Exceptions\LangException; +use Quantum\Lang\Adapters\FileAdapter; use Quantum\Tests\Unit\AppTestCase; use Quantum\Router\MatchedRoute; -use Quantum\Lang\Translator; use Quantum\Router\Route; use Quantum\Lang\Lang; @@ -16,8 +17,7 @@ public function setUp(): void { parent::setUp(); - $translator = new Translator('en'); - $this->lang = new Lang('en', true, $translator); + $this->lang = new Lang('en', new FileAdapter('en')); $route = new Route( ['POST'], @@ -42,23 +42,10 @@ public function testLangGetSet(): void $this->assertEquals('ru', $this->lang->getLang()); } - public function testLangIsEnabled(): void - { - $this->assertTrue($this->lang->isEnabled()); - - $langDisabled = new Lang('en', false, new Translator('en')); - - $this->assertFalse($langDisabled->isEnabled()); - } - public function testLangLoadAndGetTranslation(): void { $this->lang->flush(); - $this->assertEquals('custom.test', $this->lang->getTranslation('custom.test')); - - $this->lang->load(); - $this->assertEquals('Testing', $this->lang->getTranslation('custom.test')); $this->assertEquals('Information about the new feature', $this->lang->getTranslation('custom.info', ['new'])); @@ -66,12 +53,30 @@ public function testLangLoadAndGetTranslation(): void public function testLangFlushResetsTranslations(): void { - $this->lang->load(); + $this->assertEquals('Testing', $this->lang->getTranslation('custom.test')); + + $this->lang->flush(); $this->assertEquals('Testing', $this->lang->getTranslation('custom.test')); + } + public function testLangGetAdapter(): void + { + $this->assertInstanceOf(FileAdapter::class, $this->lang->getAdapter()); + } + + public function testLangCallForwardsToAdapterMethod(): void + { $this->lang->flush(); - $this->assertEquals('test', $this->lang->getTranslation('test')); + $this->assertEquals('Testing', $this->lang->get('custom.test')); + } + + public function testLangCallThrowsForUnsupportedMethod(): void + { + $this->expectException(LangException::class); + $this->expectExceptionMessage('The method `nonExistingMethod` is not supported for `' . FileAdapter::class . '`.'); + + $this->lang->nonExistingMethod(); } } diff --git a/tests/Unit/Lang/TranslatorTest.php b/tests/Unit/Lang/TranslatorTest.php deleted file mode 100644 index c66794a6..00000000 --- a/tests/Unit/Lang/TranslatorTest.php +++ /dev/null @@ -1,38 +0,0 @@ -assertInstanceOf(Translator::class, $translator); - } - - public function testTranslatorLoadTranslations(): void - { - $translator = new Translator('en'); - - $translator->loadTranslations(); - - $this->assertEquals('Testing', $translator->get('custom.test')); - - $this->assertEquals('Information about the value feature', $translator->get('custom.info', ['param' => 'value'])); - } - - public function testTranslatorGetTranslation(): void - { - $translator = new Translator('en'); - - $translator->loadTranslations(); - - $result = $translator->get('custom.info', ['new']); - - $this->assertEquals('Information about the new feature', $result); - } -} diff --git a/tests/Unit/Storage/HttpClientTestCase.php b/tests/Unit/Storage/HttpClientTestCase.php index ae1cf845..51202ecd 100644 --- a/tests/Unit/Storage/HttpClientTestCase.php +++ b/tests/Unit/Storage/HttpClientTestCase.php @@ -15,6 +15,10 @@ trait HttpClientTestCase protected $currentErrors; + protected array $data = []; + + protected $options; + protected function mockHttpClient() { $httpClientMock = Mockery::mock(HttpClient::class); @@ -28,11 +32,19 @@ protected function mockHttpClient() $httpClientMock->shouldReceive('setHeaders')->andReturnSelf(); + $httpClientMock->shouldReceive('setOpt')->andReturnUsing(function ($option, $value) use ($httpClientMock) { + $this->options[$this->url][$option] = $value; + return $httpClientMock; + }); + $httpClientMock->shouldReceive('getRequestHeaders')->andReturn([]); - $httpClientMock->shouldReceive('getData')->andReturn([]); + $httpClientMock->shouldReceive('getData')->andReturnUsing(fn () => $this->data[$this->url] ?? []); - $httpClientMock->shouldReceive('setData')->andReturn($httpClientMock); + $httpClientMock->shouldReceive('setData')->andReturnUsing(function ($data) use ($httpClientMock) { + $this->data[$this->url] = $data; + return $httpClientMock; + }); $httpClientMock->shouldReceive('start')->andReturnUsing(function () use ($httpClientMock) { $this->response[$this->url]['body'] = $this->currentResponse; diff --git a/tests/_root/shared/config/lang.php b/tests/_root/shared/config/lang.php index 407b240d..2daff344 100644 --- a/tests/_root/shared/config/lang.php +++ b/tests/_root/shared/config/lang.php @@ -6,8 +6,31 @@ * Multilingual settings * --------------------------------------------------------- */ - 'enabled' => true, 'supported' => ['en', 'es'], - 'default' => 'en', + 'default' => 'file', + 'default_locale' => 'en', + 'file' => [], + 'deepl' => [ + 'use_source_catalog' => false, + 'auth_key' => '', + 'source_locale' => null, + 'cache' => [ + 'enabled' => true, + 'default' => 'file', + 'ttl' => 3600, + 'prefix' => 'lang_deepl:', + ], + ], + 'google_translate' => [ + 'use_source_catalog' => false, + 'api_key' => '', + 'source_locale' => null, + 'cache' => [ + 'enabled' => true, + 'default' => 'file', + 'ttl' => 3600, + 'prefix' => 'lang_google_translate:', + ], + ], 'url_segment' => 1, ];