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());
+ }
+
+}