From 1cf3d879e0253823d6609e54b9e05aa53f8a5dfa Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 7 Nov 2025 17:49:56 +0100 Subject: [PATCH 1/8] IONOS(ionos-mail): update IONOS Mail API client reference to 2.0.0-20251110130214 https://github.com/IONOS-Productivity/ionos-mail-configuration-api-client/releases/tag/2.0.0-20251110130214 Signed-off-by: Misha M.-Kupriyanov --- composer.json | 2 +- composer.lock | 4 ++-- lib/Service/IONOS/IonosMailService.php | 6 +++--- tests/Unit/Service/IONOS/IonosMailServiceTest.php | 12 ++++++------ 4 files changed, 12 insertions(+), 12 deletions(-) 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/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index bb18d5c3fb..546f549913 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -10,8 +10,8 @@ namespace OCA\Mail\Service\IONOS; 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; @@ -143,7 +143,7 @@ public function createEmailAccount(string $userName): MailAccountConfig { $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(), @@ -168,7 +168,7 @@ public function createEmailAccount(string $userName): MailAccountConfig { ]); throw new ServiceException('Failed to create ionos mail', 500); } catch (ServiceException $e) { - // Re-throw ServiceException without modification + // Re-throw ServiceException without additional logging throw $e; } catch (ApiException $e) { $statusCode = $e->getCode(); diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php index 40948c6a82..df0f15a52a 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; @@ -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'; @@ -215,12 +215,12 @@ public function testCreateEmailAccountWithErrorMessageResponse(): void { $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newEventAPIApi')->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; From 0d2da75e65f9287ff88df2c45458459206ede1da Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 28 Nov 2025 17:50:56 +0100 Subject: [PATCH 2/8] IONOS(ionos-mail): feat(IonosMailService): refactored duplicate code Signed-off-by: Misha M.-Kupriyanov --- lib/Service/IONOS/IonosMailService.php | 49 ++++++++++++++------------ 1 file changed, 27 insertions(+), 22 deletions(-) diff --git a/lib/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index 546f549913..620b6cb85d 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -9,6 +9,7 @@ namespace OCA\Mail\Service\IONOS; +use IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi; use IONOS\MailConfigurationAPI\Client\ApiException; use IONOS\MailConfigurationAPI\Client\Model\MailAccountResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAddonErrorMessage; @@ -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,7 +128,7 @@ 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 { @@ -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 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->newEventAPIApi($client, $this->configService->getApiBaseUrl()); + } + /** * Normalize SSL mode from API response to expected format * From 54cebb6a0e807d55de6390134bbb2673b31a388b Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 28 Nov 2025 15:40:25 +0100 Subject: [PATCH 3/8] IONOS(ionos-mail): refactor(api): rename newEventAPIApi method for clarity Updated the method name from `newEventAPIApi` to `newMailConfigurationAPIApi` to better reflect its purpose in creating a new MailConfiguration API instance. Signed-off-by: Misha M.-Kupriyanov --- .../IONOS/ApiMailConfigClientService.php | 4 ++-- lib/Service/IONOS/IonosMailService.php | 2 +- .../IONOS/ApiMailConfigClientServiceTest.php | 10 +++++----- .../Service/IONOS/IonosMailServiceTest.php | 18 +++++++++--------- 4 files changed, 17 insertions(+), 17 deletions(-) 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/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index 620b6cb85d..53c3000592 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -204,7 +204,7 @@ private function createApiInstance(): MailConfigurationAPIApi { 'verify' => !$this->configService->getAllowInsecure(), ]); - return $this->apiClientService->newEventAPIApi($client, $this->configService->getApiBaseUrl()); + return $this->apiClientService->newMailConfigurationAPIApi($client, $this->configService->getApiBaseUrl()); } /** 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/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php index df0f15a52a..c44de8b681 100644 --- a/tests/Unit/Service/IONOS/IonosMailServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailServiceTest.php @@ -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') @@ -213,7 +213,7 @@ public function testCreateEmailAccountWithMailAddonErrorMessageResponse(): 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 MailAddonErrorMessage response $errorMessage = $this->getMockBuilder(MailAddonErrorMessage::class) @@ -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') From 97d83c16127d8e3881598c1703a8151f8d42fac6 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 28 Nov 2025 16:42:32 +0100 Subject: [PATCH 4/8] test(listener): add unit tests for UserDeletedListener functionality Added comprehensive unit tests for the UserDeletedListener to ensure proper handling of user deletion events, including scenarios with no accounts, single and multiple accounts, client exceptions, and partial failures. This enhances test coverage and reliability of the mail account deletion process. Signed-off-by: Misha M.-Kupriyanov --- .../Unit/Listener/UserDeletedListenerTest.php | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 tests/Unit/Listener/UserDeletedListenerTest.php diff --git a/tests/Unit/Listener/UserDeletedListenerTest.php b/tests/Unit/Listener/UserDeletedListenerTest.php new file mode 100644 index 0000000000..fb4ff82ce1 --- /dev/null +++ b/tests/Unit/Listener/UserDeletedListenerTest.php @@ -0,0 +1,190 @@ +accountService = $this->createMock(AccountService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->listener = new UserDeletedListener( + $this->accountService, + $this->logger + ); + } + + 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->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->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->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->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->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->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); + } +} From 17d87c3c6d981a9e7c1ef3db5c803d39569714c0 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 28 Nov 2025 15:43:55 +0100 Subject: [PATCH 5/8] IONOS(ionos-mail): refactor(listener): optimize user ID retrieval in deletion process Improves code clarity by storing user ID in a variable before use in account deletion. Signed-off-by: Misha M.-Kupriyanov --- lib/Listener/UserDeletedListener.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/Listener/UserDeletedListener.php b/lib/Listener/UserDeletedListener.php index 0f40a66848..df8dcf8cb8 100644 --- a/lib/Listener/UserDeletedListener.php +++ b/lib/Listener/UserDeletedListener.php @@ -40,10 +40,11 @@ public function handle(Event $event): void { } $user = $event->getUser(); - foreach ($this->accountService->findByUserId($user->getUID()) as $account) { + $userId = $user->getUID(); + foreach ($this->accountService->findByUserId($userId) as $account) { try { $this->accountService->delete( - $user->getUID(), + $userId, $account->getId() ); } catch (ClientException $e) { From 7be859d89c9abef189d72b1f4e57902486a9ffed Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 28 Nov 2025 16:15:25 +0100 Subject: [PATCH 6/8] IONOS(ionos-mail): refactor(config): introduce IonosConfigService for mail configuration checks This change encapsulates the logic for checking if the IONOS mail configuration feature is enabled within a dedicated service, improving code organization and readability. The existing mail configuration checks in IonosMailConfigService have been refactored to utilize this new service. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/IONOS/IonosConfigService.php | 7 ++++ lib/Service/IONOS/IonosMailConfigService.php | 6 ++-- .../IONOS/IonosMailConfigServiceTest.php | 36 +++++++++---------- 3 files changed, 25 insertions(+), 24 deletions(-) diff --git a/lib/Service/IONOS/IonosConfigService.php b/lib/Service/IONOS/IonosConfigService.php index 6606c510bb..dd4900390f 100644 --- a/lib/Service/IONOS/IonosConfigService.php +++ b/lib/Service/IONOS/IonosConfigService.php @@ -129,6 +129,13 @@ 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'; + } + /** * Get the mail domain from customer domain * diff --git a/lib/Service/IONOS/IonosMailConfigService.php b/lib/Service/IONOS/IonosMailConfigService.php index 4f74a89c4c..54ebfd7370 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, ) { @@ -35,8 +34,7 @@ public function __construct( 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) { + if (!$this->ionosConfigService->isMailConfigEnabled()) { return false; } diff --git a/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php b/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php index 7ff700e96c..f5fb1eeb56 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('isMailConfigEnabled') + ->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('isMailConfigEnabled') + ->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('isMailConfigEnabled') + ->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('isMailConfigEnabled') + ->willReturn(true); $exception = new \Exception('Test exception'); From 26634e2c2e6a6ddbc77df2a4ff108962b6b3dea6 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 28 Nov 2025 16:28:33 +0100 Subject: [PATCH 7/8] IONOS(ionos-mail): refactor(config): enhance IonosConfigService with integration checks - Added isIonosIntegrationEnabled method to verify full integration configuration. - Updated isMailConfigAvailable method to utilize the new integration check. - Enhanced unit tests to cover new integration validation scenarios. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/IONOS/IonosConfigService.php | 29 ++++++++ lib/Service/IONOS/IonosMailConfigService.php | 6 +- .../Service/IONOS/IonosConfigServiceTest.php | 73 +++++++++++++++++++ .../IONOS/IonosMailConfigServiceTest.php | 8 +- 4 files changed, 109 insertions(+), 7 deletions(-) diff --git a/lib/Service/IONOS/IonosConfigService.php b/lib/Service/IONOS/IonosConfigService.php index dd4900390f..3b5ff579ee 100644 --- a/lib/Service/IONOS/IonosConfigService.php +++ b/lib/Service/IONOS/IonosConfigService.php @@ -136,6 +136,35 @@ 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 54ebfd7370..77e9b8126e 100644 --- a/lib/Service/IONOS/IonosMailConfigService.php +++ b/lib/Service/IONOS/IonosMailConfigService.php @@ -26,15 +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 - if (!$this->ionosConfigService->isMailConfigEnabled()) { + // Check if IONOS integration is enabled and configured + if (!$this->ionosConfigService->isIonosIntegrationEnabled()) { return false; } 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 f5fb1eeb56..a05bdd9e82 100644 --- a/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php @@ -38,7 +38,7 @@ protected function setUp(): void { public function testIsMailConfigAvailableReturnsFalseWhenFeatureDisabled(): void { $this->ionosConfigService->expects($this->once()) - ->method('isMailConfigEnabled') + ->method('isIonosIntegrationEnabled') ->willReturn(false); $this->ionosMailService->expects($this->never()) @@ -51,7 +51,7 @@ public function testIsMailConfigAvailableReturnsFalseWhenFeatureDisabled(): void public function testIsMailConfigAvailableReturnsTrueWhenUserHasNoAccount(): void { $this->ionosConfigService->expects($this->once()) - ->method('isMailConfigEnabled') + ->method('isIonosIntegrationEnabled') ->willReturn(true); $this->ionosMailService->expects($this->once()) @@ -68,7 +68,7 @@ public function testIsMailConfigAvailableReturnsTrueWhenUserHasNoAccount(): void public function testIsMailConfigAvailableReturnsFalseWhenUserHasAccount(): void { $this->ionosConfigService->expects($this->once()) - ->method('isMailConfigEnabled') + ->method('isIonosIntegrationEnabled') ->willReturn(true); $this->ionosMailService->expects($this->once()) @@ -86,7 +86,7 @@ public function testIsMailConfigAvailableReturnsFalseWhenUserHasAccount(): void public function testIsMailConfigAvailableReturnsFalseOnException(): void { $this->ionosConfigService->expects($this->once()) - ->method('isMailConfigEnabled') + ->method('isIonosIntegrationEnabled') ->willReturn(true); $exception = new \Exception('Test exception'); From 3071fa306274ed893b46d06d68bdbef197cab0dc Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 28 Nov 2025 17:16:09 +0100 Subject: [PATCH 8/8] IONOS(ionos-mail): feat(listener): implement IONOS mailbox deletion on user deletion Add functionality to delete the IONOS mailbox when a user is deleted, if the IONOS integration is enabled. This enhances the user deletion process by ensuring associated email accounts are also removed, improving data management and compliance. Signed-off-by: Misha M.-Kupriyanov --- lib/Listener/UserDeletedListener.php | 13 +- lib/Service/IONOS/IonosMailService.php | 83 ++++ .../Unit/Listener/UserDeletedListenerTest.php | 29 +- .../Service/IONOS/IonosMailServiceTest.php | 437 ++++++++++++++++++ 4 files changed, 559 insertions(+), 3 deletions(-) diff --git a/lib/Listener/UserDeletedListener.php b/lib/Listener/UserDeletedListener.php index df8dcf8cb8..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; } @@ -41,6 +45,11 @@ public function handle(Event $event): void { $user = $event->getUser(); $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( diff --git a/lib/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index 53c3000592..e4b875588f 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -266,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 index fb4ff82ce1..b027f03f6e 100644 --- a/tests/Unit/Listener/UserDeletedListenerTest.php +++ b/tests/Unit/Listener/UserDeletedListenerTest.php @@ -15,6 +15,7 @@ use OCA\Mail\Exception\ClientException; use OCA\Mail\Listener\UserDeletedListener; use OCA\Mail\Service\AccountService; +use OCA\Mail\Service\IONOS\IonosMailService; use OCP\EventDispatcher\Event; use OCP\IUser; use OCP\User\Events\UserDeletedEvent; @@ -24,6 +25,7 @@ class UserDeletedListenerTest extends TestCase { private AccountService&MockObject $accountService; private LoggerInterface&MockObject $logger; + private IonosMailService&MockObject $ionosMailService; private UserDeletedListener $listener; protected function setUp(): void { @@ -31,10 +33,12 @@ protected function setUp(): void { $this->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->logger, + $this->ionosMailService ); } @@ -57,6 +61,9 @@ public function testImplementsIEventListener(): void { public function testHandleUnrelated(): void { $event = new Event(); + $this->ionosMailService->expects($this->never()) + ->method('tryDeleteEmailAccount'); + $this->accountService->expects($this->never()) ->method('findByUserId'); @@ -69,6 +76,10 @@ 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') @@ -88,6 +99,10 @@ public function testHandleUserDeletedWithSingleAccount(): void { $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') @@ -110,6 +125,10 @@ public function testHandleUserDeletedWithMultipleAccounts(): void { $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') @@ -135,6 +154,10 @@ public function testHandleUserDeletedWithClientException(): void { $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') @@ -164,6 +187,10 @@ public function testHandleUserDeletedWithPartialFailure(): void { $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') diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php index c44de8b681..1594b8318b 100644 --- a/tests/Unit/Service/IONOS/IonosMailServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailServiceTest.php @@ -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); + } }