diff --git a/.github/workflows/test.yml b/.github/workflows/run-tests.yml similarity index 62% rename from .github/workflows/test.yml rename to .github/workflows/run-tests.yml index cb7241c..69ca11d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/run-tests.yml @@ -1,5 +1,7 @@ -name: Test +name: Run tests + on: [pull_request] + env: MYSQL_USER: root MYSQL_PASSWORD: root @@ -19,15 +21,15 @@ jobs: php-versions: ['8.3'] steps: - name: Checkout Drupal core - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: repository: drupal/drupal ref: ${{ matrix.drupal-core }} - name: Checkout module - uses: actions/checkout@v2 + uses: actions/checkout@v4 with: - path: modules/custom/ocha_uimc + path: modules/contrib/ocha_uimc - name: Setup PHP, with composer and extensions uses: shivammathur/setup-php@v2 @@ -37,36 +39,26 @@ jobs: - name: Get composer cache directory id: composercache - run: echo "::set-output name=dir::$(composer config cache-files-dir)" + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache composer dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} restore-keys: ${{ runner.os }}-composer- - - name: Install Drupal core dependencies - run: | - composer --no-interaction --no-progress --prefer-dist --optimize-autoloader install - - - name: Install module dependencies - run: | - composer --no-interaction --no-progress require \ - drupal/datetime_range_timezone drupal/date_recur - - - name: Patch Drupal - https://www.drupal.org/project/drupal/issues/3004425 - run: | - curl https://www.drupal.org/files/issues/2021-12-25/3004425-41.patch | patch -p1 + - name: Remove platform PHP config + run: composer config --unset platform.php - - name: Install Coder module + - name: Install Drupal core and module dependencies run: | - composer --dev --no-interaction --no-progress require \ - drupal/coder:8.3.13 phpunit/phpunit + composer config repositories.module '{"type": "path", "url": "modules/contrib/ocha_uimc", "options": {"symlink": false}}' + composer require unocha/ocha_uimc:@dev --no-interaction --no-progress --prefer-dist --optimize-autoloader - name: Define DRUPAL_ROOT env variable run: | - echo "DRUPAL_ROOT=$HOME/drupal" >> $GITHUB_ENV + echo "DRUPAL_ROOT=$GITHUB_WORKSPACE" >> $GITHUB_ENV - name: Install drush run: composer require "drush/drush ^12.0" @@ -79,15 +71,15 @@ jobs: run: | php -d sendmail_path=$(which true); vendor/bin/drush --yes -v \ site-install minimal --db-url="$SIMPLETEST_DB" - vendor/bin/drush en $DRUPAL_MODULE_NAME -y + vendor/bin/drush en ocha_uimc -y - - name: Run tests + - name: Run module tests run: | - vendor/bin/phpunit --bootstrap core/tests/bootstrap.php \ + XDEBUG_MODE=coverage php -d zend_extension=xdebug ./vendor/bin/phpunit --bootstrap core/tests/bootstrap.php \ --coverage-clover ./clover.xml \ - -c modules/custom/ocha_uimc/phpunit.xml modules/custom/ocha_uimc + -c modules/contrib/ocha_uimc/phpunit.xml modules/contrib/ocha_uimc - - name: Monitor coverage for Drupal ${{ matrix.drupal-core }} - PHP ${{ matrix.php-versions }} + - name: Monitor coverage id: coveralls uses: slavcodev/coverage-monitor-action@v1 with: diff --git a/config/install/ocha_uimc.settings.yml b/config/install/ocha_uimc.settings.yml index d2c61af..7c77307 100644 --- a/config/install/ocha_uimc.settings.yml +++ b/config/install/ocha_uimc.settings.yml @@ -4,4 +4,8 @@ username: '' password: '' consumer_key: '' consumer_secret: '' +send_email: true verify_ssl: true +request_timeout: 10 +registration_explanation: | +

Create an account to easily access our services.
If you already possess a UN email address, please log in here directly.

