From 7c5f6dd5ef74af2a94afa63cf34b72126e3e43a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Thu, 20 Aug 2026 17:56:29 +0200 Subject: [PATCH] feat: create ClickUp task after confirming accommodation booking --- .env | 7 + config/packages/messenger.yaml | 1 + config/services.yaml | 9 +- src/ClickUp/ApiClient.php | 104 ++++++++++++++ src/ClickUp/Exception/ClickUpException.php | 9 ++ .../MailjetNewsletterWebhookCommand.php | 4 +- .../ConfirmController.php | 6 + .../ApiClient.php} | 4 +- .../CreateClickUpBookingTaskMessage.php | 13 ++ .../CreateClickUpBookingTaskHandler.php | 85 +++++++++++ src/Service/NewsletterManager.php | 3 +- tests/ClickUp/ApiClientTest.php | 99 +++++++++++++ .../MailjetNewsletterWebhookCommandTest.php | 12 +- .../ConfirmControllerTest.php | 35 ++++- .../ApiClientTest.php} | 16 +-- .../MailjetNewsletterWebhookClientTest.php | 8 +- .../CreateClickUpBookingTaskHandlerTest.php | 132 ++++++++++++++++++ tests/Service/NewsletterManagerTest.php | 36 ++--- 18 files changed, 535 insertions(+), 48 deletions(-) create mode 100644 src/ClickUp/ApiClient.php create mode 100644 src/ClickUp/Exception/ClickUpException.php rename src/{Service/MailjetApiClient.php => Mailjet/ApiClient.php} (99%) create mode 100644 src/Message/CreateClickUpBookingTaskMessage.php create mode 100644 src/MessageHandler/CreateClickUpBookingTaskHandler.php create mode 100644 tests/ClickUp/ApiClientTest.php rename tests/{Service/MailjetApiClientTest.php => Mailjet/ApiClientTest.php} (92%) rename tests/{Service => Mailjet}/MailjetNewsletterWebhookClientTest.php (85%) create mode 100644 tests/MessageHandler/CreateClickUpBookingTaskHandlerTest.php diff --git a/.env b/.env index 798f8d9..0ad4f4b 100644 --- a/.env +++ b/.env @@ -61,6 +61,13 @@ ACCOMMODATION_TERMS_IT= BPN_CONNECT_BASE_URL=https://bpn-connect.ep-reisen.app BPN_CONNECT_API_KEY= +# ClickUp task created when an accommodation booking is confirmed. Token/ids are secrets and +# stay empty here — set them in .env.local. Without them the handler skips with a warning. +CLICKUP_API_TOKEN= +CLICKUP_LIST_ID= +CLICKUP_TEMPLATE_ID= +CLICKUP_API_BASE_URL=https://api.clickup.com/api/v2 + APP_BPN_USER= APP_BPN_PASSWORD= APP_BPN_IP= diff --git a/config/packages/messenger.yaml b/config/packages/messenger.yaml index 1a2b0e4..215cce7 100644 --- a/config/packages/messenger.yaml +++ b/config/packages/messenger.yaml @@ -25,6 +25,7 @@ framework: Symfony\Component\Notifier\Message\ChatMessage: async Symfony\Component\Notifier\Message\SmsMessage: async App\Message\MailjetNewsletterEventMessage: async + App\Message\CreateClickUpBookingTaskMessage: async # Route your messages to the transports # 'App\Message\YourMessage': async diff --git a/config/services.yaml b/config/services.yaml index 35c423c..299f042 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -329,7 +329,14 @@ services: App\BpnConnect\ContingentsClient: parent: App\BpnConnect\AbstractApiClient - App\Service\MailjetApiClient: + App\ClickUp\ApiClient: + arguments: + $apiToken: '%env(default::CLICKUP_API_TOKEN)%' + $listId: '%env(default::CLICKUP_LIST_ID)%' + $templateId: '%env(default::CLICKUP_TEMPLATE_ID)%' + $baseUrl: '%env(default::CLICKUP_API_BASE_URL)%' + + App\Mailjet\ApiClient: arguments: $apiKey: '%env(default::MAILJET_API_KEY)%' $apiSecret: '%env(default::MAILJET_API_SECRET)%' diff --git a/src/ClickUp/ApiClient.php b/src/ClickUp/ApiClient.php new file mode 100644 index 0000000..e2cfc63 --- /dev/null +++ b/src/ClickUp/ApiClient.php @@ -0,0 +1,104 @@ +apiToken && '' !== $this->apiToken + && null !== $this->listId && '' !== $this->listId + && null !== $this->templateId && '' !== $this->templateId; + } + + /** + * Creates a task from the configured template and returns the new task id. + */ + public function createTaskFromTemplate(string $name): string + { + $data = $this->request('POST', sprintf('/list/%s/taskTemplate/%s', $this->listId, $this->templateId), [ + 'name' => $name, + ]); + + $id = $data['id'] ?? null; + if (!is_string($id) || '' === $id) { + throw new ClickUpException('ClickUp did not return a task id for the created task.'); + } + + return $id; + } + + public function updateTaskStatus(string $taskId, string $status): void + { + $this->request('PUT', '/task/'.$taskId, ['status' => $status]); + } + + /** + * @param array $payload + * + * @return array + */ + private function request(string $method, string $path, array $payload): array + { + $this->assertConfigured(); + + try { + $response = $this->httpClient->request($method, ($this->baseUrl ?? self::DEFAULT_BASE_URL).$path, [ + 'headers' => [ + // ClickUp deviates from the usual scheme: the raw token goes into the + // Authorization header, without a "Bearer " prefix. + 'Authorization' => $this->apiToken, + ], + 'json' => $payload, + 'timeout' => self::IDLE_TIMEOUT_SECONDS, + 'max_duration' => self::MAX_DURATION_SECONDS, + ]); + + return $response->toArray(); + } catch (ExceptionInterface $e) { + $this->logger->error('ClickUp request failed', [ + 'method' => $method, + 'path' => $path, + 'error' => $e->getMessage(), + ]); + + throw new ClickUpException('ClickUp request failed: '.$e->getMessage(), 0, $e); + } + } + + private function assertConfigured(): void + { + if (!$this->isConfigured()) { + throw new ClickUpException('ClickUp client is not configured (missing CLICKUP_API_TOKEN, CLICKUP_LIST_ID or CLICKUP_TEMPLATE_ID).'); + } + } +} diff --git a/src/ClickUp/Exception/ClickUpException.php b/src/ClickUp/Exception/ClickUpException.php new file mode 100644 index 0000000..518bd1d --- /dev/null +++ b/src/ClickUp/Exception/ClickUpException.php @@ -0,0 +1,9 @@ + $booking->getId(), ]); + // Creating the ClickUp task takes seconds, so it must not hang off this request. + $this->messageBus->dispatch(new CreateClickUpBookingTaskMessage((int) $booking->getId())); + return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()])); } diff --git a/src/Service/MailjetApiClient.php b/src/Mailjet/ApiClient.php similarity index 99% rename from src/Service/MailjetApiClient.php rename to src/Mailjet/ApiClient.php index 1d48fb8..6f8e618 100644 --- a/src/Service/MailjetApiClient.php +++ b/src/Mailjet/ApiClient.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace App\Service; +namespace App\Mailjet; use App\Exception\NewsletterProviderException; use Psr\Log\LoggerInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; -class MailjetApiClient +class ApiClient { private const string DEFAULT_BASE_URL = 'https://api.mailjet.com/v3/REST'; private const int NAME_MAX_LENGTH = 255; diff --git a/src/Message/CreateClickUpBookingTaskMessage.php b/src/Message/CreateClickUpBookingTaskMessage.php new file mode 100644 index 0000000..d311b5f --- /dev/null +++ b/src/Message/CreateClickUpBookingTaskMessage.php @@ -0,0 +1,13 @@ +clickUpClient->isConfigured()) { + $this->logger->warning('ClickUp is not configured, skipping task creation', [ + 'bookingId' => $message->bookingId, + ]); + + return; + } + + $booking = $this->bookingRepository->find($message->bookingId); + if (null === $booking) { + // The booking is gone — a retry cannot bring it back. + $this->logger->warning('Accommodation booking not found, skipping ClickUp task creation', [ + 'bookingId' => $message->bookingId, + ]); + + return; + } + + $hotelCode = $booking->getAccommodation()?->getCalendarCode(); + $dateFrom = $booking->getDateFrom(); + $contactName = trim(trim((string) $booking->getFirstName()).' '.trim((string) $booking->getLastName())); + + if (null === $hotelCode || '' === $hotelCode || null === $dateFrom || '' === $contactName) { + // A task named " 2026-08-20 " helps nobody, and no amount of retrying fills the gaps. + $this->logger->warning('Accommodation booking lacks the data for a ClickUp task name, skipping', [ + 'bookingId' => $message->bookingId, + ]); + + return; + } + + $name = sprintf('%s %s %s', $hotelCode, $dateFrom->format('Y-m-d'), $contactName); + + $taskId = $this->clickUpClient->createTaskFromTemplate($name); + + try { + $this->clickUpClient->updateTaskStatus($taskId, self::TASK_STATUS); + } catch (ClickUpException $e) { + // Deliberately swallowed: letting this bubble would retry the whole handler and create + // a second task in ClickUp. A task with the wrong status can be fixed by hand, a + // duplicate task cannot be un-created. + $this->logger->error('ClickUp task created but status update failed', [ + 'bookingId' => $message->bookingId, + 'taskId' => $taskId, + 'status' => self::TASK_STATUS, + 'error' => $e->getMessage(), + ]); + + return; + } + + $this->logger->info('Created ClickUp task for accommodation booking', [ + 'bookingId' => $message->bookingId, + 'taskId' => $taskId, + ]); + } +} diff --git a/src/Service/NewsletterManager.php b/src/Service/NewsletterManager.php index 9513a3f..a297d21 100644 --- a/src/Service/NewsletterManager.php +++ b/src/Service/NewsletterManager.php @@ -9,6 +9,7 @@ use App\Entity\NewsletterConsent; use App\Entity\NewsletterOptInRequest; use App\Exception\NewsletterListNotAllowedException; use App\Exception\NewsletterProviderException; +use App\Mailjet\ApiClient; use App\Model\NewsletterConfirmationResult; use App\Model\NewsletterSubscriptionRequestResult; use App\Repository\NewsletterConsentRepository; @@ -27,7 +28,7 @@ class NewsletterManager private readonly NewsletterOptInRequestRepository $optInRequestRepository, private readonly NewsletterConsentRepository $consentRepository, private readonly EntityManagerInterface $entityManager, - private readonly MailjetApiClient $newsletterService, + private readonly ApiClient $newsletterService, private readonly Mailer $mailer, private readonly LoggerInterface $logger, private readonly int $newsletterConfirmationTtlHours, diff --git a/tests/ClickUp/ApiClientTest.php b/tests/ClickUp/ApiClientTest.php new file mode 100644 index 0000000..6208796 --- /dev/null +++ b/tests/ClickUp/ApiClientTest.php @@ -0,0 +1,99 @@ + 'abc123'], JSON_THROW_ON_ERROR), [ + 'response_headers' => ['content-type' => 'application/json'], + ]); + }); + + $taskId = $this->client($http)->createTaskFromTemplate('WIEN01 2026-08-20 Müller'); + + self::assertSame('abc123', $taskId); + self::assertSame('POST', $requests[0][0]); + self::assertSame('https://clickup.test/list/555/taskTemplate/tpl-7', $requests[0][1]); + self::assertSame(['name' => 'WIEN01 2026-08-20 Müller'], json_decode((string) $requests[0][2]['body'], true, 512, JSON_THROW_ON_ERROR)); + self::assertContains('Authorization: pk_secret', $requests[0][2]['headers']); + } + + public function testUpdateTaskStatusPutsTheStatus(): void + { + $requests = []; + $http = new MockHttpClient(function (string $method, string $url, array $options) use (&$requests): MockResponse { + $requests[] = [$method, $url, $options]; + + return new MockResponse(json_encode(['id' => 'abc123'], JSON_THROW_ON_ERROR), [ + 'response_headers' => ['content-type' => 'application/json'], + ]); + }); + + $this->client($http)->updateTaskStatus('abc123', 'GRO'); + + self::assertSame('PUT', $requests[0][0]); + self::assertSame('https://clickup.test/task/abc123', $requests[0][1]); + self::assertSame(['status' => 'GRO'], json_decode((string) $requests[0][2]['body'], true, 512, JSON_THROW_ON_ERROR)); + } + + public function testAResponseWithoutATaskIdIsAnError(): void + { + $http = new MockHttpClient(new MockResponse(json_encode(['err' => 'nope'], JSON_THROW_ON_ERROR), [ + 'response_headers' => ['content-type' => 'application/json'], + ])); + + $this->expectException(ClickUpException::class); + + $this->client($http)->createTaskFromTemplate('WIEN01 2026-08-20 Müller'); + } + + public function testAnHttpErrorIsWrappedInADomainException(): void + { + $http = new MockHttpClient(new MockResponse('rate limited', ['http_code' => 429])); + + $this->expectException(ClickUpException::class); + + $this->client($http)->createTaskFromTemplate('WIEN01 2026-08-20 Müller'); + } + + public function testAnUnconfiguredClientReportsItselfAndRefusesToRequest(): void + { + $http = new MockHttpClient(function (): MockResponse { + self::fail('No request must be made when the client is unconfigured.'); + }); + + $client = new ApiClient($http, new NullLogger(), 'pk_secret', '', 'tpl-7', 'https://clickup.test'); + + self::assertFalse($client->isConfigured()); + + $this->expectException(ClickUpException::class); + + $client->createTaskFromTemplate('WIEN01 2026-08-20 Müller'); + } + + public function testAFullyConfiguredClientReportsItself(): void + { + self::assertTrue($this->client(new MockHttpClient())->isConfigured()); + self::assertFalse((new ApiClient(new MockHttpClient(), new NullLogger()))->isConfigured()); + } + + private function client(MockHttpClient $http): ApiClient + { + return new ApiClient($http, new NullLogger(), 'pk_secret', '555', 'tpl-7', 'https://clickup.test'); + } +} diff --git a/tests/Command/MailjetNewsletterWebhookCommandTest.php b/tests/Command/MailjetNewsletterWebhookCommandTest.php index db3ce42..9b85f63 100644 --- a/tests/Command/MailjetNewsletterWebhookCommandTest.php +++ b/tests/Command/MailjetNewsletterWebhookCommandTest.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace App\Tests\Command; use App\Command\MailjetNewsletterWebhookCommand; -use App\Service\MailjetApiClient; +use App\Mailjet\ApiClient; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\Console\Command\Command; @@ -15,7 +15,7 @@ class MailjetNewsletterWebhookCommandTest extends TestCase { public function testRegisterCreatesWebhookAndPrintsPayload(): void { - $mailjet = $this->createMock(MailjetApiClient::class); + $mailjet = $this->createMock(ApiClient::class); $mailjet->expects(self::once()) ->method('createEventCallbackUrl') ->with( @@ -43,7 +43,7 @@ class MailjetNewsletterWebhookCommandTest extends TestCase public function testRemoveDeletesWebhookById(): void { - $mailjet = $this->createMock(MailjetApiClient::class); + $mailjet = $this->createMock(ApiClient::class); $mailjet->expects(self::never())->method('createEventCallbackUrl'); $mailjet->expects(self::once()) ->method('deleteEventCallbackUrl') @@ -58,7 +58,7 @@ class MailjetNewsletterWebhookCommandTest extends TestCase public function testDeactivateUsesRemoveFlow(): void { - $mailjet = $this->createMock(MailjetApiClient::class); + $mailjet = $this->createMock(ApiClient::class); $mailjet->expects(self::never())->method('createEventCallbackUrl'); $mailjet->expects(self::once()) ->method('deleteEventCallbackUrl') @@ -73,7 +73,7 @@ class MailjetNewsletterWebhookCommandTest extends TestCase public function testRemoveFailsWithoutCallbackId(): void { - $mailjet = $this->createMock(MailjetApiClient::class); + $mailjet = $this->createMock(ApiClient::class); $mailjet->expects(self::never())->method('createEventCallbackUrl'); $mailjet->expects(self::never())->method('deleteEventCallbackUrl'); @@ -84,7 +84,7 @@ class MailjetNewsletterWebhookCommandTest extends TestCase self::assertStringContainsString('A numeric callback ID is required', $tester->getDisplay()); } - private function createCommand(MailjetApiClient $mailjetApiClient): MailjetNewsletterWebhookCommand + private function createCommand(ApiClient $mailjetApiClient): MailjetNewsletterWebhookCommand { return new MailjetNewsletterWebhookCommand( $mailjetApiClient, diff --git a/tests/Controller/Admin/AccommodationBooking/ConfirmControllerTest.php b/tests/Controller/Admin/AccommodationBooking/ConfirmControllerTest.php index 6f29804..e885a7c 100644 --- a/tests/Controller/Admin/AccommodationBooking/ConfirmControllerTest.php +++ b/tests/Controller/Admin/AccommodationBooking/ConfirmControllerTest.php @@ -7,11 +7,15 @@ namespace App\Tests\Controller\Admin\AccommodationBooking; use App\Controller\Admin\AccommodationBooking\ConfirmController; use App\Entity\Groups\AccommodationBooking; use App\Enum\Groups\AccommodationBookingStatus; +use App\Message\CreateClickUpBookingTaskMessage; use App\Service\AccommodationBookingService; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Messenger\Envelope; +use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException; /** @@ -25,7 +29,7 @@ class ConfirmControllerTest extends TestCase $bookingService = $this->createMock(AccommodationBookingService::class); $bookingService->expects(self::never())->method('confirmBooking'); - $controller = new TestableConfirmController($bookingService, $this->createMock(LoggerInterface::class)); + $controller = new TestableConfirmController($bookingService, $this->neverDispatchingBus(), $this->createMock(LoggerInterface::class)); $response = $controller->index($this->receivedBooking(), Request::create('/admin/accommodation-booking/1/confirm')); @@ -40,7 +44,14 @@ class ConfirmControllerTest extends TestCase $bookingService = $this->createMock(AccommodationBookingService::class); $bookingService->expects(self::once())->method('confirmBooking')->with($booking); - $controller = new TestableConfirmController($bookingService, $this->createMock(LoggerInterface::class)); + // The ClickUp task is created asynchronously because the ClickUp API is slow. + $messageBus = $this->createMock(MessageBusInterface::class); + $messageBus->expects(self::once()) + ->method('dispatch') + ->with(self::isInstanceOf(CreateClickUpBookingTaskMessage::class)) + ->willReturnCallback(static fn (object $message): Envelope => new Envelope($message)); + + $controller = new TestableConfirmController($bookingService, $messageBus, $this->createMock(LoggerInterface::class)); $response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/confirm', 'POST')); @@ -52,7 +63,7 @@ class ConfirmControllerTest extends TestCase $bookingService = $this->createMock(AccommodationBookingService::class); $bookingService->expects(self::never())->method('confirmBooking'); - $controller = new TestableConfirmController($bookingService, $this->createMock(LoggerInterface::class), tokenValid: false); + $controller = new TestableConfirmController($bookingService, $this->neverDispatchingBus(), $this->createMock(LoggerInterface::class), tokenValid: false); $this->expectException(AccessDeniedException::class); @@ -70,7 +81,7 @@ class ConfirmControllerTest extends TestCase $bookingService = $this->createMock(AccommodationBookingService::class); $bookingService->expects(self::never())->method('confirmBooking'); - $controller = new TestableConfirmController($bookingService, $this->createMock(LoggerInterface::class)); + $controller = new TestableConfirmController($bookingService, $this->neverDispatchingBus(), $this->createMock(LoggerInterface::class)); $response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/confirm', 'POST')); @@ -97,7 +108,7 @@ class ConfirmControllerTest extends TestCase $bookingService = $this->createMock(AccommodationBookingService::class); $bookingService->expects(self::never())->method('confirmBooking'); - $controller = new TestableConfirmController($bookingService, $this->createMock(LoggerInterface::class)); + $controller = new TestableConfirmController($bookingService, $this->neverDispatchingBus(), $this->createMock(LoggerInterface::class)); $response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/confirm', 'POST')); @@ -105,6 +116,17 @@ class ConfirmControllerTest extends TestCase self::assertStringContainsString('app_admin_accommodationbooking_edit', (string) $response->headers->get('HX-Redirect')); } + /** + * @return MessageBusInterface&MockObject + */ + private function neverDispatchingBus(): MessageBusInterface + { + $messageBus = $this->createMock(MessageBusInterface::class); + $messageBus->expects(self::never())->method('dispatch'); + + return $messageBus; + } + private function receivedBooking(): AccommodationBooking { $booking = new AccommodationBooking(); @@ -125,10 +147,11 @@ final class TestableConfirmController extends ConfirmController public function __construct( AccommodationBookingService $bookingService, + MessageBusInterface $messageBus, LoggerInterface $logger, private readonly bool $tokenValid = true, ) { - parent::__construct($bookingService, $logger); + parent::__construct($bookingService, $messageBus, $logger); } protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool diff --git a/tests/Service/MailjetApiClientTest.php b/tests/Mailjet/ApiClientTest.php similarity index 92% rename from tests/Service/MailjetApiClientTest.php rename to tests/Mailjet/ApiClientTest.php index bf61cac..1ea9385 100644 --- a/tests/Service/MailjetApiClientTest.php +++ b/tests/Mailjet/ApiClientTest.php @@ -2,15 +2,15 @@ declare(strict_types=1); -namespace App\Tests\Service; +namespace App\Tests\Mailjet; -use App\Service\MailjetApiClient; +use App\Mailjet\ApiClient; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; use Symfony\Component\HttpClient\MockHttpClient; use Symfony\Component\HttpClient\Response\MockResponse; -class MailjetApiClientTest extends TestCase +class ApiClientTest extends TestCase { public function testChecksSubscriptionAgainstSuppliedListId(): void { @@ -37,7 +37,7 @@ class MailjetApiClientTest extends TestCase ], JSON_THROW_ON_ERROR)); }); - $mailjet = new MailjetApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', '111'); + $mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', '111'); $subscribed = $mailjet->isSubscribed(' Customer@Example.COM ', 123); @@ -70,7 +70,7 @@ class MailjetApiClientTest extends TestCase ], JSON_THROW_ON_ERROR)); }); - $mailjet = new MailjetApiClient( + $mailjet = new ApiClient( $client, new NullLogger(), 'key', @@ -134,7 +134,7 @@ class MailjetApiClientTest extends TestCase return new MockResponse(json_encode(['Data' => []], JSON_THROW_ON_ERROR)); }); - $mailjet = new MailjetApiClient( + $mailjet = new ApiClient( $client, new NullLogger(), 'key', @@ -166,7 +166,7 @@ class MailjetApiClientTest extends TestCase return new MockResponse(json_encode(['Data' => []], JSON_THROW_ON_ERROR)); }); - $mailjet = new MailjetApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', '111'); + $mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', '111'); $mailjet->upsertContact(' Customer@Example.COM '); @@ -182,7 +182,7 @@ class MailjetApiClientTest extends TestCase return new MockResponse(json_encode(['Data' => []], JSON_THROW_ON_ERROR)); }); - $mailjet = new MailjetApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', '111'); + $mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', '111'); $mailjet->ensureSubscribed(' Customer@Example.COM ', 456); diff --git a/tests/Service/MailjetNewsletterWebhookClientTest.php b/tests/Mailjet/MailjetNewsletterWebhookClientTest.php similarity index 85% rename from tests/Service/MailjetNewsletterWebhookClientTest.php rename to tests/Mailjet/MailjetNewsletterWebhookClientTest.php index 37c9464..b2ff9e6 100644 --- a/tests/Service/MailjetNewsletterWebhookClientTest.php +++ b/tests/Mailjet/MailjetNewsletterWebhookClientTest.php @@ -2,9 +2,9 @@ declare(strict_types=1); -namespace App\Tests\Service; +namespace App\Tests\Mailjet; -use App\Service\MailjetApiClient; +use App\Mailjet\ApiClient; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; use Symfony\Component\HttpClient\MockHttpClient; @@ -33,7 +33,7 @@ class MailjetNewsletterWebhookClientTest extends TestCase ], JSON_THROW_ON_ERROR)); }); - $mailjet = new MailjetApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', '111'); + $mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', '111'); $response = $mailjet->createEventCallbackUrl('unsub', 'https://my.ep-reisen.de/webhooks/mailjet/newsletter'); self::assertSame(456, $response['Data'][0]['ID']); @@ -48,7 +48,7 @@ class MailjetNewsletterWebhookClientTest extends TestCase return new MockResponse('', ['http_code' => 204]); }); - $mailjet = new MailjetApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', '111'); + $mailjet = new ApiClient($client, new NullLogger(), 'key', 'secret', 'https://mailjet.test', '111'); $mailjet->deleteEventCallbackUrl(456); } } diff --git a/tests/MessageHandler/CreateClickUpBookingTaskHandlerTest.php b/tests/MessageHandler/CreateClickUpBookingTaskHandlerTest.php new file mode 100644 index 0000000..1878b9a --- /dev/null +++ b/tests/MessageHandler/CreateClickUpBookingTaskHandlerTest.php @@ -0,0 +1,132 @@ +createMock(AccommodationBookingRepository::class); + $repository->method('find')->with(42)->willReturn($this->booking()); + + $client = $this->createMock(ApiClient::class); + $client->method('isConfigured')->willReturn(true); + $client->expects(self::once()) + ->method('createTaskFromTemplate') + ->with('WIEN01 2026-08-20 Anna Müller') + ->willReturn('abc123'); + $client->expects(self::once()) + ->method('updateTaskStatus') + ->with('abc123', CreateClickUpBookingTaskHandler::TASK_STATUS); + + $this->handler($repository, $client)(new CreateClickUpBookingTaskMessage(42)); + } + + public function testAnUnconfiguredClientIsSkippedWithoutTouchingTheDatabase(): void + { + $repository = $this->createMock(AccommodationBookingRepository::class); + $repository->expects(self::never())->method('find'); + + $client = $this->createMock(ApiClient::class); + $client->method('isConfigured')->willReturn(false); + $client->expects(self::never())->method('createTaskFromTemplate'); + + $this->handler($repository, $client)(new CreateClickUpBookingTaskMessage(42)); + } + + public function testAVanishedBookingIsSkipped(): void + { + $repository = $this->createMock(AccommodationBookingRepository::class); + $repository->method('find')->willReturn(null); + + $client = $this->createMock(ApiClient::class); + $client->method('isConfigured')->willReturn(true); + $client->expects(self::never())->method('createTaskFromTemplate'); + + $this->handler($repository, $client)(new CreateClickUpBookingTaskMessage(42)); + } + + /** + * @dataProvider incompleteBookings + */ + public function testABookingWithoutTheDataForATaskNameIsSkipped(callable $mutate): void + { + $booking = $this->booking(); + $mutate($booking); + + $repository = $this->createMock(AccommodationBookingRepository::class); + $repository->method('find')->willReturn($booking); + + $client = $this->createMock(ApiClient::class); + $client->method('isConfigured')->willReturn(true); + $client->expects(self::never())->method('createTaskFromTemplate'); + + $this->handler($repository, $client)(new CreateClickUpBookingTaskMessage(42)); + } + + /** + * @return iterable + */ + public static function incompleteBookings(): iterable + { + yield 'without an accommodation' => [static fn (AccommodationBooking $b) => $b->setAccommodation(null)]; + yield 'without a contact name' => [static function (AccommodationBooking $b): void { + $b->setFirstName(null); + $b->setLastName(null); + }]; + yield 'with a blank contact name' => [static function (AccommodationBooking $b): void { + $b->setFirstName(' '); + $b->setLastName(' '); + }]; + } + + public function testAFailingStatusUpdateDoesNotRetryTheTaskCreation(): void + { + $repository = $this->createMock(AccommodationBookingRepository::class); + $repository->method('find')->willReturn($this->booking()); + + $client = $this->createMock(ApiClient::class); + $client->method('isConfigured')->willReturn(true); + $client->method('createTaskFromTemplate')->willReturn('abc123'); + $client->method('updateTaskStatus')->willThrowException(new ClickUpException('boom')); + + $this->handler($repository, $client)(new CreateClickUpBookingTaskMessage(42)); + + // Reaching this point is the assertion: the exception must not bubble up into the + // transport, because a retry would create a second ClickUp task. + self::assertTrue(true); + } + + private function handler( + AccommodationBookingRepository $repository, + ApiClient $client, + ): CreateClickUpBookingTaskHandler { + return new CreateClickUpBookingTaskHandler($repository, $client, new NullLogger()); + } + + private function booking(): AccommodationBooking + { + $accommodation = new Accommodation(); + $accommodation->setCalendarCode('WIEN01'); + + $booking = new AccommodationBooking(); + $booking->setAccommodation($accommodation); + $booking->setDateFrom(new \DateTimeImmutable('2026-08-20')); + $booking->setFirstName('Anna'); + $booking->setLastName('Müller'); + + return $booking; + } +} diff --git a/tests/Service/NewsletterManagerTest.php b/tests/Service/NewsletterManagerTest.php index 40f9c38..4cff18a 100644 --- a/tests/Service/NewsletterManagerTest.php +++ b/tests/Service/NewsletterManagerTest.php @@ -8,11 +8,11 @@ use App\Email\Mailer; use App\Entity\NewsletterConsent; use App\Entity\NewsletterOptInRequest; use App\Exception\NewsletterListNotAllowedException; +use App\Mailjet\ApiClient; use App\Model\NewsletterConfirmationResult; use App\Model\NewsletterSubscriptionRequestResult; use App\Repository\NewsletterConsentRepository; use App\Repository\NewsletterOptInRequestRepository; -use App\Service\MailjetApiClient; use App\Service\NewsletterManager; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\MockObject\MockObject; @@ -26,7 +26,7 @@ class NewsletterManagerTest extends TestCase $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); - $mailjet = $this->createMock(MailjetApiClient::class); + $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('isSubscribed'); @@ -83,7 +83,7 @@ class NewsletterManagerTest extends TestCase $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); - $mailjet = $this->createMock(MailjetApiClient::class); + $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('isSubscribed'); @@ -114,7 +114,7 @@ class NewsletterManagerTest extends TestCase $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); - $mailjet = $this->createMock(MailjetApiClient::class); + $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('isSubscribed'); @@ -147,7 +147,7 @@ class NewsletterManagerTest extends TestCase $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); - $mailjet = $this->createMock(MailjetApiClient::class); + $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('isSubscribed'); @@ -191,7 +191,7 @@ class NewsletterManagerTest extends TestCase $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); - $mailjet = $this->createMock(MailjetApiClient::class); + $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('isSubscribed'); @@ -219,7 +219,7 @@ class NewsletterManagerTest extends TestCase $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); - $mailjet = $this->createMock(MailjetApiClient::class); + $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('isSubscribed'); @@ -251,7 +251,7 @@ class NewsletterManagerTest extends TestCase $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); - $mailjet = $this->createMock(MailjetApiClient::class); + $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('isSubscribed'); @@ -276,7 +276,7 @@ class NewsletterManagerTest extends TestCase $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); - $mailjet = $this->createMock(MailjetApiClient::class); + $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('ensureSubscribed'); @@ -303,7 +303,7 @@ class NewsletterManagerTest extends TestCase $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); - $mailjet = $this->createMock(MailjetApiClient::class); + $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $mailjet->expects(self::never())->method('ensureSubscribed'); @@ -339,7 +339,7 @@ class NewsletterManagerTest extends TestCase $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); - $mailjet = $this->createMock(MailjetApiClient::class); + $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $repository->expects(self::once())->method('findByTokenHash')->with(hash('sha256', $token))->willReturn($confirmation); @@ -370,7 +370,7 @@ class NewsletterManagerTest extends TestCase $repository = $this->createMock(NewsletterOptInRequestRepository::class); $consents = $this->createMock(NewsletterConsentRepository::class); $entityManager = $this->createMock(EntityManagerInterface::class); - $mailjet = $this->createMock(MailjetApiClient::class); + $mailjet = $this->createMock(ApiClient::class); $mailer = $this->createMock(Mailer::class); $repository->expects(self::once())->method('findByTokenHash')->with(hash('sha256', $token))->willReturn($confirmation); @@ -434,17 +434,17 @@ class NewsletterManagerTest extends TestCase /** * @param MockObject&NewsletterOptInRequestRepository $repository - * @param MockObject&NewsletterConsentRepository $consents - * @param MockObject&EntityManagerInterface $entityManager - * @param MockObject&MailjetApiClient $mailjet - * @param MockObject&Mailer $mailer - * @param array $mailjetLists + * @param MockObject&NewsletterConsentRepository $consents + * @param MockObject&EntityManagerInterface $entityManager + * @param MockObject&ApiClient $mailjet + * @param MockObject&Mailer $mailer + * @param array $mailjetLists */ private function createManager( NewsletterOptInRequestRepository $repository, NewsletterConsentRepository $consents, EntityManagerInterface $entityManager, - MailjetApiClient $mailjet, + ApiClient $mailjet, Mailer $mailer, array $mailjetLists = [ 10321569 => 'E&P Newsletter',