diff --git a/composer.json b/composer.json index 559afadca2..90a68468f4 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,7 @@ "source": { "type": "git", "url": "https://github.com/ionos-productivity/ionos-mail-configuration-api-client.git", - "reference": "2.0.0-20251031093901" + "reference": "2.0.0-20251110130214" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 0921a75001..301d6b3bf5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2c00516511e6e4332fba8113ffb7e9b3", + "content-hash": "5341c5725717dffc9990db93ad12ab21", "packages": [ { "name": "amphp/amp", @@ -1860,7 +1860,7 @@ "source": { "type": "git", "url": "https://github.com/ionos-productivity/ionos-mail-configuration-api-client.git", - "reference": "2.0.0-20251031093901" + "reference": "2.0.0-20251110130214" }, "type": "library", "autoload": { diff --git a/lib/Listener/UserDeletedListener.php b/lib/Listener/UserDeletedListener.php index 0f40a66848..30e06ee6a8 100644 --- a/lib/Listener/UserDeletedListener.php +++ b/lib/Listener/UserDeletedListener.php @@ -11,6 +11,7 @@ use OCA\Mail\Exception\ClientException; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\IONOS\IonosMailService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\User\Events\UserDeletedEvent; @@ -26,8 +27,11 @@ class UserDeletedListener implements IEventListener { /** @var LoggerInterface */ private $logger; - public function __construct(AccountService $accountService, - LoggerInterface $logger) { + public function __construct( + AccountService $accountService, + LoggerInterface $logger, + private readonly IonosMailService $ionosMailService, + ) { $this->accountService = $accountService; $this->logger = $logger; } @@ -40,10 +44,16 @@ public function handle(Event $event): void { } $user = $event->getUser(); - foreach ($this->accountService->findByUserId($user->getUID()) as $account) { + $userId = $user->getUID(); + + // Delete IONOS mailbox if IONOS integration is enabled + $this->ionosMailService->tryDeleteEmailAccount($userId); + + // Delete all mail accounts in Nextcloud + foreach ($this->accountService->findByUserId($userId) as $account) { try { $this->accountService->delete( - $user->getUID(), + $userId, $account->getId() ); } catch (ClientException $e) { diff --git a/lib/Service/IONOS/ApiMailConfigClientService.php b/lib/Service/IONOS/ApiMailConfigClientService.php index a7fc40a9b6..5376dee727 100644 --- a/lib/Service/IONOS/ApiMailConfigClientService.php +++ b/lib/Service/IONOS/ApiMailConfigClientService.php @@ -24,13 +24,13 @@ public function newClient(array $config): ClientInterface { } /** - * Create a new EventAPIApi + * Create a new MailConfigurationAPIApi * * @param ClientInterface $client * @param string $apiBaseUrl * @return MailConfigurationAPIApi */ - public function newEventAPIApi(ClientInterface $client, string $apiBaseUrl): MailConfigurationAPIApi { + public function newMailConfigurationAPIApi(ClientInterface $client, string $apiBaseUrl): MailConfigurationAPIApi { if (empty($apiBaseUrl)) { throw new \InvalidArgumentException('API base URL is required'); diff --git a/lib/Service/IONOS/IonosConfigService.php b/lib/Service/IONOS/IonosConfigService.php index 6606c510bb..3b5ff579ee 100644 --- a/lib/Service/IONOS/IonosConfigService.php +++ b/lib/Service/IONOS/IonosConfigService.php @@ -129,6 +129,42 @@ public function getApiConfig(): array { ]; } + /** + * Check if IONOS mail configuration feature is enabled + */ + public function isMailConfigEnabled(): bool { + return $this->config->getAppValue('mail', 'ionos-mailconfig-enabled', 'no') === 'yes'; + } + + /** + * Check if IONOS integration is fully enabled and configured + * + * Returns true only if: + * 1. The mail config feature is enabled + * 2. All required API configuration is valid + * + * @return bool True if IONOS integration is enabled and configured, false otherwise + */ + public function isIonosIntegrationEnabled(): bool { + try { + // Check if feature is enabled + if (!$this->isMailConfigEnabled()) { + return false; + } + + // Verify all required API configuration is valid + $this->getApiConfig(); + + return true; + } catch (AppConfigException $e) { + // Configuration is missing or invalid + $this->logger->debug('IONOS integration not available - configuration error', [ + 'exception' => $e, + ]); + return false; + } + } + /** * Get the mail domain from customer domain * diff --git a/lib/Service/IONOS/IonosMailConfigService.php b/lib/Service/IONOS/IonosMailConfigService.php index 4f74a89c4c..77e9b8126e 100644 --- a/lib/Service/IONOS/IonosMailConfigService.php +++ b/lib/Service/IONOS/IonosMailConfigService.php @@ -9,7 +9,6 @@ namespace OCA\Mail\Service\IONOS; -use OCP\IConfig; use Psr\Log\LoggerInterface; /** @@ -17,7 +16,7 @@ */ class IonosMailConfigService { public function __construct( - private IConfig $config, + private IonosConfigService $ionosConfigService, private IonosMailService $ionosMailService, private LoggerInterface $logger, ) { @@ -27,16 +26,15 @@ public function __construct( * Check if IONOS mail configuration should be available for the current user * * The configuration is available only if: - * 1. The feature is enabled in app config + * 1. The IONOS integration is enabled and properly configured * 2. The user does NOT already have an IONOS mail account * * @return bool True if mail configuration should be shown, false otherwise */ public function isMailConfigAvailable(): bool { try { - // Check if feature is enabled in app config - $isEnabled = $this->config->getAppValue('mail', 'ionos-mailconfig-enabled', 'no') === 'yes'; - if (!$isEnabled) { + // Check if IONOS integration is enabled and configured + if (!$this->ionosConfigService->isIonosIntegrationEnabled()) { return false; } diff --git a/lib/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index bb18d5c3fb..e4b875588f 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -9,9 +9,10 @@ namespace OCA\Mail\Service\IONOS; +use IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi; use IONOS\MailConfigurationAPI\Client\ApiException; -use IONOS\MailConfigurationAPI\Client\Model\ErrorMessage; use IONOS\MailConfigurationAPI\Client\Model\MailAccountResponse; +use IONOS\MailConfigurationAPI\Client\Model\MailAddonErrorMessage; use IONOS\MailConfigurationAPI\Client\Model\MailCreateData; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; @@ -25,6 +26,8 @@ */ class IonosMailService { private const BRAND = 'IONOS'; + private const HTTP_NOT_FOUND = 404; + private const HTTP_INTERNAL_SERVER_ERROR = 500; public function __construct( private ApiMailConfigClientService $apiClientService, @@ -57,12 +60,7 @@ public function mailAccountExistsForCurrentUserId(string $userId): bool { 'extRef' => $this->configService->getExternalReference(), ]); - $client = $this->apiClientService->newClient([ - 'auth' => [$this->configService->getBasicAuthUser(), $this->configService->getBasicAuthPassword()], - 'verify' => !$this->configService->getAllowInsecure(), - ]); - - $apiInstance = $this->apiClientService->newEventAPIApi($client, $this->configService->getApiBaseUrl()); + $apiInstance = $this->createApiInstance(); $result = $apiInstance->getFunctionalAccount(self::BRAND, $this->configService->getExternalReference(), $userId); @@ -76,18 +74,17 @@ public function mailAccountExistsForCurrentUserId(string $userId): bool { return false; } catch (ApiException $e) { - $statusCode = $e->getCode(); // 404 - no account exists - if ($statusCode === 404) { + if ($e->getCode() === self::HTTP_NOT_FOUND) { $this->logger->debug('User does not have IONOS mail account', [ 'userId' => $userId, - 'statusCode' => $statusCode + 'statusCode' => $e->getCode() ]); return false; } $this->logger->error('API Exception when checking for existing mail account', [ - 'statusCode' => $statusCode, + 'statusCode' => $e->getCode(), 'message' => $e->getMessage(), 'responseBody' => $e->getResponseBody() ]); @@ -119,12 +116,7 @@ public function createEmailAccount(string $userName): MailAccountConfig { 'apiBaseUrl' => $this->configService->getApiBaseUrl() ]); - $client = $this->apiClientService->newClient([ - 'auth' => [$this->configService->getBasicAuthUser(), $this->configService->getBasicAuthPassword()], - 'verify' => !$this->configService->getAllowInsecure(), - ]); - - $apiInstance = $this->apiClientService->newEventAPIApi($client, $this->configService->getApiBaseUrl()); + $apiInstance = $this->createApiInstance(); $mailCreateData = new MailCreateData(); $mailCreateData->setNextcloudUserId($userId); @@ -136,14 +128,14 @@ public function createEmailAccount(string $userName): MailAccountConfig { 'userId' => $userId, 'userName' => $userName ]); - throw new ServiceException('Invalid mail configuration', 500); + throw new ServiceException('Invalid mail configuration', self::HTTP_INTERNAL_SERVER_ERROR); } try { $this->logger->debug('Send message to mailconfig service', ['data' => $mailCreateData]); $result = $apiInstance->createMailbox(self::BRAND, $this->configService->getExternalReference(), $mailCreateData); - if ($result instanceof ErrorMessage) { + if ($result instanceof MailAddonErrorMessage) { $this->logger->error('Failed to create ionos mail', [ 'status code' => $result->getStatus(), 'message' => $result->getMessage(), @@ -166,25 +158,24 @@ public function createEmailAccount(string $userName): MailAccountConfig { 'userId' => $userId, 'userName' => $userName ]); - throw new ServiceException('Failed to create ionos mail', 500); + throw new ServiceException('Failed to create ionos mail', self::HTTP_INTERNAL_SERVER_ERROR); } catch (ServiceException $e) { - // Re-throw ServiceException without modification + // Re-throw ServiceException without additional logging throw $e; } catch (ApiException $e) { - $statusCode = $e->getCode(); $this->logger->error('API Exception when calling MailConfigurationAPIApi->createMailbox', [ - 'statusCode' => $statusCode, + 'statusCode' => $e->getCode(), 'message' => $e->getMessage(), 'responseBody' => $e->getResponseBody() ]); - throw new ServiceException('Failed to create ionos mail: ' . $e->getMessage(), $statusCode, $e); + throw new ServiceException('Failed to create ionos mail: ' . $e->getMessage(), $e->getCode(), $e); } catch (\Exception $e) { $this->logger->error('Exception when calling MailConfigurationAPIApi->createMailbox', [ 'exception' => $e, 'userId' => $userId, 'userName' => $userName ]); - throw new ServiceException('Failed to create ionos mail', 500, $e); + throw new ServiceException('Failed to create ionos mail', self::HTTP_INTERNAL_SERVER_ERROR, $e); } } @@ -202,6 +193,20 @@ private function getCurrentUserId(): string { return $user->getUID(); } + /** + * Create and configure API instance with authentication + * + * @return MailConfigurationAPIApi + */ + private function createApiInstance(): MailConfigurationAPIApi { + $client = $this->apiClientService->newClient([ + 'auth' => [$this->configService->getBasicAuthUser(), $this->configService->getBasicAuthPassword()], + 'verify' => !$this->configService->getAllowInsecure(), + ]); + + return $this->apiClientService->newMailConfigurationAPIApi($client, $this->configService->getApiBaseUrl()); + } + /** * Normalize SSL mode from API response to expected format * @@ -261,4 +266,87 @@ private function buildSuccessResponse(MailAccountResponse $response): MailAccoun smtp: $smtpConfig, ); } + + /** + * Delete an IONOS email account via API + * + * @param string $userId The Nextcloud user ID + * @return bool true if deletion was successful, false otherwise + * @throws ServiceException + */ + public function deleteEmailAccount(string $userId): bool { + $this->logger->info('Attempting to delete IONOS email account', [ + 'userId' => $userId, + 'extRef' => $this->configService->getExternalReference(), + ]); + + try { + $apiInstance = $this->createApiInstance(); + + $apiInstance->deleteMailbox(self::BRAND, $this->configService->getExternalReference(), $userId); + + $this->logger->info('Successfully deleted IONOS email account', [ + 'userId' => $userId + ]); + + return true; + } catch (ApiException $e) { + // 404 means the mailbox doesn't exist - treat as success + if ($e->getCode() === self::HTTP_NOT_FOUND) { + $this->logger->debug('IONOS mailbox does not exist (already deleted or never created)', [ + 'userId' => $userId, + 'statusCode' => $e->getCode() + ]); + return true; + } + + $this->logger->error('API Exception when calling MailConfigurationAPIApi->deleteMailbox', [ + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + 'responseBody' => $e->getResponseBody(), + 'userId' => $userId + ]); + + throw new ServiceException('Failed to delete IONOS mail: ' . $e->getMessage(), $e->getCode(), $e); + } catch (\Exception $e) { + $this->logger->error('Exception when calling MailConfigurationAPIApi->deleteMailbox', [ + 'exception' => $e, + 'userId' => $userId + ]); + + throw new ServiceException('Failed to delete IONOS mail', self::HTTP_INTERNAL_SERVER_ERROR, $e); + } + } + + /** + * Delete an IONOS email account without throwing exceptions (fire and forget) + * + * This method checks if IONOS integration is enabled and attempts to delete + * the email account. All errors are logged but not thrown, making it safe + * to call in event listeners or other contexts where exceptions should not + * interrupt the flow. + * + * @param string $userId The Nextcloud user ID + * @return void + */ + public function tryDeleteEmailAccount(string $userId): void { + // Check if IONOS integration is enabled + if (!$this->configService->isIonosIntegrationEnabled()) { + $this->logger->debug('IONOS integration is not enabled, skipping email account deletion', [ + 'userId' => $userId + ]); + return; + } + + try { + $this->deleteEmailAccount($userId); + // Success is already logged by deleteEmailAccount + } catch (ServiceException $e) { + $this->logger->error('Failed to delete IONOS mailbox for user', [ + 'userId' => $userId, + 'exception' => $e, + ]); + // Don't throw - this is a fire and forget operation + } + } } diff --git a/tests/Unit/Listener/UserDeletedListenerTest.php b/tests/Unit/Listener/UserDeletedListenerTest.php new file mode 100644 index 0000000000..b027f03f6e --- /dev/null +++ b/tests/Unit/Listener/UserDeletedListenerTest.php @@ -0,0 +1,217 @@ +accountService = $this->createMock(AccountService::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->ionosMailService = $this->createMock(IonosMailService::class); + + $this->listener = new UserDeletedListener( + $this->accountService, + $this->logger, + $this->ionosMailService + ); + } + + private function createUserMock(string $userId): IUser&MockObject { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($userId); + return $user; + } + + private function createAccountMock(int $id): Account { + $mailAccount = new MailAccount(); + $mailAccount->setId($id); + return new Account($mailAccount); + } + + public function testImplementsIEventListener(): void { + $this->assertInstanceOf(\OCP\EventDispatcher\IEventListener::class, $this->listener); + } + + public function testHandleUnrelated(): void { + $event = new Event(); + + $this->ionosMailService->expects($this->never()) + ->method('tryDeleteEmailAccount'); + + $this->accountService->expects($this->never()) + ->method('findByUserId'); + + $this->listener->handle($event); + + $this->addToAssertionCount(1); + } + + public function testHandleUserDeletedWithNoAccounts(): void { + $user = $this->createUserMock('test-user'); + $event = new UserDeletedEvent($user); + + $this->ionosMailService->expects($this->once()) + ->method('tryDeleteEmailAccount') + ->with('test-user'); + + $this->accountService->expects($this->once()) + ->method('findByUserId') + ->with('test-user') + ->willReturn([]); + + $this->accountService->expects($this->never()) + ->method('delete'); + + $this->logger->expects($this->never()) + ->method('error'); + + $this->listener->handle($event); + } + + public function testHandleUserDeletedWithSingleAccount(): void { + $user = $this->createUserMock('test-user'); + $account = $this->createAccountMock(42); + $event = new UserDeletedEvent($user); + + $this->ionosMailService->expects($this->once()) + ->method('tryDeleteEmailAccount') + ->with('test-user'); + + $this->accountService->expects($this->once()) + ->method('findByUserId') + ->with('test-user') + ->willReturn([$account]); + + $this->accountService->expects($this->once()) + ->method('delete') + ->with('test-user', 42); + + $this->logger->expects($this->never()) + ->method('error'); + + $this->listener->handle($event); + } + + public function testHandleUserDeletedWithMultipleAccounts(): void { + $user = $this->createUserMock('test-user'); + $account1 = $this->createAccountMock(1); + $account2 = $this->createAccountMock(2); + $account3 = $this->createAccountMock(3); + $event = new UserDeletedEvent($user); + + $this->ionosMailService->expects($this->once()) + ->method('tryDeleteEmailAccount') + ->with('test-user'); + + $this->accountService->expects($this->once()) + ->method('findByUserId') + ->with('test-user') + ->willReturn([$account1, $account2, $account3]); + + $this->accountService->expects($this->exactly(3)) + ->method('delete') + ->willReturnCallback(function ($userId, $accountId) { + $this->assertSame('test-user', $userId); + $this->assertContains($accountId, [1, 2, 3]); + }); + + $this->logger->expects($this->never()) + ->method('error'); + + $this->listener->handle($event); + } + + public function testHandleUserDeletedWithClientException(): void { + $user = $this->createUserMock('test-user'); + $account = $this->createAccountMock(42); + $event = new UserDeletedEvent($user); + + $exception = new ClientException('Test exception'); + + $this->ionosMailService->expects($this->once()) + ->method('tryDeleteEmailAccount') + ->with('test-user'); + + $this->accountService->expects($this->once()) + ->method('findByUserId') + ->with('test-user') + ->willReturn([$account]); + + $this->accountService->expects($this->once()) + ->method('delete') + ->with('test-user', 42) + ->willThrowException($exception); + + $this->logger->expects($this->once()) + ->method('error') + ->with( + 'Could not delete user\'s Mail account: Test exception', + ['exception' => $exception] + ); + + $this->listener->handle($event); + } + + public function testHandleUserDeletedWithPartialFailure(): void { + $user = $this->createUserMock('test-user'); + $account1 = $this->createAccountMock(1); + $account2 = $this->createAccountMock(2); + $account3 = $this->createAccountMock(3); + $event = new UserDeletedEvent($user); + + $exception = new ClientException('Failed to delete account 2'); + + $this->ionosMailService->expects($this->once()) + ->method('tryDeleteEmailAccount') + ->with('test-user'); + + $this->accountService->expects($this->once()) + ->method('findByUserId') + ->with('test-user') + ->willReturn([$account1, $account2, $account3]); + + $this->accountService->expects($this->exactly(3)) + ->method('delete') + ->willReturnCallback(function ($userId, $accountId) use ($exception) { + $this->assertSame('test-user', $userId); + if ($accountId === 2) { + throw $exception; + } + }); + + $this->logger->expects($this->once()) + ->method('error') + ->with( + 'Could not delete user\'s Mail account: Failed to delete account 2', + ['exception' => $exception] + ); + + $this->listener->handle($event); + } +} diff --git a/tests/Unit/Service/IONOS/ApiMailConfigClientServiceTest.php b/tests/Unit/Service/IONOS/ApiMailConfigClientServiceTest.php index 751c1f2e91..3cf930e016 100644 --- a/tests/Unit/Service/IONOS/ApiMailConfigClientServiceTest.php +++ b/tests/Unit/Service/IONOS/ApiMailConfigClientServiceTest.php @@ -57,7 +57,7 @@ public function testNewEventAPIApi(): void { $client = $this->createMock(ClientInterface::class); $apiBaseUrl = 'https://api.example.com'; - $apiInstance = $this->service->newEventAPIApi($client, $apiBaseUrl); + $apiInstance = $this->service->newMailConfigurationAPIApi($client, $apiBaseUrl); $this->assertInstanceOf(MailConfigurationAPIApi::class, $apiInstance); $this->assertEquals($apiBaseUrl, $apiInstance->getConfig()->getHost()); @@ -70,7 +70,7 @@ public function testNewEventAPIApiWithEmptyUrl(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('API base URL is required'); - $this->service->newEventAPIApi($client, $apiBaseUrl); + $this->service->newMailConfigurationAPIApi($client, $apiBaseUrl); } public function testNewEventAPIApiWithDifferentUrls(): void { @@ -84,7 +84,7 @@ public function testNewEventAPIApiWithDifferentUrls(): void { ]; foreach ($urls as $url) { - $apiInstance = $this->service->newEventAPIApi($client, $url); + $apiInstance = $this->service->newMailConfigurationAPIApi($client, $url); $this->assertInstanceOf(MailConfigurationAPIApi::class, $apiInstance); $this->assertEquals($url, $apiInstance->getConfig()->getHost()); } @@ -104,8 +104,8 @@ public function testNewEventAPIApiReturnsNewInstanceEachTime(): void { $client = $this->createMock(ClientInterface::class); $apiBaseUrl = 'https://api.example.com'; - $api1 = $this->service->newEventAPIApi($client, $apiBaseUrl); - $api2 = $this->service->newEventAPIApi($client, $apiBaseUrl); + $api1 = $this->service->newMailConfigurationAPIApi($client, $apiBaseUrl); + $api2 = $this->service->newMailConfigurationAPIApi($client, $apiBaseUrl); // Each call should return a new instance $this->assertNotSame($api1, $api2); diff --git a/tests/Unit/Service/IONOS/IonosConfigServiceTest.php b/tests/Unit/Service/IONOS/IonosConfigServiceTest.php index b4391f3e53..9a3f6ea83e 100644 --- a/tests/Unit/Service/IONOS/IonosConfigServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosConfigServiceTest.php @@ -217,4 +217,77 @@ public function testGetMailDomainWithSimpleDomain(): void { $result = $this->service->getMailDomain(); $this->assertEquals('example.com', $result); } + + public function testIsMailConfigEnabledWhenEnabled(): void { + $this->config->method('getAppValue') + ->with('mail', 'ionos-mailconfig-enabled', 'no') + ->willReturn('yes'); + + $result = $this->service->isMailConfigEnabled(); + $this->assertTrue($result); + } + + public function testIsMailConfigEnabledWhenDisabled(): void { + $this->config->method('getAppValue') + ->with('mail', 'ionos-mailconfig-enabled', 'no') + ->willReturn('no'); + + $result = $this->service->isMailConfigEnabled(); + $this->assertFalse($result); + } + + public function testIsIonosIntegrationEnabledWhenFullyConfigured(): void { + $this->config->method('getAppValue') + ->with('mail', 'ionos-mailconfig-enabled', 'no') + ->willReturn('yes'); + + $this->config->method('getSystemValue') + ->with('ncw.ext_ref') + ->willReturn('test-ext-ref'); + + $this->appConfig->method('getValueString') + ->willReturnCallback(function ($appId, $key) { + $values = [ + 'ionos_mailconfig_api_base_url' => 'https://api.example.com', + 'ionos_mailconfig_api_auth_user' => 'testuser', + 'ionos_mailconfig_api_auth_pass' => 'testpass', + ]; + return $values[$key] ?? ''; + }); + + $this->appConfig->method('getValueBool') + ->with(Application::APP_ID, 'ionos_mailconfig_api_allow_insecure', false) + ->willReturn(false); + + $result = $this->service->isIonosIntegrationEnabled(); + $this->assertTrue($result); + } + + public function testIsIonosIntegrationEnabledWhenFeatureDisabled(): void { + $this->config->method('getAppValue') + ->with('mail', 'ionos-mailconfig-enabled', 'no') + ->willReturn('no'); + + $result = $this->service->isIonosIntegrationEnabled(); + $this->assertFalse($result); + } + + public function testIsIonosIntegrationEnabledWhenConfigurationMissing(): void { + $this->config->method('getAppValue') + ->with('mail', 'ionos-mailconfig-enabled', 'no') + ->willReturn('yes'); + + $this->config->method('getSystemValue') + ->with('ncw.ext_ref') + ->willReturn(''); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('IONOS integration not available - configuration error', $this->callback(function ($context) { + return isset($context['exception']) && $context['exception'] instanceof AppConfigException; + })); + + $result = $this->service->isIonosIntegrationEnabled(); + $this->assertFalse($result); + } } diff --git a/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php b/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php index 7ff700e96c..a05bdd9e82 100644 --- a/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php @@ -10,14 +10,14 @@ namespace OCA\Mail\Tests\Unit\Service\IONOS; use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Service\IONOS\IonosConfigService; use OCA\Mail\Service\IONOS\IonosMailConfigService; use OCA\Mail\Service\IONOS\IonosMailService; -use OCP\IConfig; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; class IonosMailConfigServiceTest extends TestCase { - private IConfig&MockObject $config; + private IonosConfigService&MockObject $ionosConfigService; private IonosMailService&MockObject $ionosMailService; private LoggerInterface&MockObject $logger; private IonosMailConfigService $service; @@ -25,22 +25,21 @@ class IonosMailConfigServiceTest extends TestCase { protected function setUp(): void { parent::setUp(); - $this->config = $this->createMock(IConfig::class); + $this->ionosConfigService = $this->createMock(IonosConfigService::class); $this->ionosMailService = $this->createMock(IonosMailService::class); $this->logger = $this->createMock(LoggerInterface::class); $this->service = new IonosMailConfigService( - $this->config, + $this->ionosConfigService, $this->ionosMailService, $this->logger, ); } public function testIsMailConfigAvailableReturnsFalseWhenFeatureDisabled(): void { - $this->config->expects($this->once()) - ->method('getAppValue') - ->with('mail', 'ionos-mailconfig-enabled', 'no') - ->willReturn('no'); + $this->ionosConfigService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(false); $this->ionosMailService->expects($this->never()) ->method('mailAccountExistsForCurrentUser'); @@ -51,10 +50,9 @@ public function testIsMailConfigAvailableReturnsFalseWhenFeatureDisabled(): void } public function testIsMailConfigAvailableReturnsTrueWhenUserHasNoAccount(): void { - $this->config->expects($this->once()) - ->method('getAppValue') - ->with('mail', 'ionos-mailconfig-enabled', 'no') - ->willReturn('yes'); + $this->ionosConfigService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); $this->ionosMailService->expects($this->once()) ->method('mailAccountExistsForCurrentUser') @@ -69,10 +67,9 @@ public function testIsMailConfigAvailableReturnsTrueWhenUserHasNoAccount(): void } public function testIsMailConfigAvailableReturnsFalseWhenUserHasAccount(): void { - $this->config->expects($this->once()) - ->method('getAppValue') - ->with('mail', 'ionos-mailconfig-enabled', 'no') - ->willReturn('yes'); + $this->ionosConfigService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); $this->ionosMailService->expects($this->once()) ->method('mailAccountExistsForCurrentUser') @@ -88,10 +85,9 @@ public function testIsMailConfigAvailableReturnsFalseWhenUserHasAccount(): void } public function testIsMailConfigAvailableReturnsFalseOnException(): void { - $this->config->expects($this->once()) - ->method('getAppValue') - ->with('mail', 'ionos-mailconfig-enabled', 'no') - ->willReturn('yes'); + $this->ionosConfigService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); $exception = new \Exception('Test exception'); diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php index 40948c6a82..1594b8318b 100644 --- a/tests/Unit/Service/IONOS/IonosMailServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailServiceTest.php @@ -12,9 +12,9 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use GuzzleHttp\ClientInterface; use IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi; -use IONOS\MailConfigurationAPI\Client\Model\ErrorMessage; use IONOS\MailConfigurationAPI\Client\Model\Imap; use IONOS\MailConfigurationAPI\Client\Model\MailAccountResponse; +use IONOS\MailConfigurationAPI\Client\Model\MailAddonErrorMessage; use IONOS\MailConfigurationAPI\Client\Model\MailServer; use IONOS\MailConfigurationAPI\Client\Model\Smtp; use OCA\Mail\Exception\ServiceException; @@ -78,7 +78,7 @@ public function testCreateEmailAccountSuccess(): void { ->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newEventAPIApi') + $this->apiClientService->method('newMailConfigurationAPIApi') ->with($client, 'https://api.example.com') ->willReturn($apiInstance); @@ -166,7 +166,7 @@ public function testCreateEmailAccountWithApiException(): void { $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newEventAPIApi')->willReturn($apiInstance); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); // Mock API to throw exception $apiInstance->method('createMailbox') @@ -191,7 +191,7 @@ public function testCreateEmailAccountWithApiException(): void { $this->service->createEmailAccount($userName); } - public function testCreateEmailAccountWithErrorMessageResponse(): void { + public function testCreateEmailAccountWithMailAddonErrorMessageResponse(): void { $userName = 'test'; $domain = 'example.com'; @@ -213,14 +213,14 @@ public function testCreateEmailAccountWithErrorMessageResponse(): void { $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newEventAPIApi')->willReturn($apiInstance); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock ErrorMessage response - $errorMessage = $this->getMockBuilder(ErrorMessage::class) + // Mock MailAddonErrorMessage response + $errorMessage = $this->getMockBuilder(MailAddonErrorMessage::class) ->disableOriginalConstructor() ->onlyMethods(['getStatus', 'getMessage']) ->getMock(); - $errorMessage->method('getStatus')->willReturn(400); + $errorMessage->method('getStatus')->willReturn(MailAddonErrorMessage::STATUS__400_BAD_REQUEST); $errorMessage->method('getMessage')->willReturn('Bad Request'); $apiInstance->method('createMailbox')->willReturn($errorMessage); @@ -232,7 +232,7 @@ public function testCreateEmailAccountWithErrorMessageResponse(): void { $this->logger->expects($this->once()) ->method('error') ->with('Failed to create ionos mail', $this->callback(function ($context) use ($userName) { - return $context['status code'] === 400 + return $context['status code'] === MailAddonErrorMessage::STATUS__400_BAD_REQUEST && $context['message'] === 'Bad Request' && $context['userId'] === 'testuser123' && $context['userName'] === $userName; @@ -267,7 +267,7 @@ public function testCreateEmailAccountWithUnknownResponseType(): void { $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newEventAPIApi')->willReturn($apiInstance); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); // Mock unknown response type (return a stdClass instead of expected types) $unknownResponse = new \stdClass(); @@ -343,7 +343,7 @@ public function testSslModeNormalization(string $apiSslMode, string $expectedSec $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newEventAPIApi')->willReturn($apiInstance); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); // Mock API response with specific SSL mode $imapServer = $this->getMockBuilder(Imap::class) @@ -449,7 +449,7 @@ public function testMailAccountExistsForCurrentUserReturnsTrueWhenAccountExists( $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newEventAPIApi')->willReturn($apiInstance); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); // Mock API response with existing account $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) @@ -489,7 +489,7 @@ public function testMailAccountExistsForCurrentUserReturnsFalseWhen404(): void { $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newEventAPIApi')->willReturn($apiInstance); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); // Mock API to throw 404 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( @@ -530,7 +530,7 @@ public function testMailAccountExistsForCurrentUserReturnsFalseOnApiError(): voi $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newEventAPIApi')->willReturn($apiInstance); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); // Mock API to throw 500 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( @@ -578,7 +578,7 @@ public function testMailAccountExistsForCurrentUserReturnsFalseOnGeneralExceptio $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newEventAPIApi')->willReturn($apiInstance); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); // Mock API to throw general exception $apiInstance->method('getFunctionalAccount') @@ -600,4 +600,441 @@ public function testMailAccountExistsForCurrentUserReturnsFalseOnGeneralExceptio $this->assertFalse($result); } + + + public function testDeleteEmailAccountSuccess(): void { + $userId = 'testuser123'; + + // Mock config + $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); + $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); + $this->configService->method('getAllowInsecure')->willReturn(false); + $this->configService->method('getBasicAuthUser')->willReturn('testuser'); + $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + + // Mock API client + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient') + ->with([ + 'auth' => ['testuser', 'testpass'], + 'verify' => true, + ]) + ->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi') + ->with($client, 'https://api.example.com') + ->willReturn($apiInstance); + + // Mock successful deletion (returns void) + $apiInstance->expects($this->once()) + ->method('deleteMailbox') + ->with('IONOS', 'test-ext-ref', $userId); + + // Expect logging calls + $callCount = 0; + $this->logger->expects($this->exactly(2)) + ->method('info') + ->willReturnCallback(function ($message, $context) use ($userId, &$callCount) { + $callCount++; + if ($callCount === 1) { + $this->assertEquals('Attempting to delete IONOS email account', $message); + $this->assertEquals($userId, $context['userId']); + $this->assertEquals('test-ext-ref', $context['extRef']); + } elseif ($callCount === 2) { + $this->assertEquals('Successfully deleted IONOS email account', $message); + $this->assertEquals($userId, $context['userId']); + } + }); + + $result = $this->service->deleteEmailAccount($userId); + + $this->assertTrue($result); + } + + public function testDeleteEmailAccountReturns404AlreadyDeleted(): void { + $userId = 'testuser123'; + + // Mock config + $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); + $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); + $this->configService->method('getAllowInsecure')->willReturn(false); + $this->configService->method('getBasicAuthUser')->willReturn('testuser'); + $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + + // Mock API client + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); + + // Mock API to throw 404 exception (mailbox doesn't exist) + $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( + 'Not Found', + 404, + [], + '{"error": "Not Found"}' + ); + + $apiInstance->expects($this->once()) + ->method('deleteMailbox') + ->with('IONOS', 'test-ext-ref', $userId) + ->willThrowException($apiException); + + // Expect logging calls + $this->logger->expects($this->once()) + ->method('info') + ->with('Attempting to delete IONOS email account', $this->callback(function ($context) use ($userId) { + return $context['userId'] === $userId + && $context['extRef'] === 'test-ext-ref'; + })); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('IONOS mailbox does not exist (already deleted or never created)', $this->callback(function ($context) use ($userId) { + return $context['userId'] === $userId + && $context['statusCode'] === 404; + })); + + // Should return true for 404 (treat as success) + $result = $this->service->deleteEmailAccount($userId); + + $this->assertTrue($result); + } + + public function testDeleteEmailAccountThrowsExceptionOnApiError(): void { + $userId = 'testuser123'; + + // Mock config + $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); + $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); + $this->configService->method('getAllowInsecure')->willReturn(false); + $this->configService->method('getBasicAuthUser')->willReturn('testuser'); + $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + + // Mock API client + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); + + // Mock API to throw 500 exception + $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( + 'Internal Server Error', + 500, + [], + '{"error": "Server error"}' + ); + + $apiInstance->expects($this->once()) + ->method('deleteMailbox') + ->with('IONOS', 'test-ext-ref', $userId) + ->willThrowException($apiException); + + // Expect logging calls + $this->logger->expects($this->once()) + ->method('info') + ->with('Attempting to delete IONOS email account', $this->callback(function ($context) use ($userId) { + return $context['userId'] === $userId + && $context['extRef'] === 'test-ext-ref'; + })); + + $this->logger->expects($this->once()) + ->method('error') + ->with('API Exception when calling MailConfigurationAPIApi->deleteMailbox', $this->callback(function ($context) use ($userId) { + return $context['statusCode'] === 500 + && $context['message'] === 'Internal Server Error' + && $context['userId'] === $userId; + })); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Failed to delete IONOS mail: Internal Server Error'); + $this->expectExceptionCode(500); + + $this->service->deleteEmailAccount($userId); + } + + public function testDeleteEmailAccountThrowsExceptionOnGeneralError(): void { + $userId = 'testuser123'; + + // Mock config + $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); + $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); + $this->configService->method('getAllowInsecure')->willReturn(false); + $this->configService->method('getBasicAuthUser')->willReturn('testuser'); + $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + + // Mock API client + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); + + // Mock API to throw general exception + $generalException = new \Exception('Unexpected error'); + + $apiInstance->expects($this->once()) + ->method('deleteMailbox') + ->with('IONOS', 'test-ext-ref', $userId) + ->willThrowException($generalException); + + // Expect logging calls + $this->logger->expects($this->once()) + ->method('info') + ->with('Attempting to delete IONOS email account', $this->callback(function ($context) use ($userId) { + return $context['userId'] === $userId + && $context['extRef'] === 'test-ext-ref'; + })); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Exception when calling MailConfigurationAPIApi->deleteMailbox', $this->callback(function ($context) use ($userId) { + return isset($context['exception']) + && $context['userId'] === $userId; + })); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Failed to delete IONOS mail'); + $this->expectExceptionCode(500); + + $this->service->deleteEmailAccount($userId); + } + + public function testDeleteEmailAccountWithInsecureConnection(): void { + $userId = 'testuser123'; + + // Mock config with insecure connection allowed + $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); + $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); + $this->configService->method('getAllowInsecure')->willReturn(true); + $this->configService->method('getBasicAuthUser')->willReturn('testuser'); + $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + + // Mock API client - verify should be false + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient') + ->with([ + 'auth' => ['testuser', 'testpass'], + 'verify' => false, + ]) + ->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi') + ->with($client, 'https://api.example.com') + ->willReturn($apiInstance); + + // Mock successful deletion + $apiInstance->expects($this->once()) + ->method('deleteMailbox') + ->with('IONOS', 'test-ext-ref', $userId); + + $this->logger->expects($this->exactly(2)) + ->method('info'); + + $result = $this->service->deleteEmailAccount($userId); + + $this->assertTrue($result); + } + + public function testTryDeleteEmailAccountWhenIntegrationDisabled(): void { + $userId = 'testuser123'; + + // Mock integration as disabled + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(false); + + // Should log that integration is not enabled + $this->logger->expects($this->once()) + ->method('debug') + ->with( + 'IONOS integration is not enabled, skipping email account deletion', + ['userId' => $userId] + ); + + // Should not attempt to create API client + $this->apiClientService->expects($this->never()) + ->method('newClient'); + + // Call tryDeleteEmailAccount - should not throw exception + $this->service->tryDeleteEmailAccount($userId); + + $this->addToAssertionCount(1); + } + + public function testTryDeleteEmailAccountWhenIntegrationEnabledSuccess(): void { + $userId = 'testuser123'; + + // Mock integration as enabled + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + // Mock config + $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); + $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); + $this->configService->method('getAllowInsecure')->willReturn(false); + $this->configService->method('getBasicAuthUser')->willReturn('testuser'); + $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + + // Mock API client + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient') + ->with([ + 'auth' => ['testuser', 'testpass'], + 'verify' => true, + ]) + ->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi') + ->with($client, 'https://api.example.com') + ->willReturn($apiInstance); + + // Mock successful deletion + $apiInstance->expects($this->once()) + ->method('deleteMailbox') + ->with('IONOS', 'test-ext-ref', $userId); + + // Should log success at info level (from deleteEmailAccount only) + $this->logger->expects($this->exactly(2)) + ->method('info') + ->willReturnCallback(function ($message, $context) use ($userId) { + if ($message === 'Attempting to delete IONOS email account') { + $this->assertSame($userId, $context['userId']); + $this->assertSame('test-ext-ref', $context['extRef']); + } elseif ($message === 'Successfully deleted IONOS email account') { + $this->assertSame($userId, $context['userId']); + } + }); + + // Call tryDeleteEmailAccount - should not throw exception + $this->service->tryDeleteEmailAccount($userId); + + $this->addToAssertionCount(1); + } + + public function testTryDeleteEmailAccountWhenIntegrationEnabledButDeletionFails(): void { + $userId = 'testuser123'; + + // Mock integration as enabled + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + // Mock config + $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); + $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); + $this->configService->method('getAllowInsecure')->willReturn(false); + $this->configService->method('getBasicAuthUser')->willReturn('testuser'); + $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + + // Mock API client + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient') + ->with([ + 'auth' => ['testuser', 'testpass'], + 'verify' => true, + ]) + ->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi') + ->with($client, 'https://api.example.com') + ->willReturn($apiInstance); + + // Mock API exception + $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException('API Error', 500); + $apiInstance->expects($this->once()) + ->method('deleteMailbox') + ->with('IONOS', 'test-ext-ref', $userId) + ->willThrowException($apiException); + + // Should log the error from deleteEmailAccount and then from tryDeleteEmailAccount + $this->logger->expects($this->exactly(2)) + ->method('error') + ->willReturnCallback(function ($message, $context) use ($userId) { + if ($message === 'API Exception when calling MailConfigurationAPIApi->deleteMailbox') { + // This is from deleteEmailAccount + $this->assertSame($userId, $context['userId']); + $this->assertSame(500, $context['statusCode']); + } elseif ($message === 'Failed to delete IONOS mailbox for user') { + // This is from tryDeleteEmailAccount + $this->assertSame($userId, $context['userId']); + $this->assertInstanceOf(ServiceException::class, $context['exception']); + } + }); + + // Call tryDeleteEmailAccount - should NOT throw exception (fire and forget) + $this->service->tryDeleteEmailAccount($userId); + + $this->addToAssertionCount(1); + } + + public function testTryDeleteEmailAccountWhenMailboxNotFound(): void { + $userId = 'testuser123'; + + // Mock integration as enabled + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + // Mock config + $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); + $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); + $this->configService->method('getAllowInsecure')->willReturn(false); + $this->configService->method('getBasicAuthUser')->willReturn('testuser'); + $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + + // Mock API client + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient') + ->with([ + 'auth' => ['testuser', 'testpass'], + 'verify' => true, + ]) + ->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi') + ->with($client, 'https://api.example.com') + ->willReturn($apiInstance); + + // Mock 404 API exception (mailbox already deleted or never existed) + $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException('Not Found', 404); + $apiInstance->expects($this->once()) + ->method('deleteMailbox') + ->with('IONOS', 'test-ext-ref', $userId) + ->willThrowException($apiException); + + // Should log at info level (from deleteEmailAccount) and debug (404 is treated as success) + $this->logger->expects($this->once()) + ->method('info') + ->with( + 'Attempting to delete IONOS email account', + [ + 'userId' => $userId, + 'extRef' => 'test-ext-ref', + ] + ); + + $this->logger->expects($this->once()) + ->method('debug') + ->with( + 'IONOS mailbox does not exist (already deleted or never created)', + [ + 'userId' => $userId, + 'statusCode' => 404 + ] + ); + + // Call tryDeleteEmailAccount - should NOT throw exception + $this->service->tryDeleteEmailAccount($userId); + + $this->addToAssertionCount(1); + } }