diff --git a/config/schema/ocha_uimc.schema.yml b/config/schema/ocha_uimc.schema.yml index c3e830c..77f7073 100644 --- a/config/schema/ocha_uimc.schema.yml +++ b/config/schema/ocha_uimc.schema.yml @@ -20,6 +20,15 @@ ocha_uimc.settings: consumer_secret: type: string label: 'The API consumer secret.' + send_email: + type: boolean + label: 'Whether or not have an email sent after registration.' verify_ssl: - type: bool + type: boolean label: 'Whether to verify SSL or not when querying the API. Useful for local/dev tests.' + request_timeout: + type: integer + label: 'Timeout for requests to the UIMC API.' + registration_explanation: + type: string + label: 'Explanation about the registration. Simple HTML is accepted.' diff --git a/ocha_uimc.services.yml b/ocha_uimc.services.yml index 98e7219..94a004b 100644 --- a/ocha_uimc.services.yml +++ b/ocha_uimc.services.yml @@ -1,9 +1,9 @@ services: ocha_uimc.api.client: - class: Drupal\ocha_uimc\Services\OchaUimcApiClient + class: Drupal\ocha_uimc\Service\OchaUimcApiClient arguments: - '@http_client' - '@config.factory' - '@key.repository' - - '@logger_channel.factory' + - '@logger.factory' - '@datetime.time' diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..0d00746 --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,14 @@ + + + PHP CodeSniffer configuration for Drupal coding standards. + . + + + ./vendor/* + ./.composer/* + *.md + *.css + *.js + + + diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..a84ed0f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + src + . + + + + src + tests + + + + + + + + tests/src/Unit + + + diff --git a/src/Form/OchaUimcRegistrationForm.php b/src/Form/OchaUimcRegistrationForm.php index 2dd5693..3b252cf 100644 --- a/src/Form/OchaUimcRegistrationForm.php +++ b/src/Form/OchaUimcRegistrationForm.php @@ -6,6 +6,7 @@ use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Render\Markup; use Drupal\honeypot\HoneypotService; use Drupal\ocha_uimc\Service\OchaUimcApiClientInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -42,12 +43,24 @@ public static function create(ContainerInterface $container) { * {@inheritdoc} */ public function buildForm(array $form, FormStateInterface $form_state): array { + // Add the registration explanation message. + $explanation = $this->config('ocha_uimc.settings')->get('registration_explanation'); + if (!empty($explanation)) { + $form['registration_explanation'] = [ + '#type' => 'markup', + '#markup' => Markup::create($explanation), + '#prefix' => '
', + '#suffix' => '
', + ]; + } + $form['first_name'] = [ '#type' => 'textfield', '#title' => $this->t('First Name'), '#required' => TRUE, '#maxlength' => 30, '#placeholder' => $this->t('Enter your first name'), + '#description' => $this->t('Enter your first name using only letters, spaces, hyphens, or apostrophes. Maximum 30 characters.'), ]; $form['last_name'] = [ @@ -56,6 +69,7 @@ public function buildForm(array $form, FormStateInterface $form_state): array { '#required' => TRUE, '#maxlength' => 30, '#placeholder' => $this->t('Enter your last name'), + '#description' => $this->t('Enter your last name using only letters, spaces, hyphens, or apostrophes. Maximum 30 characters.'), ]; $form['email'] = [ @@ -64,6 +78,7 @@ public function buildForm(array $form, FormStateInterface $form_state): array { '#required' => TRUE, '#maxlength' => 100, '#placeholder' => $this->t('Enter your email address'), + '#description' => $this->t('Enter a valid email address. Only letters, numbers, hyphens, and periods are allowed. Maximum 100 characters.'), ]; $form['actions']['#type'] = 'actions'; @@ -103,9 +118,8 @@ public function validateForm(array &$form, FormStateInterface $form_state): void if (strlen($email) > 100 || preg_match('/^[a-zA-Z0-9.-]{1,64}@[a-zA-Z0-9.-]{1,255}$/', $email) !== 1) { $form_state->setErrorByName('email', $this->t('Email must contain only letters, numbers, hyphens, or periods and be no longer than 100 characters.')); } - // Additional email validation. - if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + elseif (!filter_var($email, \FILTER_VALIDATE_EMAIL)) { $form_state->setErrorByName('email', $this->t('Please enter a valid email address.')); } } diff --git a/src/Service/OchaUimcApiClient.php b/src/Service/OchaUimcApiClient.php index 9ad286e..76e5973 100644 --- a/src/Service/OchaUimcApiClient.php +++ b/src/Service/OchaUimcApiClient.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Drupal\mymodule\Service; +namespace Drupal\ocha_uimc\Service; use Drupal\Component\Datetime\TimeInterface; use Drupal\Core\Config\ConfigFactoryInterface; @@ -11,11 +11,12 @@ use Drupal\Core\Logger\LoggerChannelInterface; use Drupal\key\KeyRepositoryInterface; use GuzzleHttp\ClientInterface; +use GuzzleHttp\Exception\RequestException; /** * Client service for the UIMC API. */ -class OchaUimcApiClient { +class OchaUimcApiClient implements OchaUimcApiClientInterface { /** * Constructs a new AccessTokenManager object. @@ -32,11 +33,11 @@ class OchaUimcApiClient { * The time service. */ public function __construct( - ClientInterface $httpClient, - ConfigFactoryInterface $configFactory, - KeyRepositoryInterface $keyRepository, - LoggerChannelFactoryInterface $loggerFactory, - TimeInterface $time, + protected ClientInterface $httpClient, + protected ConfigFactoryInterface $configFactory, + protected KeyRepositoryInterface $keyRepository, + protected LoggerChannelFactoryInterface $loggerFactory, + protected TimeInterface $time, ) {} /** @@ -45,41 +46,89 @@ public function __construct( public function registerAccount(string $first_name, string $last_name, string $email): bool { try { $config = $this->config(); - $registration_url = $config->get('registration_url'); + $send_email = !empty($config->get('send_email')); $verify_ssl = !empty($config->get('verify_ssl')); + $timeout = $config->get('request_timeout') ?? 10; + + if (empty($registration_url)) { + throw new \Exception('Invalid configuration.'); + } $access_token = $this->getAccessToken(); + if (empty($access_token)) { + throw new \Exception('Missing access token.'); + } $post_data = [ - 'fistName' => $first_name, + 'firstName' => $first_name, 'lastName' => $last_name, 'email' => $email, - 'sendEmail' => TRUE, + 'sendEmail' => $send_email, ]; $response = $this->httpClient()->request('POST', $registration_url, [ 'headers' => [ 'Authorization' => "Bearer $access_token", - 'Content-Type' => 'application/json', + 'Content-Type' => 'application/json; charset=utf-8', ], 'json' => $post_data, 'verify' => $verify_ssl, + 'timeout' => $timeout, ]); + $response_body = (string) $response->getBody()->getContents(); + + // A failure can be due to many things included an invalid or expired + // access token. if ($response->getStatusCode() !== 200) { - throw new \Exception('Error while registering account: @message', [ - '@message' => (string) $response->getBody()->getContents(), - ]); + throw new \Exception(strtr('Error while registering account: @message', [ + '@message' => $response_body, + ]), $response->getStatusCode()); + } + + // We only try to decode the response body if the request was successful. + // In case of failure, the output may not be in JSON. For example, when + // access to the endpoint is not allowed (ex: expired access token), then + // the body is a SOAP message... + $message = json_decode($response_body, TRUE); + $message_code = $message['code'] ?? 0; + + // Unless there is a server error, the API response code is 200 but it + // doesn't mean the registration was accepted. We need to check the code + // in the response's message itself. + if ($message_code !== 200) { + throw new \Exception(strtr('Error while registering account: @message', [ + '@message' => $response_body, + ]), $message_code); } return TRUE; } + // Log any error message. catch (\Exception $exception) { - $this->logger()->error('Unable to register account: @message', [ - '@message' => $exception->getMessage(), + // Guzzle truncates the response message in case of error but it can + // be retrieved with via the attached response object. + if ($exception instanceof RequestException && $exception->hasResponse()) { + $message = (string) $exception->getResponse()->getBody()->getContents(); + } + else { + $message = $exception->getMessage(); + } + + // Ensure we don't leak the access token. + if (isset($access_token)) { + $message = strtr($message, [ + $access_token ?? 'ACCESS_TOKEN' => 'REDACTED_ACCESS_TOKEN', + ]); + } + + $this->logger()->error('Unable to register account: @code - @message', [ + '@code' => $exception->getCode(), + '@message' => $message, ]); } + return FALSE; } @@ -123,13 +172,13 @@ protected function isTokenExpired(array $token_data): bool { protected function refreshAccessToken(): ?string { try { $config = $this->config(); - $token_url = $config->get('token_url'); $username = $config->get('username'); $password = $config->get('password'); $consumer_key = $config->get('consumer_key'); $consumer_secret = $config->get('consumer_secret'); $verify_ssl = !empty($config->get('verify_ssl')); + $timeout = $config->get('request_timeout') ?? 10; if (empty($token_url) || empty($username) || empty($password) || empty($consumer_key) || empty($consumer_secret)) { throw new \Exception('Invalid configuration.'); @@ -149,11 +198,15 @@ protected function refreshAccessToken(): ?string { ], 'form_params' => $post_data, 'verify' => $verify_ssl, + 'timeout' => $timeout, ]); - $body = json_decode($response->getBody(), TRUE, flags: JSON_THROW_ON_ERROR); + $response_body = (string) $response->getBody()->getContents(); + + // Try to decode the body to retrieve the access_token. + $body = json_decode($response_body, TRUE, flags: \JSON_THROW_ON_ERROR); if (empty($body['access_token']) || empty($body['expires_in'])) { - throw new \Exception('Invalid token.'); + throw new \Exception('Invalid or missing access token.'); } $token_data = [ @@ -162,13 +215,34 @@ protected function refreshAccessToken(): ?string { 'created' => $this->time()->getCurrentTime(), ]; + // @todo the `expires_in` property seems buggy. It's like 6 months but + // actually the token is only valid for a very short duration. This may + // only be on the test environment and will need further investigation. $this->storeAccessToken($token_data); - return $token; + return $body['access_token']; } + // Log any error message. catch (\Exception $exception) { - $this->logger()->error('API token retrieval failed: @message', [ - '@message' => $exception->getMessage(), + // Guzzle truncates the response message in case of error but it can + // be retrieved with via the attached response object. + if ($exception instanceof RequestException && $exception->hasResponse()) { + $message = (string) $exception->getResponse()->getBody()->getContents(); + } + else { + $message = $exception->getMessage(); + } + + // Ensure we don't leak credentials. + $message = strtr($message, [ + $username ?? 'USERNAME' => 'REDACTED_USERNAME', + $password ?? 'PASSWORD' => 'REDACTED_PASSWORD', + $auth_string ?? 'AUTH_STRING' => 'REDACTED_AUTHSTRING', + ]); + + $this->logger()->error('API token retrieval failed: @code - @message', [ + '@code' => $exception->getCode(), + '@message' => $message, ]); } @@ -182,7 +256,7 @@ protected function refreshAccessToken(): ?string { * The token data to store. */ protected function storeAccessToken(array $token_data) { - $key = $this->keyRepository()->getKey('api_access_token'); + $key = $this->keyRepository()->getKey('ocha_uimc_api_access_token'); if ($key) { $key->setKeyValue(json_encode($token_data)); } @@ -197,8 +271,8 @@ protected function storeAccessToken(array $token_data) { * @return \Drupal\key\KeyRepositoryInterface * The key factory. */ - protected function keyFactory(): KeyRepositoryInterface { - return $this->keyFactory; + protected function keyRepository(): KeyRepositoryInterface { + return $this->keyRepository; } /** diff --git a/src/Service/OchaUimcApiClientInterface.php b/src/Service/OchaUimcApiClientInterface.php index 346e5e9..c7cc942 100644 --- a/src/Service/OchaUimcApiClientInterface.php +++ b/src/Service/OchaUimcApiClientInterface.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Drupal\mymodule\Service; +namespace Drupal\ocha_uimc\Service; /** * Interface for the client service for the UIMC API. diff --git a/tests/src/Unit/Form/OchaUimcRegistrationFormTest.php b/tests/src/Unit/Form/OchaUimcRegistrationFormTest.php new file mode 100644 index 0000000..aae652a --- /dev/null +++ b/tests/src/Unit/Form/OchaUimcRegistrationFormTest.php @@ -0,0 +1,187 @@ +apiClient = $this->createMock(OchaUimcApiClientInterface::class); + $this->honeypotService = $this->createMock(HoneypotService::class); + $this->messenger = $this->createMock(MessengerInterface::class); + + $this->form = new OchaUimcRegistrationForm($this->apiClient, $this->honeypotService); + $this->form->setMessenger($this->messenger); + + $translation = $this->getStringTranslationStub(); + $this->form->setStringTranslation($translation); + } + + /** + * Tests the form validation with invalid data. + * + * @covers ::validateForm + */ + public function testValidateFormWithInvalidData(): void { + $form = [ + 'first_name' => ['#parents' => ['first_name']], + 'last_name' => ['#parents' => ['last_name']], + 'email' => ['#parents' => ['email']], + ]; + $form_state = new FormState(); + $form_state->clearErrors(); + $form_state->setValues([ + 'first_name' => 'John123', + 'last_name' => 'Doe456', + 'email' => 'invalid-email', + ]); + + $this->form->validateForm($form, $form_state); + + $this->assertTrue($form_state->hasAnyErrors()); + $this->assertCount(3, $form_state->getErrors()); + } + + /** + * Tests the form validation with valid data. + * + * @covers ::validateForm + */ + public function testValidateFormWithValidData(): void { + $form = [ + 'first_name' => ['#parents' => ['first_name']], + 'last_name' => ['#parents' => ['last_name']], + 'email' => ['#parents' => ['email']], + ]; + $form_state = new FormState(); + $form_state->clearErrors(); + $form_state->setValues([ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john.doe@example.com', + ]); + + $this->form->validateForm($form, $form_state); + + $this->assertFalse($form_state->hasAnyErrors()); + $this->assertEmpty($form_state->getErrors()); + } + + /** + * Tests the form submission with successful API registration. + * + * @covers ::submitForm + */ + public function testSubmitFormSuccess(): void { + $form = []; + $form_state = new FormState(); + $form_state->clearErrors(); + $form_state->setValues([ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john.doe@example.com', + ]); + + $this->apiClient->expects($this->once()) + ->method('registerAccount') + ->with('John', 'Doe', 'john.doe@example.com') + ->willReturn(TRUE); + + $this->messenger->expects($this->once()) + ->method('addStatus') + ->with($this->callback(function ($message) { + $expected_string = 'Registration successful, please check your mailbox for further instructions.'; + $actual_string = $message->getUntranslatedString(); + if ($expected_string !== $actual_string) { + $this->fail("Expected message '$expected_string', but got '$actual_string'"); + } + return TRUE; + })); + + $this->form->submitForm($form, $form_state); + + $this->assertEquals('user.login', $form_state->getRedirect()->getRouteName()); + } + + /** + * Tests the form submission with failed API registration. + * + * @covers ::submitForm + */ + public function testSubmitFormFailure(): void { + $form = []; + $form_state = new FormState(); + $form_state->clearErrors(); + $form_state->setValues([ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'email' => 'john.doe@example.com', + ]); + + $this->apiClient->expects($this->once()) + ->method('registerAccount') + ->with('John', 'Doe', 'john.doe@example.com') + ->willReturn(FALSE); + + $this->messenger->expects($this->once()) + ->method('addError') + ->with($this->callback(function ($message) { + $expected_string = 'Registration failed, please contact the administrator or try again later.'; + $actual_string = $message->getUntranslatedString(); + if ($expected_string !== $actual_string) { + $this->fail("Expected message '$expected_string', but got '$actual_string'"); + } + return TRUE; + })); + + $this->form->submitForm($form, $form_state); + + $this->assertNull($form_state->getRedirect()); + } + +} diff --git a/tests/src/Unit/Service/OchaUimcApiClientTest.php b/tests/src/Unit/Service/OchaUimcApiClientTest.php new file mode 100644 index 0000000..d3787af --- /dev/null +++ b/tests/src/Unit/Service/OchaUimcApiClientTest.php @@ -0,0 +1,477 @@ +httpClient = $this->prophesize(ClientInterface::class); + $this->configFactory = $this->prophesize(ConfigFactoryInterface::class); + $this->keyRepository = $this->prophesize(KeyRepositoryInterface::class); + $this->loggerFactory = $this->prophesize(LoggerChannelFactoryInterface::class); + $this->time = $this->prophesize(TimeInterface::class); + + $this->apiClient = new OchaUimcApiClient( + $this->httpClient->reveal(), + $this->configFactory->reveal(), + $this->keyRepository->reveal(), + $this->loggerFactory->reveal(), + $this->time->reveal() + ); + } + + /** + * @covers ::registerAccount + */ + public function testRegisterAccountWithInvalidConfiguration(): void { + $this->setTestConfig(registration_url: NULL); + + $logger = $this->prophesize(LoggerChannelInterface::class); + $logger->error(Argument::cetera())->shouldBeCalled(); + $this->loggerFactory->get('ocha_uimc')->willReturn($logger->reveal()); + + $result = $this->apiClient->registerAccount('John', 'Doe', 'john.doe@example.com'); + $this->assertFalse($result); + } + + /** + * @covers ::registerAccount + */ + public function testRegisterAccountWithNoAccessToken(): void { + $this->setTestConfig(); + + $this->keyRepository->getKey('ocha_uimc_api_access_token')->willReturn(NULL); + + $this->httpClient->request('POST', 'https://api.example.com/token', Argument::any()) + ->willReturn(new Response(400, [], NULL)); + + $logger = $this->prophesize(LoggerChannelInterface::class); + $logger->error(Argument::cetera())->shouldBeCalled(); + $this->loggerFactory->get('ocha_uimc')->willReturn($logger->reveal()); + + $result = $this->apiClient->registerAccount('John', 'Doe', 'john.doe@example.com'); + $this->assertFalse($result); + } + + /** + * @covers ::registerAccount + */ + public function testRegisterAccountWithApiError(): void { + $this->setTestConfig(); + + $key = $this->prophesize(KeyInterface::class); + $key->getKeyValue()->willReturn(json_encode([ + 'access_token' => 'valid_token', + 'expires_in' => 3600, + 'created' => time(), + ])); + $this->keyRepository->getKey('ocha_uimc_api_access_token')->willReturn($key->reveal()); + + $this->time->getCurrentTime()->willReturn(time()); + + $this->httpClient->request('POST', 'https://api.example.com/register', Argument::any()) + ->willReturn(new Response(400, [], '{"code": 400, "message": "Bad Request"}')); + + $logger = $this->prophesize(LoggerChannelInterface::class); + $logger->error(Argument::cetera())->shouldBeCalled(); + $this->loggerFactory->get('ocha_uimc')->willReturn($logger->reveal()); + + $result = $this->apiClient->registerAccount('John', 'Doe', 'john.doe@example.com'); + $this->assertFalse($result); + } + + /** + * @covers ::refreshAccessToken + */ + public function testRefreshAccessTokenWithInvalidResponse(): void { + $this->setTestConfig(); + + $this->httpClient->request('POST', 'https://api.example.com/token', Argument::any()) + ->willReturn(new Response(200, [], '{"invalid_response": true}')); + + $logger = $this->prophesize(LoggerChannelInterface::class); + $logger->error(Argument::cetera())->shouldBeCalled(); + $this->loggerFactory->get('ocha_uimc')->willReturn($logger->reveal()); + + $method = new \ReflectionMethod(OchaUimcApiClient::class, 'refreshAccessToken'); + $method->setAccessible(TRUE); + $result = $method->invoke($this->apiClient); + + $this->assertNull($result); + } + + /** + * @covers ::registerAccount + */ + public function testRegisterAccountWithRequestException(): void { + $this->setTestConfig(); + + $key = $this->prophesize(KeyInterface::class); + $key->getKeyValue()->willReturn(json_encode([ + 'access_token' => 'valid_token', + 'expires_in' => 3600, + 'created' => time(), + ])); + $this->keyRepository->getKey('ocha_uimc_api_access_token')->willReturn($key->reveal()); + + $this->time->getCurrentTime()->willReturn(time()); + + $exception = new RequestException('Error Communicating with Server', new Request('POST', 'test'), new Response(500, [], 'Server Error')); + $this->httpClient->request('POST', 'https://api.example.com/register', Argument::any()) + ->willThrow($exception); + + $logger = $this->prophesize(LoggerChannelInterface::class); + $logger->error(Argument::cetera())->shouldBeCalled(); + $this->loggerFactory->get('ocha_uimc')->willReturn($logger->reveal()); + + $result = $this->apiClient->registerAccount('John', 'Doe', 'john.doe@example.com'); + $this->assertFalse($result); + } + + /** + * @covers ::registerAccount + */ + public function testRegisterAccountWithTimeout(): void { + $this->setTestConfig(request_timeout: 1); + + $key = $this->prophesize(KeyInterface::class); + $key->getKeyValue()->willReturn(json_encode([ + 'access_token' => 'valid_token', + 'expires_in' => 3600, + 'created' => time(), + ])); + $this->keyRepository->getKey('ocha_uimc_api_access_token')->willReturn($key->reveal()); + + $this->time->getCurrentTime()->willReturn(time()); + + $this->httpClient->request('POST', 'https://api.example.com/register', Argument::any()) + ->willThrow(new ConnectException('Connection timed out', new Request('POST', 'test'))); + + $logger = $this->prophesize(LoggerChannelInterface::class); + $logger->error(Argument::cetera())->shouldBeCalled(); + $this->loggerFactory->get('ocha_uimc')->willReturn($logger->reveal()); + + $result = $this->apiClient->registerAccount('John', 'Doe', 'john.doe@example.com'); + $this->assertFalse($result); + } + + /** + * @covers ::registerAccount + */ + public function testRegisterAccount(): void { + $this->setTestConfig(); + + $logger = $this->prophesize(LoggerChannelInterface::class); + $this->loggerFactory->get('ocha_uimc')->willReturn($logger->reveal()); + + // Mock the key repository to return a key with an expired token. + $expiredKey = $this->prophesize(KeyInterface::class); + $expiredKey->getKeyValue()->willReturn(json_encode([ + 'access_token' => 'expired_token', + 'expires_in' => 3600, + 'created' => time() - 4000, + ])); + $expiredKey->setKeyValue(Argument::any())->shouldBeCalled(); + $this->keyRepository->getKey('ocha_uimc_api_access_token')->willReturn($expiredKey->reveal()); + + // Mock the time service. + $this->time->getCurrentTime()->willReturn(time()); + + // Mock the HTTP client to return a new token and then a successful + // registration. + $this->httpClient->request('POST', 'https://api.example.com/token', Argument::any()) + ->willReturn(new Response(200, [], json_encode([ + 'access_token' => 'new_token', + 'expires_in' => 3600, + ]))); + $this->httpClient->request('POST', 'https://api.example.com/register', Argument::any()) + ->willReturn(new Response(200, [], '{"code": 200, "message": "Success"}')); + + $result = $this->apiClient->registerAccount('John', 'Doe', 'john.doe@example.com'); + $this->assertTrue($result); + } + + /** + * @covers ::getAccessToken + */ + public function testGetAccessToken(): void { + $tokenData = [ + 'access_token' => 'test_token', + 'expires_in' => 3600, + 'created' => time(), + ]; + + $key = $this->prophesize(KeyInterface::class); + $key->getKeyValue()->willReturn(json_encode($tokenData)); + $this->keyRepository->getKey('ocha_uimc_api_access_token')->willReturn($key->reveal()); + + $this->time->getCurrentTime()->willReturn(time()); + + $method = new \ReflectionMethod(OchaUimcApiClient::class, 'getAccessToken'); + $method->setAccessible(TRUE); + $result = $method->invoke($this->apiClient); + + $this->assertEquals('test_token', $result); + } + + /** + * @covers ::refreshAccessToken + */ + public function testRefreshAccessTokenWithInvalidConfiguration(): void { + $this->setTestConfig(token_url: NULL); + + $logger = $this->prophesize(LoggerChannelInterface::class); + $logger->error(Argument::cetera())->shouldBeCalled(); + $this->loggerFactory->get('ocha_uimc')->willReturn($logger->reveal()); + + $method = new \ReflectionMethod(OchaUimcApiClient::class, 'refreshAccessToken'); + $method->setAccessible(TRUE); + $result = $method->invoke($this->apiClient); + + $this->assertNull($result); + } + + /** + * @covers ::refreshAccessToken + */ + public function testRefreshAccessTokenWithJsonDecodeError(): void { + $this->setTestConfig(); + + $this->httpClient->request('POST', 'https://api.example.com/token', Argument::any()) + ->willReturn(new Response(200, [], 'Invalid JSON')); + + $logger = $this->prophesize(LoggerChannelInterface::class); + $logger->error(Argument::cetera())->shouldBeCalled(); + $this->loggerFactory->get('ocha_uimc')->willReturn($logger->reveal()); + + $method = new \ReflectionMethod(OchaUimcApiClient::class, 'refreshAccessToken'); + $method->setAccessible(TRUE); + $result = $method->invoke($this->apiClient); + + $this->assertNull($result); + } + + /** + * @covers ::refreshAccessToken + */ + public function testRefreshAccessTokenWithNon200StatusCode(): void { + $this->setTestConfig(); + + $this->httpClient->request('POST', 'https://api.example.com/token', Argument::any()) + ->willReturn(new Response(401, [], '{"error": "unauthorized"}')); + + $logger = $this->prophesize(LoggerChannelInterface::class); + $logger->error(Argument::cetera())->shouldBeCalled(); + $this->loggerFactory->get('ocha_uimc')->willReturn($logger->reveal()); + + $method = new \ReflectionMethod(OchaUimcApiClient::class, 'refreshAccessToken'); + $method->setAccessible(TRUE); + $result = $method->invoke($this->apiClient); + + $this->assertNull($result); + } + + /** + * @covers ::refreshAccessToken + */ + public function testRefreshAccessToken(): void { + $this->setTestConfig(); + + $this->httpClient->request('POST', 'https://api.example.com/token', Argument::any()) + ->willReturn(new Response(200, [], '{"access_token": "new_token", "expires_in": 3600}')); + + $key = $this->prophesize(KeyInterface::class); + $key->setKeyValue(Argument::any())->shouldBeCalled(); + $this->keyRepository->getKey('ocha_uimc_api_access_token')->willReturn($key->reveal()); + + $logger = $this->prophesize(LoggerChannelInterface::class); + $this->loggerFactory->get('ocha_uimc')->willReturn($logger->reveal()); + + $this->time->getCurrentTime()->willReturn(time()); + + $method = new \ReflectionMethod(OchaUimcApiClient::class, 'refreshAccessToken'); + $method->setAccessible(TRUE); + $result = $method->invoke($this->apiClient); + + $this->assertEquals('new_token', $result); + } + + /** + * @covers ::isTokenExpired + */ + public function testIsTokenExpired(): void { + $this->time->getCurrentTime()->willReturn(1000); + + $method = new \ReflectionMethod(OchaUimcApiClient::class, 'isTokenExpired'); + $method->setAccessible(TRUE); + + $tokenData = [ + 'created' => 500, + 'expires_in' => 400, + ]; + $result = $method->invoke($this->apiClient, $tokenData); + $this->assertTrue($result); + + $tokenData = [ + 'created' => 500, + 'expires_in' => 600, + ]; + $result = $method->invoke($this->apiClient, $tokenData); + $this->assertFalse($result); + } + + /** + * @covers ::storeAccessToken + */ + public function testStoreAccessTokenWithMissingKey(): void { + $this->keyRepository->getKey('ocha_uimc_api_access_token')->willReturn(NULL); + + $logger = $this->prophesize(LoggerChannelInterface::class); + $logger->error('Failed to store access token: Key not found.')->shouldBeCalled(); + $this->loggerFactory->get('ocha_uimc')->willReturn($logger->reveal()); + + $method = new \ReflectionMethod(OchaUimcApiClient::class, 'storeAccessToken'); + $method->setAccessible(TRUE); + $method->invoke($this->apiClient, ['access_token' => 'test_token', 'expires_in' => 3600]); + } + + /** + * @covers ::storeAccessToken + */ + public function testStoreAccessToken(): void { + $tokenData = [ + 'access_token' => 'new_token', + 'expires_in' => 3600, + 'created' => time(), + ]; + + $key = $this->prophesize(KeyInterface::class); + $key->setKeyValue(json_encode($tokenData))->shouldBeCalled(); + $this->keyRepository->getKey('ocha_uimc_api_access_token')->willReturn($key->reveal()); + + $method = new \ReflectionMethod(OchaUimcApiClient::class, 'storeAccessToken'); + $method->setAccessible(TRUE); + $method->invoke($this->apiClient, $tokenData); + } + + /** + * Set the test configuration. + * + * @param string|null $token_url + * The token_url config setting. + * @param string|null $registration_url + * The registration_url config setting. + * @param string|null $username + * The username config setting. + * @param string|null $password + * The password config setting. + * @param string|null $consumer_key + * The consumer_key config setting. + * @param string|null $consumer_secret + * The consumer_secret config setting. + * @param bool|null $send_email + * The send_email config setting. + * @param bool|null $verify_ssl + * The verify_ssl config setting. + * @param int|null $request_timeout + * The request_timeout config setting. + */ + protected function setTestConfig( + ?string $token_url = 'https://api.example.com/token', + ?string $registration_url = 'https://api.example.com/register', + ?string $username = 'test_user', + ?string $password = 'test_pass', + ?string $consumer_key = 'test_key', + ?string $consumer_secret = 'test_secret', + ?bool $send_email = FALSE, + ?bool $verify_ssl = FALSE, + ?int $request_timeout = 10, + ): void { + $config = $this->prophesize(ImmutableConfig::class); + $config->get('token_url')->willReturn($token_url); + $config->get('registration_url')->willReturn($registration_url); + $config->get('username')->willReturn($username); + $config->get('password')->willReturn($password); + $config->get('consumer_key')->willReturn($consumer_key); + $config->get('consumer_secret')->willReturn($consumer_secret); + $config->get('verify_ssl')->willReturn($verify_ssl); + $config->get('request_timeout')->willReturn($request_timeout); + $config->get('send_email')->willReturn($send_email); + + $this->configFactory->get('ocha_uimc.settings')->willReturn($config->reveal()); + } + +}